Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Checkpoint incomplete calibration work (cond SDT green, evap SST failing) plus related solver/UI changes so the next pass can fix and extend safely. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
222
.entropyk/modules/calibration.ekmod
Normal file
222
.entropyk/modules/calibration.ekmod
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "comp",
|
||||||
|
"params": {
|
||||||
|
"displacement_m3": 0.000065,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"isentropic_efficiency": 0.7,
|
||||||
|
"speed_hz": 50,
|
||||||
|
"superheat_k": 5,
|
||||||
|
"t_cond_k": 318.15,
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"volumetric_efficiency": 0.92
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "IsentropicCompressor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "cond",
|
||||||
|
"params": {
|
||||||
|
"__fixed_calib_sdt_c": false,
|
||||||
|
"__fixed_z_dp": true,
|
||||||
|
"__fixed_z_ua": true,
|
||||||
|
"calib_sdt_c": 40,
|
||||||
|
"channel_spacing_mm": 1.5,
|
||||||
|
"chevron_angle_deg": 60,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"corrugation_pitch_mm": 3,
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"manufacturer": "",
|
||||||
|
"model": "",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_thickness_mm": 0.6,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"target_subcooling_k": 5,
|
||||||
|
"ua": 2500,
|
||||||
|
"z_dp": 1,
|
||||||
|
"z_ua": 1
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "BphxCondenser"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "exv",
|
||||||
|
"params": {
|
||||||
|
"__fixed_opening": true,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"opening": 1,
|
||||||
|
"t_evap_k": 278.15
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "IsenthalpicExpansionValve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "evap",
|
||||||
|
"params": {
|
||||||
|
"__fixed_calib_sst_c": false,
|
||||||
|
"__fixed_z_dp": true,
|
||||||
|
"__fixed_z_ua": false,
|
||||||
|
"calib_sst_c": 5,
|
||||||
|
"channel_spacing_mm": 1.5,
|
||||||
|
"chevron_angle_deg": 60,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"corrugation_pitch_mm": 3,
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"manufacturer": "",
|
||||||
|
"model": "",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_thickness_mm": 0.6,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"target_superheat_k": 5,
|
||||||
|
"ua": 2000,
|
||||||
|
"z_dp": 1,
|
||||||
|
"z_ua": 1
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "BphxEvaporator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "cond_water_in",
|
||||||
|
"params": {
|
||||||
|
"__fixed_m_flow_kg_s": true,
|
||||||
|
"__fixed_p_set_bar": false,
|
||||||
|
"__fixed_t_set_c": true,
|
||||||
|
"fluid": "Water",
|
||||||
|
"m_flow_kg_s": 0.4,
|
||||||
|
"p_set_bar": 2,
|
||||||
|
"t_set_c": 30
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "BrineSource"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "cond_water_out",
|
||||||
|
"params": {
|
||||||
|
"__fixed_p_back_bar": true,
|
||||||
|
"__fixed_t_set_c": false,
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 2,
|
||||||
|
"t_set_c": 7
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "BrineSink"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "evap_water_in",
|
||||||
|
"params": {
|
||||||
|
"__fixed_m_flow_kg_s": true,
|
||||||
|
"__fixed_p_set_bar": false,
|
||||||
|
"__fixed_t_set_c": true,
|
||||||
|
"fluid": "Water",
|
||||||
|
"m_flow_kg_s": 0.5,
|
||||||
|
"p_set_bar": 3,
|
||||||
|
"t_set_c": 12
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "BrineSource"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "evap_water_out",
|
||||||
|
"params": {
|
||||||
|
"__fixed_p_back_bar": true,
|
||||||
|
"__fixed_t_set_c": false,
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 3,
|
||||||
|
"t_set_c": 7
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "BrineSink"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"circuit": 0,
|
||||||
|
"flipH": false,
|
||||||
|
"flipV": false,
|
||||||
|
"name": "SST probe",
|
||||||
|
"params": {
|
||||||
|
"__fixed_target": true,
|
||||||
|
"fluid": "R134a",
|
||||||
|
"measure": "SST",
|
||||||
|
"target": 5.9
|
||||||
|
},
|
||||||
|
"rotation": 0,
|
||||||
|
"type": "Probe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"from": "comp:outlet",
|
||||||
|
"to": "cond:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "cond:outlet",
|
||||||
|
"to": "exv:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "exv:outlet",
|
||||||
|
"to": "evap:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "cond_water_in:outlet",
|
||||||
|
"to": "cond:secondary_inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "cond:secondary_outlet",
|
||||||
|
"to": "cond_water_out:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "evap_water_in:outlet",
|
||||||
|
"to": "evap:secondary_inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "evap:secondary_outlet",
|
||||||
|
"to": "evap_water_out:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "evap:outlet",
|
||||||
|
"to": "SST probe:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "SST probe:outlet",
|
||||||
|
"to": "comp:inlet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"instances": [],
|
||||||
|
"params": {},
|
||||||
|
"ports": {
|
||||||
|
"inlet": "comp:inlet"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
# - Dev moves story to 'review', then Dev runs code-review (fresh context, ideally different LLM)
|
# - Dev moves story to 'review', then Dev runs code-review (fresh context, ideally different LLM)
|
||||||
|
|
||||||
generated: 2026-07-18T12:51:31Z
|
generated: 2026-07-18T12:51:31Z
|
||||||
last_updated: 2026-07-19T14:40:00Z
|
last_updated: 2026-07-19T16:00:00Z
|
||||||
project: Entropyk
|
project: Entropyk
|
||||||
project_key: ENTROPYK
|
project_key: ENTROPYKnon
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
story_location: "{project-root}/_bmad-output/implementation-artifacts"
|
story_location: "{project-root}/_bmad-output/implementation-artifacts"
|
||||||
|
|
||||||
development_status:
|
development_status:
|
||||||
epic-0: done
|
epic-0: in-progress
|
||||||
0-1-fix-failing-tests: done
|
0-1-fix-failing-tests: done
|
||||||
epic-0-retrospective: optional
|
epic-0-retrospective: optional
|
||||||
# --- Epic 0: Physical Regularization & Jacobian Health (epics-solver-hp.md) ---
|
# --- Epic 0: Physical Regularization & Jacobian Health (epics-solver-hp.md) ---
|
||||||
@@ -42,8 +42,8 @@ development_status:
|
|||||||
0-3-valve-flow-exv-regularization: done
|
0-3-valve-flow-exv-regularization: done
|
||||||
0-4-heat-exchanger-regularization: done
|
0-4-heat-exchanger-regularization: done
|
||||||
0-5-domain-sweep-jacobian-health-gate: done
|
0-5-domain-sweep-jacobian-health-gate: done
|
||||||
0-6-emergent-pressure-seed-robustness: review
|
|
||||||
0-7-bphx-property-call-reduction: review
|
0-7-bphx-property-call-reduction: review
|
||||||
|
0-8-bphx-phase-change-entu-duty: review
|
||||||
# --- Epic 1: Performance Foundation - Linear Algebra Modernization ---
|
# --- Epic 1: Performance Foundation - Linear Algebra Modernization ---
|
||||||
epic-1: in-progress
|
epic-1: in-progress
|
||||||
1-1-phase-0-baseline-golden-safety-net: review
|
1-1-phase-0-baseline-golden-safety-net: review
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "@/lib/mediaStyle";
|
} from "@/lib/mediaStyle";
|
||||||
import {
|
import {
|
||||||
findNearestEdge,
|
findNearestEdge,
|
||||||
isPipeType,
|
isInsertableOnEdge,
|
||||||
pipeMediaKind,
|
pipeMediaKind,
|
||||||
} from "@/lib/edgeInsert";
|
} from "@/lib/edgeInsert";
|
||||||
import { defaultParams } from "@/lib/componentMeta";
|
import { defaultParams } from "@/lib/componentMeta";
|
||||||
@@ -154,17 +154,24 @@ export default function Canvas({ onOpenModuleEditor }: CanvasProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipe / duct dropped on a wire → splice into the circuit (Modelica-style).
|
// Pipe / duct / Probe dropped on a wire → splice into the circuit
|
||||||
if (isPipeType(type)) {
|
// (Modelica-style). Probe is a zero-residual measurement tap that uses
|
||||||
const prefer = pipeMediaKind(type, defaultParams(type));
|
// the same insertOnEdge path.
|
||||||
|
if (isInsertableOnEdge(type)) {
|
||||||
|
// A Probe can tap ANY medium (refrigerant / water / air) — a sensor sits
|
||||||
|
// wherever the physics is, so we don't filter by media for it. Pipes
|
||||||
|
// still get media filtering so a water pipe doesn't splice into a
|
||||||
|
// refrigerant line.
|
||||||
|
const prefer = type === "Probe" ? undefined : pipeMediaKind(type, defaultParams(type));
|
||||||
const hit = findNearestEdge(position, nodes, edges, {
|
const hit = findNearestEdge(position, nodes, edges, {
|
||||||
maxDistance: 64,
|
// Probes are small targets — be more forgiving on the drop distance.
|
||||||
|
maxDistance: type === "Probe" ? 96 : 64,
|
||||||
preferMedia: prefer,
|
preferMedia: prefer,
|
||||||
});
|
});
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const result = insertOnEdge(type, hit.midpoint, hit.edge.id);
|
const result = insertOnEdge(type, hit.midpoint, hit.edge.id);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
// Media mismatch: fall through to free placement.
|
// Media mismatch / splice failed: fall through to free placement.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,22 @@ const GLYPHS: Record<string, (c: string) => ReactElement> = {
|
|||||||
<path d="M16 60 C26 34 38 34 48 60 S 70 78 84 52" {...accent(c, 2)} fill="none" />
|
<path d="M16 60 C26 34 38 34 48 60 S 70 78 84 52" {...accent(c, 2)} fill="none" />
|
||||||
</Frame>
|
</Frame>
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Probe — small measurement tap on a connection line ─────────
|
||||||
|
// A filled circle with a thin ring + a small dot inside. Deliberately
|
||||||
|
// compact (the 36×36 node footprint makes it read as a tap, not a block).
|
||||||
|
// The four short ticks hint at a sensor crosshair.
|
||||||
|
probe: (c) => (
|
||||||
|
<Frame>
|
||||||
|
<circle cx="50" cy="50" r="34" {...line(2)} />
|
||||||
|
<circle cx="50" cy="50" r="20" {...accent(c, 1.6)} />
|
||||||
|
<circle cx="50" cy="50" r="6" fill={c} stroke="none" />
|
||||||
|
<path
|
||||||
|
d="M50 8 V22 M50 78 V92 M8 50 H22 M78 50 H92"
|
||||||
|
{...line(1.4)}
|
||||||
|
/>
|
||||||
|
</Frame>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
|
function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
|
||||||
@@ -314,6 +330,7 @@ function glyphKey(type?: string, opts?: HxVisualOpts): keyof typeof GLYPHS {
|
|||||||
if (hx && hx in GLYPHS) return hx as keyof typeof GLYPHS;
|
if (hx && hx in GLYPHS) return hx as keyof typeof GLYPHS;
|
||||||
|
|
||||||
if (t === "SaturatedController") return "control";
|
if (t === "SaturatedController") return "control";
|
||||||
|
if (t === "Probe") return "probe";
|
||||||
if (t.includes("Compressor")) return "compressor";
|
if (t.includes("Compressor")) return "compressor";
|
||||||
if (t === "HeatExchanger") return "hx";
|
if (t === "HeatExchanger") return "hx";
|
||||||
if (t === "ReversingValve") return "reversing";
|
if (t === "ReversingValve") return "reversing";
|
||||||
|
|||||||
@@ -427,17 +427,25 @@ export default function PropertiesPanel() {
|
|||||||
: isParamFixed(params, p);
|
: isParamFixed(params, p);
|
||||||
const free = Boolean(p.fixable && !fixed);
|
const free = Boolean(p.fixable && !fixed);
|
||||||
const displayVal = effectiveParamValue(params, p);
|
const displayVal = effectiveParamValue(params, p);
|
||||||
|
// Probe: label the target row with the chosen measurement kind
|
||||||
|
// (SST / SDT / SH / SC / …) instead of the generic "Cible".
|
||||||
|
const label =
|
||||||
|
node.data.type === "Probe" && p.key === "target"
|
||||||
|
? (COMPONENT_BY_TYPE["Probe"]?.params
|
||||||
|
.find((mp) => mp.key === "measure")
|
||||||
|
?.options?.find((o) => o.value === params.measure)?.label ?? p.label)
|
||||||
|
: p.label;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={p.key}
|
key={p.key}
|
||||||
className="border-b border-[var(--line)]/70 hover:bg-[var(--chrome-2)]/80"
|
className="border-b border-[var(--line)]/70 hover:bg-[var(--chrome-2)]/80"
|
||||||
title={p.description ?? p.label}
|
title={p.description ?? label}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
className="max-w-[140px] truncate px-2 py-1 text-[var(--ink-dim)]"
|
className="max-w-[140px] truncate px-2 py-1 text-[var(--ink-dim)]"
|
||||||
title={p.description ?? p.label}
|
title={p.description ?? label}
|
||||||
>
|
>
|
||||||
{p.label}
|
{label}
|
||||||
{p.required ? <span className="text-[var(--hot)]"> *</span> : null}
|
{p.required ? <span className="text-[var(--hot)]"> *</span> : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-0.5">
|
<td className="px-1 py-0.5">
|
||||||
@@ -543,9 +551,9 @@ export default function PropertiesPanel() {
|
|||||||
{activeTab === "Calibration" && (
|
{activeTab === "Calibration" && (
|
||||||
<div className="space-y-1.5 border-t border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1.5 text-[10px] leading-snug text-[var(--ink-dim)]">
|
<div className="space-y-1.5 border-t border-[var(--line)] bg-[var(--chrome-2)] px-2.5 py-1.5 text-[10px] leading-snug text-[var(--ink-dim)]">
|
||||||
<p>
|
<p>
|
||||||
<strong>Comment calibrer :</strong> coche Fixed sur la cible (SST/SDT), décoche
|
<strong>Calibration :</strong> pose une <strong>Probe</strong> (sonde) sur la
|
||||||
Fixed sur le facteur (Z_UA). Z_UA vaut <strong>1</strong> par défaut (pas de
|
ligne à mesurer, coche Fixed sur sa cible, puis décoche Fixed sur le facteur
|
||||||
correction).
|
(Z_UA) ici. La valeur résolue s’affiche ci-dessous après simulation.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[var(--ink-faint)]">
|
<p className="text-[var(--ink-faint)]">
|
||||||
Tu n’as pas besoin du nœud « Regulation loop » pour ça.
|
Tu n’as pas besoin du nœud « Regulation loop » pour ça.
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export function portRole(port: string): "source" | "target" {
|
|||||||
*/
|
*/
|
||||||
export function nodeSize(type: string): { w: number; h: number } {
|
export function nodeSize(type: string): { w: number; h: number } {
|
||||||
const t = String(type || "");
|
const t = String(type || "");
|
||||||
|
if (t === "Probe") return { w: 36, h: 36 };
|
||||||
if (t === "SaturatedController") return { w: 184, h: 96 };
|
if (t === "SaturatedController") return { w: 184, h: 96 };
|
||||||
if (t.includes("Compressor")) return { w: 76, h: 76 };
|
if (t.includes("Compressor")) return { w: 76, h: 76 };
|
||||||
if (t === "HeatExchanger") return { w: 92, h: 92 };
|
if (t === "HeatExchanger") return { w: 92, h: 92 };
|
||||||
@@ -207,11 +208,80 @@ export const COMPONENT_CATEGORIES = [
|
|||||||
"Piping",
|
"Piping",
|
||||||
"Hydraulics",
|
"Hydraulics",
|
||||||
"Boundaries",
|
"Boundaries",
|
||||||
|
"Instrumentation",
|
||||||
"Advanced",
|
"Advanced",
|
||||||
"Generic",
|
"Generic",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const COMPONENTS: ComponentMeta[] = [
|
export const COMPONENTS: ComponentMeta[] = [
|
||||||
|
// ── Instrumentation: measurement taps for calibration (Probe = all measurements) ──
|
||||||
|
{
|
||||||
|
type: "Probe",
|
||||||
|
label: "Probe (sonde)",
|
||||||
|
category: "Instrumentation",
|
||||||
|
description:
|
||||||
|
"Sonde de mesure posée sur une ligne. Lit P/T/SH/SC/SST/SDT/DGT/DSH/capacité/ṁ/h à cet endroit. Toute calibration impose sa cible sur un Probe.",
|
||||||
|
help:
|
||||||
|
"Sonde de calibration (Probe)\n" +
|
||||||
|
"Toute calibration se fait via un Probe posé sur une ligne (règle dure).\n\n" +
|
||||||
|
"1. Glisse le Probe sur un fil du schéma (comme pour insérer un Pipe).\n" +
|
||||||
|
"2. Choisis la grandeur mesurée (SST, SDT, DGT, DSH, SH, SC, T, P, débit, capacité, enthalpie) et le fluide.\n" +
|
||||||
|
"3. Coche Fixed sur « Cible » et tape la valeur mesurée.\n" +
|
||||||
|
"4. Libère le Z-factor correspondant (Z_UA, Z_dP, Z_flow…) sur le composant à calibrer.\n" +
|
||||||
|
"Le solveur appaire automatiquement le Probe (mesure) et le Z-factor libre (inconnue).",
|
||||||
|
ports: ["inlet", "outlet"],
|
||||||
|
color: "#0ea5e9",
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
key: "measure",
|
||||||
|
label: "Grandeur mesurée",
|
||||||
|
kind: "string",
|
||||||
|
default: "SH",
|
||||||
|
required: true,
|
||||||
|
section: "Mesure",
|
||||||
|
description:
|
||||||
|
"Grandeur physique mesurée à l'emplacement du Probe. SST/SDT = Tsat(P), SH/DSH = T−Tsat(P), SC = Tsat(P)−T.",
|
||||||
|
options: [
|
||||||
|
{ value: "SST", label: "SST (Tsat aspiration)" },
|
||||||
|
{ value: "SDT", label: "SDT (Tsat refoulement)" },
|
||||||
|
{ value: "DGT", label: "DGT (T° gaz refoulement)" },
|
||||||
|
{ value: "DSH", label: "DSH (surch. refoulement)" },
|
||||||
|
{ value: "SH", label: "SH (surchauffe)" },
|
||||||
|
{ value: "SC", label: "SC (sous-refroid.)" },
|
||||||
|
{ value: "T", label: "T (température)" },
|
||||||
|
{ value: "P", label: "P (pression)" },
|
||||||
|
{ value: "MassFlow", label: "Débit massique" },
|
||||||
|
{ value: "Capacity", label: "Capacité (kW)" },
|
||||||
|
{ value: "Enthalpy", label: "Enthalpie" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fluid",
|
||||||
|
label: "Fluide",
|
||||||
|
kind: "string",
|
||||||
|
default: "R134a",
|
||||||
|
required: true,
|
||||||
|
section: "Mesure",
|
||||||
|
description: "Fluide frigorigène pour le calcul de Tsat/SH/SC (via CoolProp).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "target",
|
||||||
|
label: "Cible (Fixed = impose)",
|
||||||
|
kind: "number",
|
||||||
|
section: "Calibration",
|
||||||
|
fixable: true,
|
||||||
|
defaultFixed: false,
|
||||||
|
// Auto: resolved from `measure` at config-build time
|
||||||
|
// (SST/SDT → saturationTemperature, SH/DSH → superheat, SC → subcooling,
|
||||||
|
// DGT/T → temperature, P → pressure, MassFlow → massFlowRate,
|
||||||
|
// Capacity → capacity, Enthalpy → heatTransferRate as carrier).
|
||||||
|
measureOutput: "auto",
|
||||||
|
description:
|
||||||
|
"Coche Fixed et tape la valeur mesurée (K pour températures, K pour SH/SC, Pa pour P, kg/s pour débit, W pour capacité, J/kg pour h).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
// ── Advanced regulation (optional — most users use Fixed on Calibration tab) ──
|
// ── Advanced regulation (optional — most users use Fixed on Calibration tab) ──
|
||||||
{
|
{
|
||||||
type: "SaturatedController",
|
type: "SaturatedController",
|
||||||
@@ -739,19 +809,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
{ key: "fan_head_pressure_target_c", label: "Fan head-pressure target", kind: "number", unit: "°C", section: "Controls", advanced: true },
|
{ key: "fan_head_pressure_target_c", label: "Fan head-pressure target", kind: "number", unit: "°C", section: "Controls", advanced: true },
|
||||||
{ key: "flooded_head_pressure_target_c", label: "Flooded head-pressure target", kind: "number", unit: "°C", section: "Controls", advanced: true },
|
{ key: "flooded_head_pressure_target_c", label: "Flooded head-pressure target", kind: "number", unit: "°C", section: "Controls", advanced: true },
|
||||||
{ key: "skip_pressure_eq", label: "Skip pressure equation", kind: "boolean", default: false, section: "Solver structure", advanced: true },
|
{ key: "skip_pressure_eq", label: "Skip pressure equation", kind: "boolean", default: false, section: "Solver structure", advanced: true },
|
||||||
// Fixed checkbox: ON = impose SDT; OFF = ignore as measure
|
|
||||||
{
|
|
||||||
key: "calib_sdt_c",
|
|
||||||
label: "SDT (calib target)",
|
|
||||||
kind: "number",
|
|
||||||
unit: "°C",
|
|
||||||
default: 40.0,
|
|
||||||
section: "Calibration",
|
|
||||||
fixable: true,
|
|
||||||
defaultFixed: false,
|
|
||||||
measureOutput: "saturationTemperature",
|
|
||||||
description: "Cocher Fixed pour imposer la SDT (mesure). Libérer Z_UA en même temps.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "z_ua",
|
key: "z_ua",
|
||||||
label: "Z_UA (UA factor)",
|
label: "Z_UA (UA factor)",
|
||||||
@@ -803,18 +860,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 6000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
{ key: "ua", label: "UA", kind: "number", unit: "W/K", default: 6000.0, required: true, section: "Thermal model", min: 0.0, sweepable: true, sweepGroup: "thermal" },
|
||||||
{ key: "t_sat_k", label: "Fixed saturation temperature", kind: "number", unit: "K", default: 275.15, section: "Thermal model", description: "Used only in fixed-pressure mode." },
|
{ key: "t_sat_k", label: "Fixed saturation temperature", kind: "number", unit: "K", default: 275.15, section: "Thermal model", description: "Used only in fixed-pressure mode." },
|
||||||
{ key: "superheat_k", label: "Outlet superheat closure", kind: "number", unit: "K", default: 5.0, section: "Thermal model", min: 0.0 },
|
{ key: "superheat_k", label: "Outlet superheat closure", kind: "number", unit: "K", default: 5.0, section: "Thermal model", min: 0.0 },
|
||||||
{
|
|
||||||
key: "calib_sst_c",
|
|
||||||
label: "SST (calib target)",
|
|
||||||
kind: "number",
|
|
||||||
unit: "°C",
|
|
||||||
default: 5.0,
|
|
||||||
section: "Calibration",
|
|
||||||
fixable: true,
|
|
||||||
defaultFixed: false,
|
|
||||||
measureOutput: "saturationTemperature",
|
|
||||||
description: "Cocher Fixed pour imposer la SST. Décocher Fixed sur Z_UA pour calibrer.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "z_ua",
|
key: "z_ua",
|
||||||
label: "Z_UA (UA factor)",
|
label: "Z_UA (UA factor)",
|
||||||
@@ -1090,17 +1135,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
||||||
{
|
|
||||||
key: "calib_sst_c",
|
|
||||||
label: "SST (calib target)",
|
|
||||||
kind: "number",
|
|
||||||
unit: "°C",
|
|
||||||
default: 5.0,
|
|
||||||
section: "Calibration",
|
|
||||||
fixable: true,
|
|
||||||
defaultFixed: false,
|
|
||||||
measureOutput: "saturationTemperature",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "z_ua",
|
key: "z_ua",
|
||||||
label: "Z_UA (UA factor)",
|
label: "Z_UA (UA factor)",
|
||||||
@@ -1190,17 +1224,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
||||||
{
|
|
||||||
key: "calib_sdt_c",
|
|
||||||
label: "SDT (calib target)",
|
|
||||||
kind: "number",
|
|
||||||
unit: "°C",
|
|
||||||
default: 40.0,
|
|
||||||
section: "Calibration",
|
|
||||||
fixable: true,
|
|
||||||
defaultFixed: false,
|
|
||||||
measureOutput: "saturationTemperature",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "z_ua",
|
key: "z_ua",
|
||||||
label: "Z_UA (UA factor)",
|
label: "Z_UA (UA factor)",
|
||||||
@@ -1270,18 +1293,6 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
section: "Secondary hydraulics",
|
section: "Secondary hydraulics",
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "calib_sst_c",
|
|
||||||
label: "SST (calib target)",
|
|
||||||
kind: "number",
|
|
||||||
unit: "°C",
|
|
||||||
default: 5.0,
|
|
||||||
section: "Calibration",
|
|
||||||
fixable: true,
|
|
||||||
defaultFixed: false,
|
|
||||||
measureOutput: "saturationTemperature",
|
|
||||||
description: "Cocher Fixed pour imposer la SST mesurée. Décocher Fixed sur Z_UA.",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "z_ua",
|
key: "z_ua",
|
||||||
label: "Z_UA (UA factor)",
|
label: "Z_UA (UA factor)",
|
||||||
|
|||||||
@@ -483,44 +483,68 @@ describe("caloporteur (secondary stream) resolution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Fixed / Free calibration (Dymola-style checkboxes)", () => {
|
describe("Fixed / Free calibration (Probe-based pairing)", () => {
|
||||||
it("emits a control when SST is Fixed and Z_UA is Free", () => {
|
it("pairs a Fixed Probe measure with a Free Z_UA on the HX", () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
node("e", "FloodedEvaporator", "evap", 0, {
|
node("e", "FloodedEvaporator", "evap", 0, {
|
||||||
ua: 9000,
|
ua: 9000,
|
||||||
calib_sst_c: 5,
|
|
||||||
z_ua: 1.0,
|
z_ua: 1.0,
|
||||||
// Fixed ON for SST, Fixed OFF for Z_UA
|
// Fixed OFF for Z_UA → free actuator
|
||||||
[fixedFlagKey("calib_sst_c")]: true,
|
|
||||||
[fixedFlagKey("z_ua")]: false,
|
[fixedFlagKey("z_ua")]: false,
|
||||||
}),
|
}),
|
||||||
|
node("p1", "Probe", "sst_probe", 0, {
|
||||||
|
measure: "SST",
|
||||||
|
fluid: "R134a",
|
||||||
|
target: 278.15,
|
||||||
|
// Fixed ON for the Probe target → impose the measure
|
||||||
|
[fixedFlagKey("target")]: true,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
const controls = buildFixedFreeCalibrationControls(nodes);
|
const edges: Edge[] = [
|
||||||
|
{ id: "pe", source: "e", target: "p1", sourceHandle: "outlet", targetHandle: "inlet" },
|
||||||
|
];
|
||||||
|
const controls = buildFixedFreeCalibrationControls(nodes, edges);
|
||||||
expect(controls).toHaveLength(1);
|
expect(controls).toHaveLength(1);
|
||||||
expect(controls[0]).toMatchObject({
|
expect(controls[0]).toMatchObject({
|
||||||
measure: { component: "evap", output: "saturationTemperature" },
|
measure: { component: "sst_probe", output: "saturationTemperature" },
|
||||||
actuator: { component: "evap", factor: "z_ua" },
|
actuator: { component: "evap", factor: "z_ua" },
|
||||||
target: 5 + 273.15,
|
target: 278.15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = buildScenarioConfig(nodes, []);
|
const cfg = buildScenarioConfig(nodes, edges);
|
||||||
expect(cfg.controls?.length).toBe(1);
|
expect(cfg.controls?.length).toBe(1);
|
||||||
// UI-only keys stripped from component JSON
|
// UI-only keys stripped from component JSON; Z_UA still emitted as initial value.
|
||||||
const comp = cfg.circuits[0].components[0];
|
const comp = cfg.circuits[0].components[0];
|
||||||
expect(comp.calib_sst_c).toBeUndefined();
|
|
||||||
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
expect(comp[fixedFlagKey("z_ua")]).toBeUndefined();
|
||||||
expect(comp.z_ua).toBe(1.0);
|
expect(comp.z_ua).toBe(1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits no control when Z_UA stays Fixed", () => {
|
it("emits no control when Z_UA stays Fixed even with a Probe present", () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
node("e", "Evaporator", "evap", 0, {
|
node("e", "Evaporator", "evap", 0, {
|
||||||
ua: 6000,
|
ua: 6000,
|
||||||
calib_sst_c: 5,
|
|
||||||
z_ua: 1.0,
|
z_ua: 1.0,
|
||||||
[fixedFlagKey("calib_sst_c")]: true,
|
|
||||||
[fixedFlagKey("z_ua")]: true,
|
[fixedFlagKey("z_ua")]: true,
|
||||||
}),
|
}),
|
||||||
|
node("p1", "Probe", "sst_probe", 0, {
|
||||||
|
measure: "SST",
|
||||||
|
fluid: "R134a",
|
||||||
|
target: 278.15,
|
||||||
|
[fixedFlagKey("target")]: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits nothing for a freed Z_UA with no matching Probe (HARD RULE)", () => {
|
||||||
|
// A freed z-factor without a Probe to measure against must not emit a
|
||||||
|
// control — the user must place a Probe. No legacy same-component fallback.
|
||||||
|
const nodes = [
|
||||||
|
node("e", "Evaporator", "evap", 0, {
|
||||||
|
ua: 6000,
|
||||||
|
z_ua: 1.0,
|
||||||
|
[fixedFlagKey("z_ua")]: false,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
expect(buildFixedFreeCalibrationControls(nodes)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ export function buildScenarioConfig(
|
|||||||
const nodeControls = nodes
|
const nodeControls = nodes
|
||||||
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
|
.filter((node) => node.data.type === CONTROL_NODE_TYPE)
|
||||||
.map(controlNodeToConfig);
|
.map(controlNodeToConfig);
|
||||||
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes);
|
const fixedFreeControls = buildFixedFreeCalibrationControls(nodes, edges);
|
||||||
const controls = mergeControls(
|
const controls = mergeControls(
|
||||||
mergeControls(options.controls ?? [], nodeControls),
|
mergeControls(options.controls ?? [], nodeControls),
|
||||||
fixedFreeControls,
|
fixedFreeControls,
|
||||||
@@ -362,7 +362,7 @@ export function buildScenarioConfig(
|
|||||||
/**
|
/**
|
||||||
* Remove UI-only keys before sending to the CLI:
|
* Remove UI-only keys before sending to the CLI:
|
||||||
* - `__fixed_*` Fixed checkbox flags
|
* - `__fixed_*` Fixed checkbox flags
|
||||||
* - measure-only calib targets (calib_sst_c, …) — they become control setpoints
|
* - measure-only targets (e.g. Probe `target`) — they become control setpoints
|
||||||
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
|
* - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
|
||||||
*/
|
*/
|
||||||
export function stripUiOnlyParams(
|
export function stripUiOnlyParams(
|
||||||
@@ -466,44 +466,85 @@ export function applyBoundaryFixSemantics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build inverse-calibration controls from Fixed checkboxes (EES/Dymola style).
|
* Build the `controls[]` array for calibration (Probe-based model, HARD RULE):
|
||||||
*
|
*
|
||||||
* - Param with `measureOutput` + Fixed ON → impose that measure (setpoint = value)
|
* - Probe with Fixed `target` → impose that measure (setpoint = value)
|
||||||
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
|
* - Param with `actuatorFactor` + Fixed OFF → free that Z-factor
|
||||||
* Pairs on the same component: each free factor with the first fixed measure.
|
*
|
||||||
|
* Every freed Z-factor is paired with a semantically-compatible Probe
|
||||||
|
* (preferring one adjacent on an edge). A freed factor with no matching
|
||||||
|
* Probe emits nothing — the user must place a Probe to calibrate.
|
||||||
|
*
|
||||||
|
* Pairing matrix (factor → compatible Probe measures):
|
||||||
|
* z_ua → SST, SDT, SH, DSH, SC, Capacity
|
||||||
|
* z_dp → P
|
||||||
|
* z_flow → MassFlow, Capacity
|
||||||
|
* z_power → Capacity
|
||||||
|
* z_etav → MassFlow
|
||||||
*/
|
*/
|
||||||
export function buildFixedFreeCalibrationControls(
|
export function buildFixedFreeCalibrationControls(
|
||||||
nodes: Node<EntropykNodeData>[],
|
nodes: Node<EntropykNodeData>[],
|
||||||
|
edges: Edge[] = [],
|
||||||
): ControlConfig[] {
|
): ControlConfig[] {
|
||||||
const controls: ControlConfig[] = [];
|
const controls: ControlConfig[] = [];
|
||||||
|
|
||||||
|
// ── Pass 1: collect Probe measures and per-component freed z-factors ──
|
||||||
|
type ProbeMeasure = {
|
||||||
|
nodeName: string;
|
||||||
|
kind: string; // SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy
|
||||||
|
target: number;
|
||||||
|
factorCompat: readonly string[]; // z-factors this Probe kind can pair with
|
||||||
|
};
|
||||||
|
type FreeAct = {
|
||||||
|
factor: string;
|
||||||
|
initial: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeMeasures: ProbeMeasure[] = [];
|
||||||
|
const freeActsByComponent = new Map<
|
||||||
|
string,
|
||||||
|
{ nodeName: string; node: Node<EntropykNodeData>; acts: FreeAct[] }
|
||||||
|
>();
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.data.type === CONTROL_NODE_TYPE) continue;
|
if (node.data.type === CONTROL_NODE_TYPE) continue;
|
||||||
const meta = COMPONENT_BY_TYPE[node.data.type];
|
const meta = COMPONENT_BY_TYPE[node.data.type];
|
||||||
if (!meta) continue;
|
if (!meta) continue;
|
||||||
const params = node.data.params ?? {};
|
const params = node.data.params ?? {};
|
||||||
|
|
||||||
type Measure = { output: string; target: number; key: string };
|
// Probe node: read the `target` Fixed param + the configured `measure` kind.
|
||||||
type FreeAct = { factor: string; initial: number; min: number; max: number; key: string };
|
if (node.data.type === "Probe") {
|
||||||
|
const measureParam = meta.params.find((p) => p.key === "measure");
|
||||||
|
const targetParam = meta.params.find((p) => p.key === "target");
|
||||||
|
if (!measureParam || !targetParam) continue;
|
||||||
|
const kindRaw = params.measure;
|
||||||
|
const kind =
|
||||||
|
typeof kindRaw === "string" ? kindRaw : String(measureParam.default ?? "SH");
|
||||||
|
const targetMeta = targetParam;
|
||||||
|
const targetFixed = isParamFixed(params, targetParam);
|
||||||
|
if (!targetFixed) continue;
|
||||||
|
const rawTarget = params.target;
|
||||||
|
const n = typeof rawTarget === "number" ? rawTarget : Number(rawTarget);
|
||||||
|
if (!Number.isFinite(n)) continue;
|
||||||
|
probeMeasures.push({
|
||||||
|
nodeName: node.data.name,
|
||||||
|
kind,
|
||||||
|
target: measureSetpointSi(targetMeta, n),
|
||||||
|
factorCompat: FACTOR_COMPATIBILITY[kind] ?? [],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const measures: Measure[] = [];
|
// Non-Probe node: collect freed z-factors (actuators to calibrate).
|
||||||
const freeActs: FreeAct[] = [];
|
const freeActs: FreeAct[] = [];
|
||||||
|
|
||||||
for (const p of meta.params) {
|
for (const p of meta.params) {
|
||||||
if (!p.fixable) continue;
|
if (!p.fixable) continue;
|
||||||
const fixed = isParamFixed(params, p);
|
const fixed = isParamFixed(params, p);
|
||||||
const raw = params[p.key];
|
const raw = params[p.key];
|
||||||
|
|
||||||
if (p.measureOutput && fixed) {
|
|
||||||
const n = typeof raw === "number" ? raw : Number(raw);
|
|
||||||
if (!Number.isFinite(n)) continue;
|
|
||||||
measures.push({
|
|
||||||
output: p.measureOutput,
|
|
||||||
target: measureSetpointSi(p, n),
|
|
||||||
key: p.key,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.actuatorFactor && !fixed) {
|
if (p.actuatorFactor && !fixed) {
|
||||||
const n = typeof raw === "number" ? raw : Number(raw);
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
const initial = Number.isFinite(n) ? n : 1.0;
|
const initial = Number.isFinite(n) ? n : 1.0;
|
||||||
@@ -517,37 +558,144 @@ export function buildFixedFreeCalibrationControls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (freeActs.length === 0 || measures.length === 0) continue;
|
if (freeActs.length > 0) {
|
||||||
|
freeActsByComponent.set(node.data.name, { nodeName: node.data.name, node, acts: freeActs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < freeActs.length; i++) {
|
// ── Adjacency index: for each component node, which Probe nodes share an edge ──
|
||||||
const act = freeActs[i];
|
const adjacentProbes = new Map<string, Set<string>>();
|
||||||
const meas = measures[Math.min(i, measures.length - 1)];
|
const probeNodeIds = new Set(
|
||||||
controls.push({
|
nodes.filter((n) => n.data.type === "Probe").map((n) => n.id),
|
||||||
type: "SaturatedController",
|
);
|
||||||
id: `calib_${node.data.name}_${act.factor}`,
|
for (const edge of edges) {
|
||||||
measure: {
|
const endpoints = [edge.source, edge.target];
|
||||||
component: node.data.name,
|
for (const endpoint of endpoints) {
|
||||||
output: meas.output,
|
if (!endpoint) continue;
|
||||||
},
|
// Probe directly adjacent to this endpoint
|
||||||
actuator: {
|
for (const other of endpoints) {
|
||||||
component: node.data.name,
|
if (other && other !== endpoint && probeNodeIds.has(other)) {
|
||||||
factor: act.factor,
|
const probeName = nodes.find((n) => n.id === other)?.data.name;
|
||||||
initial: act.initial,
|
if (probeName) {
|
||||||
min: act.min,
|
const endpointName = nodes.find((n) => n.id === endpoint)?.data.name;
|
||||||
max: act.max,
|
if (endpointName) {
|
||||||
},
|
if (!adjacentProbes.has(endpointName)) {
|
||||||
target: meas.target,
|
adjacentProbes.set(endpointName, new Set());
|
||||||
// Negative gain: higher Z_UA → higher capacity / often higher T_sat for flooded
|
}
|
||||||
// — user can refine; default chosen for UA-style calibration.
|
adjacentProbes.get(endpointName)!.add(probeName);
|
||||||
gain: -0.5,
|
}
|
||||||
band: 2.0,
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best Probe to pair with a freed z-factor on `component`:
|
||||||
|
* 1. Must be semantically compatible (FACTOR_COMPATIBILITY).
|
||||||
|
* 2. Prefer Probes adjacent to the component (share an edge endpoint).
|
||||||
|
* 3. Fall back to any compatible Probe.
|
||||||
|
*/
|
||||||
|
const findProbeFor = (
|
||||||
|
component: string,
|
||||||
|
factor: string,
|
||||||
|
usedProbeNames: Set<string>,
|
||||||
|
): ProbeMeasure | undefined => {
|
||||||
|
const compatible = probeMeasures.filter(
|
||||||
|
(m) => m.factorCompat.includes(factor) && !usedProbeNames.has(m.nodeName),
|
||||||
|
);
|
||||||
|
if (compatible.length === 0) return undefined;
|
||||||
|
const adjacent = adjacentProbes.get(component);
|
||||||
|
const adjacentMatch = adjacent
|
||||||
|
? compatible.find((m) => adjacent.has(m.nodeName))
|
||||||
|
: undefined;
|
||||||
|
const chosen = adjacentMatch ?? compatible[0];
|
||||||
|
usedProbeNames.add(chosen.nodeName);
|
||||||
|
return chosen;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pass 2: emit controls ──
|
||||||
|
// Each freed z-factor is paired with a compatible Probe (HARD RULE). A
|
||||||
|
// freed actuator with no matching Probe emits nothing — the user must
|
||||||
|
// place a Probe to calibrate.
|
||||||
|
const usedProbeNames = new Set<string>();
|
||||||
|
|
||||||
|
for (const { nodeName, acts } of freeActsByComponent.values()) {
|
||||||
|
for (const act of acts) {
|
||||||
|
const probe = findProbeFor(nodeName, act.factor, usedProbeNames);
|
||||||
|
if (probe) {
|
||||||
|
controls.push({
|
||||||
|
type: "SaturatedController",
|
||||||
|
id: `calib_${nodeName}_${act.factor}`,
|
||||||
|
measure: {
|
||||||
|
component: probe.nodeName,
|
||||||
|
output: probeOutputFor(probe.kind),
|
||||||
|
},
|
||||||
|
actuator: {
|
||||||
|
component: nodeName,
|
||||||
|
factor: act.factor,
|
||||||
|
initial: act.initial,
|
||||||
|
min: act.min,
|
||||||
|
max: act.max,
|
||||||
|
},
|
||||||
|
target: probe.target,
|
||||||
|
gain: -0.5,
|
||||||
|
band: 2.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return controls;
|
return controls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a Probe `measure` kind to the solver-side `ComponentOutput` string
|
||||||
|
* accepted by the CLI (`parse_component_output` in run.rs).
|
||||||
|
*/
|
||||||
|
function probeOutputFor(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "SST":
|
||||||
|
case "SDT":
|
||||||
|
return "saturationTemperature";
|
||||||
|
case "SH":
|
||||||
|
case "DSH":
|
||||||
|
return "superheat";
|
||||||
|
case "SC":
|
||||||
|
return "subcooling";
|
||||||
|
case "DGT":
|
||||||
|
case "T":
|
||||||
|
return "temperature";
|
||||||
|
case "P":
|
||||||
|
return "pressure";
|
||||||
|
case "MassFlow":
|
||||||
|
return "massFlowRate";
|
||||||
|
case "Capacity":
|
||||||
|
return "capacity";
|
||||||
|
case "Enthalpy":
|
||||||
|
// No dedicated Enthalpy ComponentOutput; carry it on heatTransferRate.
|
||||||
|
return "heatTransferRate";
|
||||||
|
default:
|
||||||
|
return "temperature";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe `measure` kind → list of z-factors it can calibrate (pairing matrix). */
|
||||||
|
const FACTOR_COMPATIBILITY: Record<string, readonly string[]> = {
|
||||||
|
SST: ["z_ua"],
|
||||||
|
SDT: ["z_ua"],
|
||||||
|
SH: ["z_ua"],
|
||||||
|
DSH: ["z_ua"],
|
||||||
|
SC: ["z_ua"],
|
||||||
|
Capacity: ["z_ua", "z_flow", "z_power"],
|
||||||
|
P: ["z_dp"],
|
||||||
|
MassFlow: ["z_flow", "z_etav"],
|
||||||
|
// Kinds not used for z-factor calibration (raw T, Enthalpy) — no compat.
|
||||||
|
DGT: [],
|
||||||
|
T: [],
|
||||||
|
Enthalpy: [],
|
||||||
|
};
|
||||||
|
|
||||||
/** Convert UI measure value to SI expected by the solver (temps → K). */
|
/** Convert UI measure value to SI expected by the solver (temps → K). */
|
||||||
function measureSetpointSi(meta: ParamMeta, value: number): number {
|
function measureSetpointSi(meta: ParamMeta, value: number): number {
|
||||||
const unit = (meta.unit ?? "").toLowerCase();
|
const unit = (meta.unit ?? "").toLowerCase();
|
||||||
|
|||||||
@@ -25,10 +25,26 @@ export const PIPE_TYPES = new Set([
|
|||||||
"PipeAir",
|
"PipeAir",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2-port pass-through components that splice into an existing wire via
|
||||||
|
* `insertOnEdge`. Includes the Pipe family (above) plus the `Probe`
|
||||||
|
* measurement tap (calibration redesign — every measurement lives on a Probe
|
||||||
|
* the user drops onto a line).
|
||||||
|
*/
|
||||||
|
export const INSERTABLE_ON_EDGE_TYPES: ReadonlySet<string> = new Set([
|
||||||
|
...PIPE_TYPES,
|
||||||
|
"Probe",
|
||||||
|
]);
|
||||||
|
|
||||||
export function isPipeType(type: string): boolean {
|
export function isPipeType(type: string): boolean {
|
||||||
return PIPE_TYPES.has(type) || type.includes("Pipe") || type === "AirDuct";
|
return PIPE_TYPES.has(type) || type.includes("Pipe") || type === "AirDuct";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True for any component the canvas should splice onto a wire on drop. */
|
||||||
|
export function isInsertableOnEdge(type: string): boolean {
|
||||||
|
return isPipeType(type) || type === "Probe";
|
||||||
|
}
|
||||||
|
|
||||||
export function pipeMediaKind(type: string, params?: Record<string, unknown>): MediaKind {
|
export function pipeMediaKind(type: string, params?: Record<string, unknown>): MediaKind {
|
||||||
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
if (type === "WaterPipe" || type === "PipeWater") return "water";
|
||||||
if (type === "AirDuct" || type === "PipeAir") return "air";
|
if (type === "AirDuct" || type === "PipeAir") return "air";
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ fn process_single_file(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,7 +426,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -439,7 +441,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -483,7 +486,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -497,7 +501,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -529,7 +534,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -543,7 +549,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -571,7 +578,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -585,7 +593,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 1000,
|
elapsed_ms: 1000,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -643,7 +652,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 100,
|
elapsed_ms: 100,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -667,7 +677,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "ok2.json".to_string(),
|
input: "ok2.json".to_string(),
|
||||||
@@ -681,7 +692,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 60,
|
elapsed_ms: 60,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "fail.json".to_string(),
|
input: "fail.json".to_string(),
|
||||||
@@ -695,7 +707,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -452,6 +452,9 @@ pub struct SolverConfig {
|
|||||||
/// Solver strategy: "newton" or "picard".
|
/// Solver strategy: "newton" or "picard".
|
||||||
#[serde(default = "default_solver_strategy")]
|
#[serde(default = "default_solver_strategy")]
|
||||||
pub strategy: String,
|
pub strategy: String,
|
||||||
|
/// Dense linear backend: "nalgebra" (default) or "faer" (Story 1.5 opt-in).
|
||||||
|
#[serde(default = "default_linear_backend")]
|
||||||
|
pub linear_backend: String,
|
||||||
/// Maximum iterations.
|
/// Maximum iterations.
|
||||||
#[serde(default = "default_max_iterations")]
|
#[serde(default = "default_max_iterations")]
|
||||||
pub max_iterations: usize,
|
pub max_iterations: usize,
|
||||||
@@ -470,6 +473,10 @@ fn default_solver_strategy() -> String {
|
|||||||
"newton".to_string()
|
"newton".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_linear_backend() -> String {
|
||||||
|
"nalgebra".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_max_iterations() -> usize {
|
fn default_max_iterations() -> usize {
|
||||||
100
|
100
|
||||||
}
|
}
|
||||||
@@ -482,6 +489,7 @@ impl Default for SolverConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
strategy: default_solver_strategy(),
|
strategy: default_solver_strategy(),
|
||||||
|
linear_backend: default_linear_backend(),
|
||||||
max_iterations: default_max_iterations(),
|
max_iterations: default_max_iterations(),
|
||||||
tolerance: default_tolerance(),
|
tolerance: default_tolerance(),
|
||||||
timeout_ms: 0,
|
timeout_ms: 0,
|
||||||
|
|||||||
@@ -372,7 +372,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +408,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -717,7 +719,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -737,7 +740,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,7 +786,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -823,7 +828,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -867,7 +873,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,7 +894,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -957,7 +965,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -983,7 +992,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
let find_component = |name: &str| {
|
let find_component = |name: &str| {
|
||||||
config
|
config
|
||||||
@@ -1042,7 +1052,63 @@ fn execute_simulation(
|
|||||||
|
|
||||||
// Register declared control loops (saturated-PI, co-solved). Must happen
|
// Register declared control loops (saturated-PI, co-solved). Must happen
|
||||||
// BEFORE finalize() so the actuator is wired to the component's CalibIndices.
|
// BEFORE finalize() so the actuator is wired to the component's CalibIndices.
|
||||||
|
//
|
||||||
|
// Routing (calibration redesign): controls whose actuator is a z-factor
|
||||||
|
// (z_flow/z_flow_eco/z_dp/z_ua/z_power/z_etav) go through PLAIN inverse
|
||||||
|
// embedding (+1 residual measured−target, +1 unknown z) — not the
|
||||||
|
// SaturatedController (2+2 with integrator, meant for physical actuators).
|
||||||
|
let control_error = |msg: String| SimulationResult {
|
||||||
|
input: input_name.to_string(),
|
||||||
|
status: SimulationStatus::Error,
|
||||||
|
convergence: None,
|
||||||
|
iterations: None,
|
||||||
|
state: None,
|
||||||
|
performance: None,
|
||||||
|
error: Some(msg),
|
||||||
|
failure_diagnostics: None,
|
||||||
|
initialization_diagnostics: None,
|
||||||
|
dof: None,
|
||||||
|
elapsed_ms,
|
||||||
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
|
};
|
||||||
for control in &config.controls {
|
for control in &config.controls {
|
||||||
|
let factor = control.actuator.factor.trim();
|
||||||
|
// Plain embedding applies to single-point z-factor calibration only.
|
||||||
|
// A control WITH `objectives` is a supervisory override network
|
||||||
|
// (selector semantics) and always keeps the SaturatedController path,
|
||||||
|
// regardless of the actuator factor.
|
||||||
|
if entropyk_core::normalize_factor_name(factor).is_some() && control.objectives.is_empty() {
|
||||||
|
match build_plain_z_embedding(control, factor) {
|
||||||
|
Ok((constraint, bounded_var, actuator_id)) => {
|
||||||
|
if let Err(e) = system.add_constraint(constraint) {
|
||||||
|
return control_error(format!(
|
||||||
|
"Failed to add constraint for control '{}': {:?}",
|
||||||
|
control.id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
||||||
|
return control_error(format!(
|
||||||
|
"Failed to add actuator for control '{}': {:?}",
|
||||||
|
control.id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(e) = system.link_constraint_to_control(
|
||||||
|
&entropyk_solver::inverse::ConstraintId::new(control.id.clone()),
|
||||||
|
&actuator_id,
|
||||||
|
) {
|
||||||
|
return control_error(format!(
|
||||||
|
"Failed to link control '{}': {:?}",
|
||||||
|
control.id, e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
return control_error(format!("Invalid control '{}': {}", control.id, msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match build_saturated_control(control) {
|
match build_saturated_control(control) {
|
||||||
Ok((bounded_var, controller)) => {
|
Ok((bounded_var, controller)) => {
|
||||||
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
if let Err(e) = system.add_bounded_variable(bounded_var) {
|
||||||
@@ -1061,7 +1127,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
system.add_saturated_controller(controller);
|
system.add_saturated_controller(controller);
|
||||||
@@ -1079,7 +1146,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1253,7 +1321,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
system.add_free_actuator(var_id);
|
system.add_free_actuator(var_id);
|
||||||
@@ -1275,7 +1344,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1296,7 +1366,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1524,10 +1595,35 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: Some(dof_summary),
|
dof: Some(dof_summary),
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dense linear backend dispatch (Story 1.5): "nalgebra" (default) or
|
||||||
|
// "faer"; env ENTROPYK_LINEAR_BACKEND overrides for the test harness.
|
||||||
|
if !matches!(config.solver.linear_backend.as_str(), "nalgebra" | "faer") {
|
||||||
|
return SimulationResult {
|
||||||
|
input: input_name.to_string(),
|
||||||
|
status: SimulationStatus::Error,
|
||||||
|
convergence: None,
|
||||||
|
iterations: None,
|
||||||
|
state: None,
|
||||||
|
performance: None,
|
||||||
|
error: Some(format!(
|
||||||
|
"Unsupported linear backend '{}'. Use 'nalgebra' or 'faer'.",
|
||||||
|
config.solver.linear_backend
|
||||||
|
)),
|
||||||
|
failure_diagnostics: None,
|
||||||
|
initialization_diagnostics: None,
|
||||||
|
dof: Some(dof_summary),
|
||||||
|
elapsed_ms,
|
||||||
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
entropyk_solver::linear::set_linear_backend_override(Some(&config.solver.linear_backend));
|
||||||
|
|
||||||
// Build + run the configured strategy for a single solve. CLI
|
// Build + run the configured strategy for a single solve. CLI
|
||||||
// `solver.max_iterations` / `tolerance` are now honoured by every stage
|
// `solver.max_iterations` / `tolerance` are now honoured by every stage
|
||||||
// (previously the defaults capped Newton at 100 iterations). Reused by the
|
// (previously the defaults capped Newton at 100 iterations). Reused by the
|
||||||
@@ -1799,7 +1895,8 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: Some(initialization_diagnostics),
|
initialization_diagnostics: Some(initialization_diagnostics),
|
||||||
dof: Some(dof_summary),
|
dof: Some(dof_summary),
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2968,9 +3065,8 @@ fn bphx_dp_correlation_from_params(
|
|||||||
"{component_name}: dp_correlation must be a string (got {value})"
|
"{component_name}: dp_correlation must be a string (got {value})"
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
BphxDpCorrelation::parse(raw).map_err(|msg| {
|
BphxDpCorrelation::parse(raw)
|
||||||
CliError::Config(format!("{component_name}: {msg}"))
|
.map_err(|msg| CliError::Config(format!("{component_name}: {msg}")))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract calibration Z-factors for BphxEvaporator/BphxCondenser from JSON params.
|
/// Extract calibration Z-factors for BphxEvaporator/BphxCondenser from JSON params.
|
||||||
@@ -3134,6 +3230,43 @@ fn parse_component_output(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a bounded actuator + saturated controller from a control config.
|
/// Builds a bounded actuator + saturated controller from a control config.
|
||||||
|
/// Plain inverse embedding for z-factor calibration controls (calibration
|
||||||
|
/// redesign, WS-2): one `Constraint` (measured − target) + one `BoundedVariable`
|
||||||
|
/// (the z-factor), linked 1:1. The bounded var id reuses the
|
||||||
|
/// `{component}__{z_factor}` convention so `finalize()` wires it to the
|
||||||
|
/// component's matching `CalibIndices` slot (system.rs).
|
||||||
|
fn build_plain_z_embedding(
|
||||||
|
control: &crate::config::ControlConfig,
|
||||||
|
factor: &str,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
entropyk_solver::inverse::Constraint,
|
||||||
|
entropyk_solver::inverse::BoundedVariable,
|
||||||
|
entropyk_solver::inverse::BoundedVariableId,
|
||||||
|
),
|
||||||
|
String,
|
||||||
|
> {
|
||||||
|
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId, Constraint, ConstraintId};
|
||||||
|
|
||||||
|
let output = parse_component_output(&control.measure.component, &control.measure.output)?;
|
||||||
|
let actuator_id =
|
||||||
|
BoundedVariableId::new(saturated_actuator_id(&control.actuator.component, factor));
|
||||||
|
let bounded_var = BoundedVariable::with_component(
|
||||||
|
actuator_id.clone(),
|
||||||
|
&control.actuator.component,
|
||||||
|
control.actuator.initial,
|
||||||
|
control.actuator.min,
|
||||||
|
control.actuator.max,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("invalid actuator bounds: {e:?}"))?;
|
||||||
|
let constraint = Constraint::new(
|
||||||
|
ConstraintId::new(control.id.clone()),
|
||||||
|
output,
|
||||||
|
control.target,
|
||||||
|
);
|
||||||
|
Ok((constraint, bounded_var, actuator_id))
|
||||||
|
}
|
||||||
|
|
||||||
fn build_saturated_control(
|
fn build_saturated_control(
|
||||||
control: &crate::config::ControlConfig,
|
control: &crate::config::ControlConfig,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
@@ -5249,6 +5382,55 @@ fn create_component(
|
|||||||
Ok(Box::new(load))
|
Ok(Box::new(load))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"Probe" => {
|
||||||
|
use entropyk::{ComponentFluidId, Enthalpy, Port, Probe, ProbeMeasure, Pressure};
|
||||||
|
// Calibration-redesign measurement tap (zero-residual, 2-port splice).
|
||||||
|
// The user drops it onto a wire (UI `insertOnEdge`) and picks a
|
||||||
|
// measure kind; the freed z-factor on a calibrated component
|
||||||
|
// elsewhere is paired with this Probe via a calibration control
|
||||||
|
// whose `measure.component` names the Probe.
|
||||||
|
let measure_str = params
|
||||||
|
.get("measure")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("SH");
|
||||||
|
let measure = ProbeMeasure::parse(measure_str).map_err(CliError::Config)?;
|
||||||
|
|
||||||
|
let fluid = params
|
||||||
|
.get("fluid")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| _primary_fluid.as_str())
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Nominal edge state for the port pair (mirrors Pipe's idiom —
|
||||||
|
// both pairs carry identical nominal values, the live solver state
|
||||||
|
// overrides them via `set_system_context`).
|
||||||
|
let p_bar = params.get("p_bar").and_then(|v| v.as_f64()).unwrap_or(5.0);
|
||||||
|
let h_j = params
|
||||||
|
.get("h_j_kg")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.or_else(|| {
|
||||||
|
params
|
||||||
|
.get("h_kj_kg")
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.map(|k| k * 1000.0)
|
||||||
|
})
|
||||||
|
.unwrap_or(250_000.0);
|
||||||
|
|
||||||
|
let mk = || {
|
||||||
|
Port::new(
|
||||||
|
ComponentFluidId::new(&fluid),
|
||||||
|
Pressure::from_bar(p_bar),
|
||||||
|
Enthalpy::from_joules_per_kg(h_j),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let probe = Probe::new(measure, &fluid, mk(), mk())
|
||||||
|
.with_fluid_backend(backend)
|
||||||
|
.connect(mk(), mk())
|
||||||
|
.map_err(CliError::Component)?;
|
||||||
|
Ok(Box::new(probe))
|
||||||
|
}
|
||||||
|
|
||||||
"Anchor" | "RefrigerantNode" => {
|
"Anchor" | "RefrigerantNode" => {
|
||||||
use entropyk::{Anchor, AnchorConstraint};
|
use entropyk::{Anchor, AnchorConstraint};
|
||||||
// BOLT `BoundaryNode.Refrigerant.Node` equivalent: inline spec node
|
// BOLT `BoundaryNode.Refrigerant.Node` equivalent: inline spec node
|
||||||
@@ -5362,7 +5544,7 @@ fn create_component(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ => Err(CliError::Config(format!(
|
_ => Err(CliError::Config(format!(
|
||||||
"Unknown component type: '{}'. Supported: IsentropicCompressor, IsenthalpicExpansionValve, ScrewEconomizerCompressor, CentrifugalCompressor, CapillaryTube, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, ReversingValve, Pump, Fan, Pipe, RefrigerantPipe, WaterPipe, AirDuct, Placeholder",
|
"Unknown component type: '{}'. Supported: IsentropicCompressor, IsenthalpicExpansionValve, ScrewEconomizerCompressor, CentrifugalCompressor, CapillaryTube, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, ReversingValve, Pump, Fan, Pipe, RefrigerantPipe, WaterPipe, AirDuct, Probe, Anchor, Placeholder",
|
||||||
component_type
|
component_type
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
@@ -5672,7 +5854,8 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&result).unwrap();
|
let json = serde_json::to_string_pretty(&result).unwrap();
|
||||||
@@ -5839,10 +6022,8 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
BphxDpCorrelation::SimplifiedChannel
|
BphxDpCorrelation::SimplifiedChannel
|
||||||
);
|
);
|
||||||
let params = std::collections::HashMap::from([(
|
let params =
|
||||||
"DpCorrelation".to_string(),
|
std::collections::HashMap::from([("DpCorrelation".to_string(), json!("Martin1996"))]);
|
||||||
json!("Martin1996"),
|
|
||||||
)]);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bphx_dp_correlation_from_params(¶ms, "BphxCondenser").unwrap(),
|
bphx_dp_correlation_from_params(¶ms, "BphxCondenser").unwrap(),
|
||||||
BphxDpCorrelation::Martin1996
|
BphxDpCorrelation::Martin1996
|
||||||
@@ -5853,10 +6034,8 @@ mod tests {
|
|||||||
fn test_bphx_dp_correlation_rejects_unknown() {
|
fn test_bphx_dp_correlation_rejects_unknown() {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
let params = std::collections::HashMap::from([(
|
let params =
|
||||||
"dp_correlation".to_string(),
|
std::collections::HashMap::from([("dp_correlation".to_string(), json!("Amalfi2016"))]);
|
||||||
json!("Amalfi2016"),
|
|
||||||
)]);
|
|
||||||
let error = bphx_dp_correlation_from_params(¶ms, "BphxEvaporator").unwrap_err();
|
let error = bphx_dp_correlation_from_params(¶ms, "BphxEvaporator").unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("unsupported dp_correlation"),
|
error.to_string().contains("unsupported dp_correlation"),
|
||||||
@@ -5868,15 +6047,9 @@ mod tests {
|
|||||||
fn test_bphx_dp_correlation_rejects_non_string() {
|
fn test_bphx_dp_correlation_rejects_non_string() {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
let params = std::collections::HashMap::from([(
|
let params = std::collections::HashMap::from([("dp_correlation".to_string(), json!(42))]);
|
||||||
"dp_correlation".to_string(),
|
|
||||||
json!(42),
|
|
||||||
)]);
|
|
||||||
let error = bphx_dp_correlation_from_params(¶ms, "BphxEvaporator").unwrap_err();
|
let error = bphx_dp_correlation_from_params(¶ms, "BphxEvaporator").unwrap_err();
|
||||||
assert!(
|
assert!(error.to_string().contains("must be a string"), "{error}");
|
||||||
error.to_string().contains("must be a string"),
|
|
||||||
"{error}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn comp_from_json(v: serde_json::Value) -> crate::config::ComponentConfig {
|
fn comp_from_json(v: serde_json::Value) -> crate::config::ComponentConfig {
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ fn test_simulation_result_statuses() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "fail.json".to_string(),
|
input: "fail.json".to_string(),
|
||||||
@@ -106,7 +107,8 @@ fn test_simulation_result_statuses() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "timeout.json".to_string(),
|
input: "timeout.json".to_string(),
|
||||||
@@ -120,7 +122,8 @@ fn test_simulation_result_statuses() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 1000,
|
elapsed_ms: 1000,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -163,7 +166,8 @@ fn test_batch_aggregator_csv_output() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 150,
|
elapsed_ms: 150,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "scenario2.json".to_string(),
|
input: "scenario2.json".to_string(),
|
||||||
@@ -183,7 +187,8 @@ fn test_batch_aggregator_csv_output() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 200,
|
elapsed_ms: 200,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "scenario3.json".to_string(),
|
input: "scenario3.json".to_string(),
|
||||||
@@ -197,7 +202,8 @@ fn test_batch_aggregator_csv_output() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -229,7 +235,8 @@ fn test_batch_aggregator_json_summary() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -243,7 +250,8 @@ fn test_batch_aggregator_json_summary() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 75,
|
elapsed_ms: 75,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test3.json".to_string(),
|
input: "test3.json".to_string(),
|
||||||
@@ -257,7 +265,8 @@ fn test_batch_aggregator_json_summary() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 5000,
|
elapsed_ms: 5000,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -322,7 +331,8 @@ fn test_batch_summary_csv_with_convergence() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 300,
|
elapsed_ms: 300,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let summary = BatchSummary {
|
let summary = BatchSummary {
|
||||||
|
|||||||
190
crates/cli/tests/calibration_sdt.rs
Normal file
190
crates/cli/tests/calibration_sdt.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
//! Calibration redesign (WS-2/WS-3/WS-4) — AC#1: BPHX condenser SDT
|
||||||
|
//! calibration via plain inverse embedding converges and back-solves z_ua
|
||||||
|
//! (the case that diverged with a singular Jacobian before the redesign).
|
||||||
|
|
||||||
|
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn run_config(json: &str) -> SimulationResult {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("bphx_sdt_calib.json");
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
run_simulation(&path, None, false).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bphx_condenser_sdt_calibration_converges_and_solves_z_ua() {
|
||||||
|
let json = r#"
|
||||||
|
{
|
||||||
|
"name": "BPHX Water-Cooled Chiller R134a",
|
||||||
|
"description": "Full vapor-compression cycle with brazed-plate evaporator and condenser (4-port). Both Bphx units use emergent_pressure so outlet closures (superheat / subcooling) square the DoF like Condenser/Evaporator.",
|
||||||
|
"fluid": "R134a",
|
||||||
|
"fluid_backend": "CoolProp",
|
||||||
|
"circuits": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "Refrigerant + water loops",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "IsentropicCompressor",
|
||||||
|
"name": "comp",
|
||||||
|
"isentropic_efficiency": 0.7,
|
||||||
|
"t_cond_k": 318.15,
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"superheat_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"displacement_m3": 6.5e-05,
|
||||||
|
"speed_hz": 50.0,
|
||||||
|
"volumetric_efficiency": 0.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BphxCondenser",
|
||||||
|
"name": "cond",
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"target_subcooling_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"ua": 2500.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "IsenthalpicExpansionValve",
|
||||||
|
"name": "exv",
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"emergent_pressure": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BphxEvaporator",
|
||||||
|
"name": "evap",
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"target_superheat_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"ua": 2000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSource",
|
||||||
|
"name": "cond_water_in",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_set_bar": 2.0,
|
||||||
|
"t_set_c": 30.0,
|
||||||
|
"m_flow_kg_s": 0.4,
|
||||||
|
"fix_pressure": false,
|
||||||
|
"fix_temperature": true,
|
||||||
|
"fix_mass_flow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSink",
|
||||||
|
"name": "cond_water_out",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 2.0,
|
||||||
|
"fix_pressure": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSource",
|
||||||
|
"name": "evap_water_in",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_set_bar": 3.0,
|
||||||
|
"t_set_c": 12.0,
|
||||||
|
"m_flow_kg_s": 0.5,
|
||||||
|
"fix_pressure": false,
|
||||||
|
"fix_temperature": true,
|
||||||
|
"fix_mass_flow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSink",
|
||||||
|
"name": "evap_water_out",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 3.0,
|
||||||
|
"fix_pressure": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"from": "comp:outlet",
|
||||||
|
"to": "cond:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "cond:outlet",
|
||||||
|
"to": "exv:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "exv:outlet",
|
||||||
|
"to": "evap:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "evap:outlet",
|
||||||
|
"to": "comp:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "cond_water_in:outlet",
|
||||||
|
"to": "cond:secondary_inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "cond:secondary_outlet",
|
||||||
|
"to": "cond_water_out:inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "evap_water_in:outlet",
|
||||||
|
"to": "evap:secondary_inlet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "evap:secondary_outlet",
|
||||||
|
"to": "evap_water_out:inlet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"solver": {
|
||||||
|
"strategy": "fallback",
|
||||||
|
"max_iterations": 300,
|
||||||
|
"tolerance": 1e-06,
|
||||||
|
"timeout_ms": 60000
|
||||||
|
},
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"type": "SaturatedController",
|
||||||
|
"id": "sdt_calib",
|
||||||
|
"measure": {
|
||||||
|
"component": "cond",
|
||||||
|
"output": "saturationTemperature"
|
||||||
|
},
|
||||||
|
"actuator": {
|
||||||
|
"component": "cond",
|
||||||
|
"factor": "z_ua",
|
||||||
|
"initial": 0.3,
|
||||||
|
"min": 0.05,
|
||||||
|
"max": 2.0
|
||||||
|
},
|
||||||
|
"target": 315.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let result = run_config(json);
|
||||||
|
assert!(
|
||||||
|
matches!(result.status, SimulationStatus::Converged),
|
||||||
|
"BPHX SDT calibration must converge (was the singular-Jacobian case): {:?} ({:?})",
|
||||||
|
result.status,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
let solved = result
|
||||||
|
.solved_variables
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("cond"))
|
||||||
|
.expect("solved_variables must contain cond/z_ua");
|
||||||
|
assert!(
|
||||||
|
solved.value > 0.05 && solved.value < 2.0,
|
||||||
|
"z_ua must solve within bounds, got {}",
|
||||||
|
solved.value
|
||||||
|
);
|
||||||
|
}
|
||||||
193
crates/cli/tests/probe_calibration.rs
Normal file
193
crates/cli/tests/probe_calibration.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
|
||||||
|
//! test: a `Probe` node measuring SDT on the condenser refrigerant inlet edge
|
||||||
|
//! drives the plain inverse embedding for `z_ua`. The Probe is the measurement
|
||||||
|
//! source (`control.measure.component` names the Probe); the freed z-factor
|
||||||
|
//! lives on the BPHX condenser (`control.actuator.component`). The two are
|
||||||
|
//! linked 1:1 (+1 residual on Probe SDT target, +1 unknown z_ua).
|
||||||
|
//!
|
||||||
|
//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the
|
||||||
|
//! end-to-end path the UI emits after the calibration redesign.
|
||||||
|
|
||||||
|
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn run_config(json: &str) -> SimulationResult {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("probe_sdt_calib.json");
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
run_simulation(&path, None, false).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_based_sdt_calibration_converges_and_solves_z_ua() {
|
||||||
|
let json = r#"
|
||||||
|
{
|
||||||
|
"name": "Probe-based SDT calibration (R134a BPHX chiller)",
|
||||||
|
"description": "Same vapor-compression cycle as calibration_sdt.rs, but the SDT measurement lives on a Probe node spliced into the condenser refrigerant inlet edge. control.measure.component = the Probe name.",
|
||||||
|
"fluid": "R134a",
|
||||||
|
"fluid_backend": "CoolProp",
|
||||||
|
"circuits": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "Refrigerant + water loops",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "IsentropicCompressor",
|
||||||
|
"name": "comp",
|
||||||
|
"isentropic_efficiency": 0.7,
|
||||||
|
"t_cond_k": 318.15,
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"superheat_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"displacement_m3": 6.5e-05,
|
||||||
|
"speed_hz": 50.0,
|
||||||
|
"volumetric_efficiency": 0.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BphxCondenser",
|
||||||
|
"name": "cond",
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"target_subcooling_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"ua": 2500.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Probe",
|
||||||
|
"name": "cond_sdt_probe",
|
||||||
|
"measure": "SDT",
|
||||||
|
"fluid": "R134a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "IsenthalpicExpansionValve",
|
||||||
|
"name": "exv",
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"emergent_pressure": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BphxEvaporator",
|
||||||
|
"name": "evap",
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"target_superheat_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"ua": 2000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSource",
|
||||||
|
"name": "cond_water_in",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_set_bar": 2.0,
|
||||||
|
"t_set_c": 30.0,
|
||||||
|
"m_flow_kg_s": 0.4,
|
||||||
|
"fix_pressure": false,
|
||||||
|
"fix_temperature": true,
|
||||||
|
"fix_mass_flow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSink",
|
||||||
|
"name": "cond_water_out",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 2.0,
|
||||||
|
"fix_pressure": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSource",
|
||||||
|
"name": "evap_water_in",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_set_bar": 3.0,
|
||||||
|
"t_set_c": 12.0,
|
||||||
|
"m_flow_kg_s": 0.5,
|
||||||
|
"fix_pressure": false,
|
||||||
|
"fix_temperature": true,
|
||||||
|
"fix_mass_flow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSink",
|
||||||
|
"name": "evap_water_out",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 3.0,
|
||||||
|
"fix_pressure": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "comp:outlet", "to": "cond_sdt_probe:inlet" },
|
||||||
|
{ "from": "cond_sdt_probe:outlet","to": "cond:inlet" },
|
||||||
|
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||||
|
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||||
|
{ "from": "evap:outlet", "to": "comp:inlet" },
|
||||||
|
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||||
|
{ "from": "cond:secondary_outlet","to": "cond_water_out:inlet" },
|
||||||
|
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||||
|
{ "from": "evap:secondary_outlet","to": "evap_water_out:inlet" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"solver": {
|
||||||
|
"strategy": "fallback",
|
||||||
|
"max_iterations": 300,
|
||||||
|
"tolerance": 1e-06,
|
||||||
|
"timeout_ms": 60000
|
||||||
|
},
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"type": "SaturatedController",
|
||||||
|
"id": "probe_sdt_calib",
|
||||||
|
"measure": {
|
||||||
|
"component": "cond_sdt_probe",
|
||||||
|
"output": "saturationTemperature"
|
||||||
|
},
|
||||||
|
"actuator": {
|
||||||
|
"component": "cond",
|
||||||
|
"factor": "z_ua",
|
||||||
|
"initial": 0.3,
|
||||||
|
"min": 0.05,
|
||||||
|
"max": 2.0
|
||||||
|
},
|
||||||
|
"target": 315.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let result = run_config(json);
|
||||||
|
assert!(
|
||||||
|
matches!(result.status, SimulationStatus::Converged),
|
||||||
|
"Probe-based SDT calibration must converge: {:?} ({:?})",
|
||||||
|
result.status,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
let solved = result
|
||||||
|
.solved_variables
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("cond"))
|
||||||
|
.expect("solved_variables must contain cond/z_ua");
|
||||||
|
eprintln!("DIAG z_ua résolu = {}", solved.value);
|
||||||
|
eprintln!("DIAG cible SDT = 315.0 K (41.85°C)");
|
||||||
|
if let Some(state) = result.state.as_ref() {
|
||||||
|
for e in state.iter() {
|
||||||
|
if e.target.as_deref() == Some("cond") || e.source.as_deref() == Some("cond") {
|
||||||
|
eprintln!("DIAG edge {}→{} P={}bar T_sat={}°C T={}°C",
|
||||||
|
e.source.as_deref().unwrap_or("?"),
|
||||||
|
e.target.as_deref().unwrap_or("?"),
|
||||||
|
e.pressure_bar,
|
||||||
|
e.saturation_temperature_c.unwrap_or(f64::NAN),
|
||||||
|
e.temperature_c.unwrap_or(f64::NAN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
solved.value > 0.05 && solved.value < 2.0,
|
||||||
|
"z_ua must solve within bounds, got {}",
|
||||||
|
solved.value
|
||||||
|
);
|
||||||
|
}
|
||||||
194
crates/cli/tests/probe_sst_evap.rs
Normal file
194
crates/cli/tests/probe_sst_evap.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//! Calibration redesign (HARD RULE — Probe for ALL measurements) — integration
|
||||||
|
//! test: a `Probe` node measuring SDT on the condenser refrigerant inlet edge
|
||||||
|
//! drives the plain inverse embedding for `z_ua`. The Probe is the measurement
|
||||||
|
//! source (`control.measure.component` names the Probe); the freed z-factor
|
||||||
|
//! lives on the BPHX condenser (`control.actuator.component`). The two are
|
||||||
|
//! linked 1:1 (+1 residual on Probe SDT target, +1 unknown z_ua).
|
||||||
|
//!
|
||||||
|
//! This is the Probe-based variant of `calibration_sdt.rs`. It proves the
|
||||||
|
//! end-to-end path the UI emits after the calibration redesign.
|
||||||
|
|
||||||
|
use entropyk_cli::run::{run_simulation, SimulationResult, SimulationStatus};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn run_config(json: &str) -> SimulationResult {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("probe_sdt_calib.json");
|
||||||
|
std::fs::write(&path, json).unwrap();
|
||||||
|
run_simulation(&path, None, false).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_based_sst_evap_calibration() {
|
||||||
|
let json = r#"
|
||||||
|
{
|
||||||
|
"name": "Probe-based SDT calibration (R134a BPHX chiller)",
|
||||||
|
"description": "Same vapor-compression cycle as calibration_sdt.rs, but the SDT measurement lives on a Probe node spliced into the condenser refrigerant inlet edge. control.measure.component = the Probe name.",
|
||||||
|
"fluid": "R134a",
|
||||||
|
"fluid_backend": "CoolProp",
|
||||||
|
"circuits": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "Refrigerant + water loops",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "IsentropicCompressor",
|
||||||
|
"name": "comp",
|
||||||
|
"isentropic_efficiency": 0.7,
|
||||||
|
"t_cond_k": 318.15,
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"superheat_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"displacement_m3": 6.5e-05,
|
||||||
|
"speed_hz": 50.0,
|
||||||
|
"volumetric_efficiency": 0.92
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BphxCondenser",
|
||||||
|
"name": "cond",
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"target_subcooling_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"ua": 2500.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Probe",
|
||||||
|
"name": "evap_sst_probe",
|
||||||
|
"measure": "SDT",
|
||||||
|
"fluid": "R134a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "IsenthalpicExpansionValve",
|
||||||
|
"name": "exv",
|
||||||
|
"t_evap_k": 278.15,
|
||||||
|
"emergent_pressure": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BphxEvaporator",
|
||||||
|
"name": "evap",
|
||||||
|
"refrigerant": "R134a",
|
||||||
|
"secondary_fluid": "Water",
|
||||||
|
"n_plates": 40,
|
||||||
|
"plate_length_m": 0.4,
|
||||||
|
"plate_width_m": 0.12,
|
||||||
|
"target_superheat_k": 5.0,
|
||||||
|
"emergent_pressure": true,
|
||||||
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
|
"ua": 2000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSource",
|
||||||
|
"name": "cond_water_in",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_set_bar": 2.0,
|
||||||
|
"t_set_c": 30.0,
|
||||||
|
"m_flow_kg_s": 0.4,
|
||||||
|
"fix_pressure": false,
|
||||||
|
"fix_temperature": true,
|
||||||
|
"fix_mass_flow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSink",
|
||||||
|
"name": "cond_water_out",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 2.0,
|
||||||
|
"fix_pressure": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSource",
|
||||||
|
"name": "evap_water_in",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_set_bar": 3.0,
|
||||||
|
"t_set_c": 12.0,
|
||||||
|
"m_flow_kg_s": 0.5,
|
||||||
|
"fix_pressure": false,
|
||||||
|
"fix_temperature": true,
|
||||||
|
"fix_mass_flow": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "BrineSink",
|
||||||
|
"name": "evap_water_out",
|
||||||
|
"fluid": "Water",
|
||||||
|
"p_back_bar": 3.0,
|
||||||
|
"fix_pressure": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "exv:outlet", "to": "evap_sst_probe:inlet" },
|
||||||
|
{ "from": "evap_sst_probe:outlet","to": "cond:inlet" },
|
||||||
|
{ "from": "cond:outlet", "to": "exv:inlet" },
|
||||||
|
{ "from": "exv:outlet", "to": "evap:inlet" },
|
||||||
|
{ "from": "evap:outlet", "to": "evap_sst_probe:inlet" },
|
||||||
|
{ "from": "evap_sst_probe:outlet", "to": "comp:inlet" },
|
||||||
|
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
|
||||||
|
{ "from": "cond:secondary_outlet","to": "cond_water_out:inlet" },
|
||||||
|
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
|
||||||
|
{ "from": "evap:secondary_outlet","to": "evap_water_out:inlet" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"solver": {
|
||||||
|
"strategy": "fallback",
|
||||||
|
"max_iterations": 300,
|
||||||
|
"tolerance": 1e-06,
|
||||||
|
"timeout_ms": 60000
|
||||||
|
},
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"type": "SaturatedController",
|
||||||
|
"id": "probe_sdt_calib",
|
||||||
|
"measure": {
|
||||||
|
"component": "evap_sst_probe",
|
||||||
|
"output": "saturationTemperature"
|
||||||
|
},
|
||||||
|
"actuator": {
|
||||||
|
"component": "evap",
|
||||||
|
"factor": "z_ua",
|
||||||
|
"initial": 0.3,
|
||||||
|
"min": 0.05,
|
||||||
|
"max": 2.0
|
||||||
|
},
|
||||||
|
"target": 277.55
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let result = run_config(json);
|
||||||
|
assert!(
|
||||||
|
matches!(result.status, SimulationStatus::Converged),
|
||||||
|
"Probe-based SDT calibration must converge: {:?} ({:?})",
|
||||||
|
result.status,
|
||||||
|
result.error
|
||||||
|
);
|
||||||
|
let solved = result
|
||||||
|
.solved_variables
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.variable == "z_ua" && v.component.as_deref() == Some("evap"))
|
||||||
|
.expect("solved_variables must contain cond/z_ua");
|
||||||
|
eprintln!("DIAG z_ua résolu = {}", solved.value);
|
||||||
|
eprintln!("DIAG cible SST = 277.55 K (41.85°C)");
|
||||||
|
if let Some(state) = result.state.as_ref() {
|
||||||
|
for e in state.iter() {
|
||||||
|
if e.target.as_deref() == Some("evap") || e.source.as_deref() == Some("evap") {
|
||||||
|
eprintln!("DIAG edge {}→{} P={}bar T_sat={}°C T={}°C",
|
||||||
|
e.source.as_deref().unwrap_or("?"),
|
||||||
|
e.target.as_deref().unwrap_or("?"),
|
||||||
|
e.pressure_bar,
|
||||||
|
e.saturation_temperature_c.unwrap_or(f64::NAN),
|
||||||
|
e.temperature_c.unwrap_or(f64::NAN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
solved.value > 0.05 && solved.value < 2.0,
|
||||||
|
"z_ua must solve within bounds, got {}",
|
||||||
|
solved.value
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ fn test_simulation_result_serialization() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&result).unwrap();
|
let json = serde_json::to_string_pretty(&result).unwrap();
|
||||||
@@ -70,7 +71,8 @@ fn test_error_result_serialization() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
@@ -96,7 +98,8 @@ fn test_error_result_serializes_failure_diagnostics() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
@@ -2303,7 +2306,8 @@ fn test_structural_failure_serializes_without_diagnostics() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
@@ -2348,7 +2352,8 @@ fn test_success_result_does_not_include_failure_diagnostics() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 120,
|
elapsed_ms: 120,
|
||||||
raw_state_vector: None, solved_variables: Vec::new(),
|
raw_state_vector: None,
|
||||||
|
solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
|
|||||||
@@ -555,6 +555,10 @@ impl Component for BphxCondenser {
|
|||||||
self.inner.energy_transfers(state)
|
self.inner.energy_transfers(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||||
|
self.inner.measure_output(kind, state)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_fluid_backend_from_builder(
|
fn set_fluid_backend_from_builder(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ impl BphxDpCorrelation {
|
|||||||
|
|
||||||
/// Parse CLI/UI identifiers. Rejects unknown strings (no silent fallback).
|
/// Parse CLI/UI identifiers. Rejects unknown strings (no silent fallback).
|
||||||
pub fn parse(name: &str) -> Result<Self, String> {
|
pub fn parse(name: &str) -> Result<Self, String> {
|
||||||
let key = name.trim().to_ascii_lowercase().replace(['-', '_', ' '], "");
|
let key = name
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.replace(['-', '_', ' '], "");
|
||||||
match key.as_str() {
|
match key.as_str() {
|
||||||
"simplifiedchannel" | "simplified" | "channel" | "default" => {
|
"simplifiedchannel" | "simplified" | "channel" | "default" => {
|
||||||
Ok(Self::SimplifiedChannel)
|
Ok(Self::SimplifiedChannel)
|
||||||
@@ -104,13 +107,7 @@ fn simplified_channel_fanning(re: f64) -> (f64, f64) {
|
|||||||
return (f_turb, df_turb);
|
return (f_turb, df_turb);
|
||||||
}
|
}
|
||||||
|
|
||||||
let f = entropyk_core::smoothing::cubic_blend(
|
let f = entropyk_core::smoothing::cubic_blend(f_lam, f_turb, re, RE_LAMINAR, RE_TURBULENT);
|
||||||
f_lam,
|
|
||||||
f_turb,
|
|
||||||
re,
|
|
||||||
RE_LAMINAR,
|
|
||||||
RE_TURBULENT,
|
|
||||||
);
|
|
||||||
// d/dRe of blend(f_lam(Re), f_turb(Re), Re): product rule on cubic Hermite.
|
// d/dRe of blend(f_lam(Re), f_turb(Re), Re): product rule on cubic Hermite.
|
||||||
let df_blend_dx = entropyk_core::smoothing::cubic_blend_derivative(
|
let df_blend_dx = entropyk_core::smoothing::cubic_blend_derivative(
|
||||||
f_lam,
|
f_lam,
|
||||||
@@ -178,12 +175,10 @@ fn martin1996_fanning(re: f64, chevron_angle_deg: f64) -> (f64, f64) {
|
|||||||
let t = ((re_safe - RE_LO) / (RE_HI - RE_LO)).clamp(0.0, 1.0);
|
let t = ((re_safe - RE_LO) / (RE_HI - RE_LO)).clamp(0.0, 1.0);
|
||||||
let w_b = t * t * (3.0 - 2.0 * t);
|
let w_b = t * t * (3.0 - 2.0 * t);
|
||||||
let w_a = 1.0 - w_b;
|
let w_a = 1.0 - w_b;
|
||||||
let df0_blend = entropyk_core::smoothing::cubic_blend_derivative(
|
let df0_blend =
|
||||||
f0_l, f0_t, re_safe, RE_LO, RE_HI,
|
entropyk_core::smoothing::cubic_blend_derivative(f0_l, f0_t, re_safe, RE_LO, RE_HI);
|
||||||
);
|
let df1_blend =
|
||||||
let df1_blend = entropyk_core::smoothing::cubic_blend_derivative(
|
entropyk_core::smoothing::cubic_blend_derivative(f1_l, f1_t, re_safe, RE_LO, RE_HI);
|
||||||
f1_l, f1_t, re_safe, RE_LO, RE_HI,
|
|
||||||
);
|
|
||||||
let df0 = df0_blend + w_a * df0_l + w_b * df0_t;
|
let df0 = df0_blend + w_a * df0_l + w_b * df0_t;
|
||||||
let df1 = df1_blend + w_a * df1_l + w_b * df1_t;
|
let df1 = df1_blend + w_a * df1_l + w_b * df1_t;
|
||||||
(f0, df0, f1, df1)
|
(f0, df0, f1, df1)
|
||||||
@@ -324,14 +319,9 @@ mod tests {
|
|||||||
1.0,
|
1.0,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let martin = evaluate_channel_pressure_drop(
|
let martin =
|
||||||
BphxDpCorrelation::Martin1996,
|
evaluate_channel_pressure_drop(BphxDpCorrelation::Martin1996, &geo(), g, rho, 1.0)
|
||||||
&geo(),
|
.unwrap();
|
||||||
g,
|
|
||||||
rho,
|
|
||||||
1.0,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
(simp.delta_p_pa - martin.delta_p_pa).abs() > 1.0,
|
(simp.delta_p_pa - martin.delta_p_pa).abs() > 1.0,
|
||||||
"Martin and Simplified should differ: {} vs {}",
|
"Martin and Simplified should differ: {} vs {}",
|
||||||
@@ -381,14 +371,9 @@ mod tests {
|
|||||||
fn martin_rejects_invalid_chevron() {
|
fn martin_rejects_invalid_chevron() {
|
||||||
let mut g = geo();
|
let mut g = geo();
|
||||||
g.chevron_angle = f64::NAN;
|
g.chevron_angle = f64::NAN;
|
||||||
let err = evaluate_channel_pressure_drop(
|
let err =
|
||||||
BphxDpCorrelation::Martin1996,
|
evaluate_channel_pressure_drop(BphxDpCorrelation::Martin1996, &g, 30.0, 1000.0, 1.0)
|
||||||
&g,
|
.unwrap_err();
|
||||||
30.0,
|
|
||||||
1000.0,
|
|
||||||
1.0,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(format!("{err}").contains("chevron"));
|
assert!(format!("{err}").contains("chevron"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,10 +388,8 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
let eval = evaluate_channel_pressure_drop(corr, &geo, g0, rho, 1.0).unwrap();
|
let eval = evaluate_channel_pressure_drop(corr, &geo, g0, rho, 1.0).unwrap();
|
||||||
let h = 1e-4;
|
let h = 1e-4;
|
||||||
let plus =
|
let plus = evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
|
||||||
evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
|
let minus = evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
|
||||||
let minus =
|
|
||||||
evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
|
|
||||||
let fd = (plus.delta_p_pa - minus.delta_p_pa) / (2.0 * h);
|
let fd = (plus.delta_p_pa - minus.delta_p_pa) / (2.0 * h);
|
||||||
let rel = (eval.d_delta_p_d_g - fd).abs() / fd.abs().max(1.0);
|
let rel = (eval.d_delta_p_d_g - fd).abs() / fd.abs().max(1.0);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -517,6 +517,10 @@ impl Component for BphxEvaporator {
|
|||||||
self.inner.energy_transfers(state)
|
self.inner.energy_transfers(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||||
|
self.inner.measure_output(kind, state)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_fluid_backend_from_builder(
|
fn set_fluid_backend_from_builder(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||||
|
|||||||
@@ -40,11 +40,14 @@ use super::correlation_registry::{
|
|||||||
};
|
};
|
||||||
use super::eps_ntu::{EpsNtuModel, ExchangerType};
|
use super::eps_ntu::{EpsNtuModel, ExchangerType};
|
||||||
use super::exchanger::{HeatExchanger, HxSideConditions};
|
use super::exchanger::{HeatExchanger, HxSideConditions};
|
||||||
|
use super::phase_change_entu::{condenser_duty, evaporator_duty};
|
||||||
|
use super::sat_domain;
|
||||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||||
use crate::{
|
use crate::{
|
||||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||||
};
|
};
|
||||||
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
|
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
|
||||||
|
use entropyk_fluids::{FluidId, FluidState, Property, Quality};
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -498,8 +501,7 @@ impl BphxExchanger {
|
|||||||
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
|
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
|
||||||
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
|
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
|
||||||
|
|
||||||
let a_flow =
|
let a_flow = self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
|
||||||
self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
|
|
||||||
if a_flow <= 1e-30 {
|
if a_flow <= 1e-30 {
|
||||||
return Err(ComponentError::InvalidState(
|
return Err(ComponentError::InvalidState(
|
||||||
"BPHX channel flow area too small for pressure-drop Jacobian".into(),
|
"BPHX channel flow area too small for pressure-drop Jacobian".into(),
|
||||||
@@ -528,6 +530,193 @@ impl BphxExchanger {
|
|||||||
let ua = h * self.geometry.area * self.calib().z_ua;
|
let ua = h * self.geometry.area * self.calib().z_ua;
|
||||||
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
|
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Condenser / evaporator plate HX use Shah \(C^*\to 0\) duty instead of
|
||||||
|
/// two-stream sensible ε-NTU (see `phase_change_entu`).
|
||||||
|
fn uses_phase_change_duty(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.geometry.exchanger_type,
|
||||||
|
BphxType::Condenser | BphxType::Evaporator
|
||||||
|
) && self.fluid_backend.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// \(T_{\mathrm{sat}}(P)\) [K] for the refrigerant fluid id, domain-clamped.
|
||||||
|
fn tsat_k(&self, p_pa: f64, refrigerant_id: &str) -> Result<f64, ComponentError> {
|
||||||
|
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
||||||
|
ComponentError::CalculationFailed(
|
||||||
|
"BphxExchanger: FluidBackend required for Tsat".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let p_pa =
|
||||||
|
sat_domain::clamp_to_saturation_domain(backend, refrigerant_id, p_pa).unwrap_or(p_pa);
|
||||||
|
backend
|
||||||
|
.property(
|
||||||
|
FluidId::new(refrigerant_id),
|
||||||
|
Property::Temperature,
|
||||||
|
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
|
||||||
|
)
|
||||||
|
.map_err(ComponentError::from_fluid_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary inlet \((T [K], C_{\mathrm{sec}} [W/K])\) without querying refrigerant \(c_p\).
|
||||||
|
fn secondary_inlet_capacity(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
|
||||||
|
let edges = self.inner.four_port_edges().ok_or_else(|| {
|
||||||
|
ComponentError::InvalidState(
|
||||||
|
"BphxExchanger: phase-change duty needs live four-port edges".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (m_idx, p_idx, h_idx) = match self.geometry.exchanger_type {
|
||||||
|
// Condenser: cold = secondary. Evaporator (remapped): hot = secondary.
|
||||||
|
BphxType::Condenser => edges.cold_in,
|
||||||
|
BphxType::Evaporator => edges.hot_in,
|
||||||
|
BphxType::Generic => {
|
||||||
|
return Err(ComponentError::InvalidState(
|
||||||
|
"BphxExchanger: Generic type has no phase-change secondary stream".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let max_idx = m_idx.max(p_idx).max(h_idx);
|
||||||
|
if max_idx >= state.len() {
|
||||||
|
return Err(ComponentError::InvalidStateDimensions {
|
||||||
|
expected: max_idx + 1,
|
||||||
|
actual: state.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let m = state[m_idx].max(0.0);
|
||||||
|
let p = state[p_idx];
|
||||||
|
let h = state[h_idx];
|
||||||
|
let (t, cp) = match self.geometry.exchanger_type {
|
||||||
|
BphxType::Condenser => (
|
||||||
|
self.inner.cold_side_temperature(p, h)?,
|
||||||
|
self.inner.cold_side_cp(p, h)?,
|
||||||
|
),
|
||||||
|
BphxType::Evaporator => (
|
||||||
|
self.inner.hot_side_temperature(p, h)?,
|
||||||
|
self.inner.hot_side_cp(p, h)?,
|
||||||
|
),
|
||||||
|
BphxType::Generic => unreachable!(),
|
||||||
|
};
|
||||||
|
Ok((t, m * cp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace sensible ε-NTU energy rows with phase-change duty.
|
||||||
|
///
|
||||||
|
/// Layout (unchanged): `r0` = hot energy, `r1` = cold energy, then DP rows.
|
||||||
|
fn overwrite_energy_with_phase_change(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
residuals: &mut ResidualVector,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
if residuals.len() < 2 {
|
||||||
|
return Err(ComponentError::InvalidResidualDimensions {
|
||||||
|
expected: 2,
|
||||||
|
actual: residuals.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let edges = self.inner.four_port_edges().ok_or_else(|| {
|
||||||
|
ComponentError::InvalidState(
|
||||||
|
"BphxExchanger: phase-change duty needs live four-port edges".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||||
|
let (_, _, h_h_out) = edges.hot_out;
|
||||||
|
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||||
|
let (_, _, h_c_out) = edges.cold_out;
|
||||||
|
let max_idx = [m_h, p_h_in, h_h_in, h_h_out, m_c, p_c_in, h_c_in, h_c_out]
|
||||||
|
.into_iter()
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
if max_idx >= state.len() {
|
||||||
|
return Err(ComponentError::InvalidStateDimensions {
|
||||||
|
expected: max_idx + 1,
|
||||||
|
actual: state.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (t_sec, c_sec) = self.secondary_inlet_capacity(state)?;
|
||||||
|
// Calibration redesign (WS-4): when a z_ua calibration variable is
|
||||||
|
// wired, the residual must track `state[z_ua]` (plain embedding), not
|
||||||
|
// the cached parameter — otherwise ∂Q/∂z_ua = 0 and the calibration
|
||||||
|
// Jacobian is singular. Mirrors exchanger.rs's `dynamic_f_ua` pattern.
|
||||||
|
let ua = match self.inner.calib_indices_ref().z_ua {
|
||||||
|
Some(z_idx) if z_idx < state.len() => self.inner.ua_nominal() * state[z_idx],
|
||||||
|
_ => self.ua(),
|
||||||
|
};
|
||||||
|
let m_hot = state[m_h].max(0.0);
|
||||||
|
let m_cold = state[m_c].max(0.0);
|
||||||
|
|
||||||
|
let q = match self.geometry.exchanger_type {
|
||||||
|
BphxType::Condenser => {
|
||||||
|
// hot = refrigerant, cold = secondary
|
||||||
|
let t_sat = self.tsat_k(state[p_h_in], self.inner.hot_fluid_id_str())?;
|
||||||
|
condenser_duty(ua, c_sec, t_sat, t_sec)
|
||||||
|
}
|
||||||
|
BphxType::Evaporator => {
|
||||||
|
// remapped: hot = secondary, cold = refrigerant
|
||||||
|
let t_sat = self.tsat_k(state[p_c_in], self.inner.cold_fluid_id_str())?;
|
||||||
|
evaporator_duty(ua, c_sec, t_sat, t_sec)
|
||||||
|
}
|
||||||
|
BphxType::Generic => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same sign convention as EpsNtuModel::compute_residuals.
|
||||||
|
residuals[0] = m_hot * (state[h_h_in] - state[h_h_out]) - q;
|
||||||
|
residuals[1] = m_cold * (state[h_c_out] - state[h_c_in]) - q;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finite-difference Jacobian through `self.compute_residuals` so energy
|
||||||
|
/// rows see phase-change duty (inner HeatExchanger FD would stay sensible).
|
||||||
|
fn fd_jacobian_via_self(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
jacobian: &mut JacobianBuilder,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
let Some(edges) = self.inner.four_port_edges() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||||
|
let (_, p_h_out, h_h_out) = edges.hot_out;
|
||||||
|
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||||
|
let (_, p_c_out, h_c_out) = edges.cold_out;
|
||||||
|
|
||||||
|
let mut cols = vec![
|
||||||
|
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
|
||||||
|
];
|
||||||
|
if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
|
||||||
|
cols.push(z_idx);
|
||||||
|
}
|
||||||
|
if let Some(z_idx) = self.inner.calib_indices_ref().z_ua {
|
||||||
|
cols.push(z_idx);
|
||||||
|
}
|
||||||
|
cols.sort_unstable();
|
||||||
|
cols.dedup();
|
||||||
|
cols.retain(|c| *c < state.len());
|
||||||
|
|
||||||
|
let n = self.n_equations();
|
||||||
|
let compute = |s: &[f64]| -> Result<Vec<f64>, ComponentError> {
|
||||||
|
let mut r = vec![0.0; n];
|
||||||
|
self.compute_residuals(s, &mut r)?;
|
||||||
|
Ok(r)
|
||||||
|
};
|
||||||
|
|
||||||
|
for &col in &cols {
|
||||||
|
let h = (state[col].abs() * 1e-6).max(1e-3);
|
||||||
|
let mut sp = state.to_vec();
|
||||||
|
sp[col] += h;
|
||||||
|
let rp = compute(&sp)?;
|
||||||
|
let mut sm = state.to_vec();
|
||||||
|
sm[col] -= h;
|
||||||
|
let rm = compute(&sm)?;
|
||||||
|
for row in 0..n {
|
||||||
|
let fd = (rp[row] - rm[row]) / (2.0 * h);
|
||||||
|
if fd.abs() > 1e-15 {
|
||||||
|
jacobian.add_entry(row, col, fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for BphxExchanger {
|
impl Component for BphxExchanger {
|
||||||
@@ -540,6 +729,23 @@ impl Component for BphxExchanger {
|
|||||||
state: &StateSlice,
|
state: &StateSlice,
|
||||||
residuals: &mut ResidualVector,
|
residuals: &mut ResidualVector,
|
||||||
) -> Result<(), ComponentError> {
|
) -> Result<(), ComponentError> {
|
||||||
|
if self.uses_phase_change_duty() {
|
||||||
|
// Skip HeatExchanger sensible ε-NTU (needs refrigerant cp, fails in
|
||||||
|
// the two-phase dome). Write Shah C*→0 energy + channel ΔP only.
|
||||||
|
let n = self.n_equations();
|
||||||
|
if residuals.len() < n {
|
||||||
|
return Err(ComponentError::InvalidResidualDimensions {
|
||||||
|
expected: n,
|
||||||
|
actual: residuals.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for r in residuals.iter_mut().take(n) {
|
||||||
|
*r = 0.0;
|
||||||
|
}
|
||||||
|
self.overwrite_energy_with_phase_change(state, residuals)?;
|
||||||
|
self.overwrite_pressure_closures_with_dp(state, residuals)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
self.inner.compute_residuals(state, residuals)?;
|
self.inner.compute_residuals(state, residuals)?;
|
||||||
// Replace isobaric P_out − P_in with channel ΔP on both sides.
|
// Replace isobaric P_out − P_in with channel ΔP on both sides.
|
||||||
self.overwrite_pressure_closures_with_dp(state, residuals)
|
self.overwrite_pressure_closures_with_dp(state, residuals)
|
||||||
@@ -550,6 +756,10 @@ impl Component for BphxExchanger {
|
|||||||
state: &StateSlice,
|
state: &StateSlice,
|
||||||
jacobian: &mut JacobianBuilder,
|
jacobian: &mut JacobianBuilder,
|
||||||
) -> Result<(), ComponentError> {
|
) -> Result<(), ComponentError> {
|
||||||
|
if self.uses_phase_change_duty() {
|
||||||
|
// FD must call this Component's residuals (phase-change energy + DP).
|
||||||
|
return self.fd_jacobian_via_self(state, jacobian);
|
||||||
|
}
|
||||||
self.inner.jacobian_entries(state, jacobian)?;
|
self.inner.jacobian_entries(state, jacobian)?;
|
||||||
// Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=−1; add ṁ and z_dp terms.
|
// Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=−1; add ṁ and z_dp terms.
|
||||||
self.append_pressure_dp_jacobian(state, jacobian)
|
self.append_pressure_dp_jacobian(state, jacobian)
|
||||||
@@ -587,6 +797,80 @@ impl Component for BphxExchanger {
|
|||||||
self.inner.energy_transfers(state)
|
self.inner.energy_transfers(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||||
|
use crate::MeasuredOutput::*;
|
||||||
|
let edges = self.inner.four_port_edges()?;
|
||||||
|
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||||
|
let (_, p_h_out, h_h_out) = edges.hot_out;
|
||||||
|
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||||
|
let (_, _, h_c_out) = edges.cold_out;
|
||||||
|
let max_idx = [
|
||||||
|
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, h_c_out,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.max()?;
|
||||||
|
if max_idx >= state.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Refrigerant side: hot for a condenser, cold (remapped) for an evaporator.
|
||||||
|
let (p_ref_in, p_ref_out, h_ref_out, m_ref, ref_fluid) = match self.geometry.exchanger_type
|
||||||
|
{
|
||||||
|
BphxType::Condenser => (p_h_in, p_h_out, h_h_out, m_h, self.inner.hot_fluid_id_str()),
|
||||||
|
BphxType::Evaporator => (p_c_in, p_c_in, h_c_out, m_c, self.inner.cold_fluid_id_str()),
|
||||||
|
BphxType::Generic => return None,
|
||||||
|
};
|
||||||
|
match kind {
|
||||||
|
SaturationTemperature => self.tsat_k(state[p_ref_in], ref_fluid).ok(),
|
||||||
|
Superheat | Subcooling => {
|
||||||
|
let backend = self.fluid_backend.as_ref()?;
|
||||||
|
let tsat = self.tsat_k(state[p_ref_out], ref_fluid).ok()?;
|
||||||
|
let t = backend
|
||||||
|
.property(
|
||||||
|
FluidId::new(ref_fluid),
|
||||||
|
Property::Temperature,
|
||||||
|
FluidState::from_ph(
|
||||||
|
entropyk_core::Pressure::from_pascals(state[p_ref_out]),
|
||||||
|
entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
if !t.is_finite() || t <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
Superheat => Some(t - tsat),
|
||||||
|
_ => Some(tsat - t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Capacity | HeatTransferRate => {
|
||||||
|
let q_hot = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
|
||||||
|
let q_cold = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
|
||||||
|
match (q_hot.is_finite(), q_cold.is_finite()) {
|
||||||
|
(true, true) => Some(0.5 * (q_hot + q_cold)),
|
||||||
|
(true, false) => Some(q_hot),
|
||||||
|
(false, true) => Some(q_cold),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MassFlowRate => Some(state[m_ref].abs()),
|
||||||
|
Pressure => Some(state[p_ref_in]),
|
||||||
|
Temperature => {
|
||||||
|
let backend = self.fluid_backend.as_ref()?;
|
||||||
|
backend
|
||||||
|
.property(
|
||||||
|
FluidId::new(ref_fluid),
|
||||||
|
Property::Temperature,
|
||||||
|
FluidState::from_ph(
|
||||||
|
entropyk_core::Pressure::from_pascals(state[p_ref_out]),
|
||||||
|
entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.filter(|t| t.is_finite() && *t > 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_fluid_backend_from_builder(
|
fn set_fluid_backend_from_builder(
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||||
@@ -983,8 +1267,8 @@ mod tests {
|
|||||||
|
|
||||||
// State layout per port triple (ṁ, P, h):
|
// State layout per port triple (ṁ, P, h):
|
||||||
// hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
|
// hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
|
||||||
let mut hx = BphxExchanger::new(test_geometry())
|
let mut hx =
|
||||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
hx.set_hot_fluid("Water");
|
hx.set_hot_fluid("Water");
|
||||||
hx.set_cold_fluid("Water");
|
hx.set_cold_fluid("Water");
|
||||||
hx.set_port_context(&[
|
hx.set_port_context(&[
|
||||||
@@ -1027,8 +1311,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// z_dp scale: half calib → half residual contribution at same P_out=P_in
|
// z_dp scale: half calib → half residual contribution at same P_out=P_in
|
||||||
let mut hx_half = BphxExchanger::new(test_geometry())
|
let mut hx_half =
|
||||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
hx_half.set_hot_fluid("Water");
|
hx_half.set_hot_fluid("Water");
|
||||||
hx_half.set_cold_fluid("Water");
|
hx_half.set_cold_fluid("Water");
|
||||||
hx_half.set_port_context(&[
|
hx_half.set_port_context(&[
|
||||||
@@ -1078,8 +1362,8 @@ mod tests {
|
|||||||
state[10] = 200_000.0;
|
state[10] = 200_000.0;
|
||||||
state[11] = 60_000.0;
|
state[11] = 60_000.0;
|
||||||
|
|
||||||
let mut simp = BphxExchanger::new(test_geometry())
|
let mut simp =
|
||||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
simp.set_hot_fluid("Water");
|
simp.set_hot_fluid("Water");
|
||||||
simp.set_cold_fluid("Water");
|
simp.set_cold_fluid("Water");
|
||||||
simp.set_port_context(&ports);
|
simp.set_port_context(&ports);
|
||||||
@@ -1102,4 +1386,110 @@ mod tests {
|
|||||||
"Martin residual should differ from Simplified"
|
"Martin residual should differ from Simplified"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_condenser_energy_uses_phase_change_duty() {
|
||||||
|
use entropyk_fluids::TestBackend;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// Condenser geometry → Shah C*→0 path (not sensible ṁ·cp).
|
||||||
|
let geo = test_geometry().with_exchanger_type(BphxType::Condenser);
|
||||||
|
let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
|
hx.set_hot_fluid("R134a");
|
||||||
|
hx.set_cold_fluid("Water");
|
||||||
|
hx.set_port_context(&[
|
||||||
|
Some((0, 1, 2)),
|
||||||
|
Some((3, 4, 5)),
|
||||||
|
Some((6, 7, 8)),
|
||||||
|
Some((9, 10, 11)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut state = vec![0.0; 12];
|
||||||
|
// Refrigerant ~10 bar, superheated-ish enthalpy table values
|
||||||
|
state[0] = 0.02;
|
||||||
|
state[1] = 1_000_000.0;
|
||||||
|
state[2] = 430_000.0;
|
||||||
|
state[3] = 0.02;
|
||||||
|
state[4] = 1_000_000.0;
|
||||||
|
state[5] = 250_000.0;
|
||||||
|
// Water secondary ~2 bar, ~30 °C
|
||||||
|
state[6] = 0.40;
|
||||||
|
state[7] = 200_000.0;
|
||||||
|
state[8] = 125_000.0;
|
||||||
|
state[9] = 0.40;
|
||||||
|
state[10] = 200_000.0;
|
||||||
|
state[11] = 140_000.0;
|
||||||
|
|
||||||
|
assert!(hx.uses_phase_change_duty());
|
||||||
|
let n = hx.n_equations();
|
||||||
|
let mut residuals = vec![0.0; n];
|
||||||
|
hx.compute_residuals(&state, &mut residuals).unwrap();
|
||||||
|
|
||||||
|
let t_sat = hx.tsat_k(state[1], "R134a").unwrap();
|
||||||
|
let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
|
||||||
|
let q = condenser_duty(hx.ua(), c_sec, t_sat, t_sec);
|
||||||
|
let q_hot = state[0] * (state[2] - state[5]);
|
||||||
|
let expected_r0 = q_hot - q;
|
||||||
|
assert!(
|
||||||
|
(residuals[0] - expected_r0).abs() < 1e-4 * expected_r0.abs().max(1.0),
|
||||||
|
"energy r0 must match condenser_duty: got {} expected {} (Q={}, Tsat={:.2}K)",
|
||||||
|
residuals[0],
|
||||||
|
expected_r0,
|
||||||
|
q,
|
||||||
|
t_sat
|
||||||
|
);
|
||||||
|
// Approach must use Tsat, not gas temperature — Q grows if Tsat rises.
|
||||||
|
let q_higher = condenser_duty(hx.ua(), c_sec, t_sat + 10.0, t_sec);
|
||||||
|
assert!(q_higher > q, "higher Tsat must increase condenser duty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_evaporator_energy_uses_phase_change_duty() {
|
||||||
|
use entropyk_fluids::TestBackend;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
|
||||||
|
let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
|
// Same remapping as BphxEvaporator: hot = secondary, cold = refrigerant.
|
||||||
|
hx.set_hot_fluid("Water");
|
||||||
|
hx.set_cold_fluid("R134a");
|
||||||
|
hx.set_port_context(&[
|
||||||
|
Some((0, 1, 2)),
|
||||||
|
Some((3, 4, 5)),
|
||||||
|
Some((6, 7, 8)),
|
||||||
|
Some((9, 10, 11)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut state = vec![0.0; 12];
|
||||||
|
// Water (hot) ~12 °C
|
||||||
|
state[0] = 0.50;
|
||||||
|
state[1] = 300_000.0;
|
||||||
|
state[2] = 50_000.0;
|
||||||
|
state[3] = 0.50;
|
||||||
|
state[4] = 300_000.0;
|
||||||
|
state[5] = 40_000.0;
|
||||||
|
// Refrigerant (cold) ~3.5 bar
|
||||||
|
state[6] = 0.02;
|
||||||
|
state[7] = 350_000.0;
|
||||||
|
state[8] = 250_000.0;
|
||||||
|
state[9] = 0.02;
|
||||||
|
state[10] = 350_000.0;
|
||||||
|
state[11] = 400_000.0;
|
||||||
|
|
||||||
|
assert!(hx.uses_phase_change_duty());
|
||||||
|
let mut residuals = vec![0.0; hx.n_equations()];
|
||||||
|
hx.compute_residuals(&state, &mut residuals).unwrap();
|
||||||
|
|
||||||
|
let t_sat = hx.tsat_k(state[7], "R134a").unwrap();
|
||||||
|
let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
|
||||||
|
let q = evaporator_duty(hx.ua(), c_sec, t_sat, t_sec);
|
||||||
|
let q_cold = state[6] * (state[11] - state[8]);
|
||||||
|
let expected_r1 = q_cold - q;
|
||||||
|
assert!(
|
||||||
|
(residuals[1] - expected_r1).abs() < 1e-4 * expected_r1.abs().max(1.0),
|
||||||
|
"energy r1 must match evaporator_duty: got {} expected {}",
|
||||||
|
residuals[1],
|
||||||
|
expected_r1
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -588,6 +588,34 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hot-side temperature [K] at `(P, h)` (live backend).
|
||||||
|
pub(crate) fn hot_side_temperature(
|
||||||
|
&self,
|
||||||
|
p_pa: f64,
|
||||||
|
h_jkg: f64,
|
||||||
|
) -> Result<f64, ComponentError> {
|
||||||
|
self.hot_temperature(p_pa, h_jkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hot-side \(c_p\) [J/(kg·K)] at `(P, h)`.
|
||||||
|
pub(crate) fn hot_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
|
||||||
|
self.hot_cp(p_pa, h_jkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cold-side temperature [K] at `(P, h)`.
|
||||||
|
pub(crate) fn cold_side_temperature(
|
||||||
|
&self,
|
||||||
|
p_pa: f64,
|
||||||
|
h_jkg: f64,
|
||||||
|
) -> Result<f64, ComponentError> {
|
||||||
|
self.cold_temperature(p_pa, h_jkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cold-side \(c_p\) [J/(kg·K)] at `(P, h)`.
|
||||||
|
pub(crate) fn cold_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
|
||||||
|
self.cold_cp(p_pa, h_jkg)
|
||||||
|
}
|
||||||
|
|
||||||
fn live_state_required_error(&self) -> ComponentError {
|
fn live_state_required_error(&self) -> ComponentError {
|
||||||
ComponentError::InvalidState(format!(
|
ComponentError::InvalidState(format!(
|
||||||
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
|
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ pub mod lmtd;
|
|||||||
pub mod mchx_condenser_coil;
|
pub mod mchx_condenser_coil;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod moving_boundary_hx;
|
pub mod moving_boundary_hx;
|
||||||
|
pub mod phase_change_entu;
|
||||||
pub mod pool_boiling;
|
pub mod pool_boiling;
|
||||||
pub mod sat_domain;
|
pub mod sat_domain;
|
||||||
pub mod shell_and_tube;
|
pub mod shell_and_tube;
|
||||||
@@ -119,6 +120,9 @@ pub use gas_cooler::{is_supercritical, pettersen_htc, GasCooler, GasCoolerInput}
|
|||||||
pub use lmtd::{FlowConfiguration, LmtdModel};
|
pub use lmtd::{FlowConfiguration, LmtdModel};
|
||||||
pub use mchx_condenser_coil::MchxCondenserCoil;
|
pub use mchx_condenser_coil::MchxCondenserCoil;
|
||||||
pub use model::HeatTransferModel;
|
pub use model::HeatTransferModel;
|
||||||
|
pub use phase_change_entu::{
|
||||||
|
condenser_duty, d_eps_c_d_c, evaporator_duty, phase_change_effectiveness,
|
||||||
|
};
|
||||||
pub use pool_boiling::{
|
pub use pool_boiling::{
|
||||||
assess_cooper_domain, cooper_1984, cooper_metadata, flooded_shell_htc, mostinski_1963,
|
assess_cooper_domain, cooper_1984, cooper_metadata, flooded_shell_htc, mostinski_1963,
|
||||||
palen_bundle_factor, thome_robinson_oil_factor, ua_from_two_side_htc, PoolBoilingInput,
|
palen_bundle_factor, thome_robinson_oil_factor, ua_from_two_side_htc, PoolBoilingInput,
|
||||||
|
|||||||
87
crates/components/src/heat_exchanger/phase_change_entu.rs
Normal file
87
crates/components/src/heat_exchanger/phase_change_entu.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//! Phase-change ε-NTU helpers (Shah / Incropera \(C^*\to 0\)).
|
||||||
|
//!
|
||||||
|
//! For condensers and evaporators where the refrigerant changes phase at
|
||||||
|
//! (ideally) constant \(T_{\mathrm{sat}}(P)\), the refrigerant heat-capacity
|
||||||
|
//! rate approaches infinity. Then \(C_{\min}=C_{\mathrm{sec}}\) and
|
||||||
|
//! \(\varepsilon = 1 - \exp(-\mathrm{UA}/C_{\mathrm{sec}})\).
|
||||||
|
//!
|
||||||
|
//! See Shah & Sekulić, *Fundamentals of Heat Exchanger Design* (Wiley 2003), §3.3.2.
|
||||||
|
|
||||||
|
/// Effectiveness for a single-stream (phase-change) exchanger:
|
||||||
|
/// \(\varepsilon = 1 - \exp(-\mathrm{UA}/C_{\mathrm{sec}})\).
|
||||||
|
#[inline]
|
||||||
|
pub fn phase_change_effectiveness(ua: f64, c_sec: f64) -> f64 {
|
||||||
|
if c_sec <= 1e-10 || ua <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
1.0 - (-ua / c_sec).exp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// \(g'(C)\) where \(g(C)=C\cdot\varepsilon(C)=C\cdot(1-e^{-\mathrm{UA}/C})\).
|
||||||
|
///
|
||||||
|
/// Used for \(\partial Q/\partial\dot{m}_{\mathrm{sec}}\).
|
||||||
|
#[inline]
|
||||||
|
pub fn d_eps_c_d_c(ua: f64, c_sec: f64) -> f64 {
|
||||||
|
if c_sec <= 1e-10 || ua <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let e = (-ua / c_sec).exp();
|
||||||
|
(1.0 - e) - (ua / c_sec) * e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Condenser duty [W]: \(Q = \varepsilon\,C_{\mathrm{sec}}\,(T_{\mathrm{sat}}-T_{\mathrm{sec,in}})\).
|
||||||
|
///
|
||||||
|
/// Positive \(Q\) = heat rejected by the refrigerant into the secondary.
|
||||||
|
#[inline]
|
||||||
|
pub fn condenser_duty(ua: f64, c_sec: f64, t_sat_k: f64, t_sec_in_k: f64) -> f64 {
|
||||||
|
let eps = phase_change_effectiveness(ua, c_sec);
|
||||||
|
eps * c_sec * (t_sat_k - t_sec_in_k)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaporator duty [W]: \(Q = \varepsilon\,C_{\mathrm{sec}}\,(T_{\mathrm{sec,in}}-T_{\mathrm{sat}})\).
|
||||||
|
///
|
||||||
|
/// Positive \(Q\) = heat absorbed by the refrigerant from the secondary.
|
||||||
|
#[inline]
|
||||||
|
pub fn evaporator_duty(ua: f64, c_sec: f64, t_sat_k: f64, t_sec_in_k: f64) -> f64 {
|
||||||
|
let eps = phase_change_effectiveness(ua, c_sec);
|
||||||
|
eps * c_sec * (t_sec_in_k - t_sat_k)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effectiveness_cr_zero_matches_textbook() {
|
||||||
|
let ua = 2500.0;
|
||||||
|
let c_sec = 0.4 * 4180.0;
|
||||||
|
let eps = phase_change_effectiveness(ua, c_sec);
|
||||||
|
let expected = 1.0 - (-ua / c_sec).exp();
|
||||||
|
assert!((eps - expected).abs() < 1e-12);
|
||||||
|
assert!(eps > 0.7 && eps < 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn condenser_duty_zero_when_tsat_equals_tsec() {
|
||||||
|
let q = condenser_duty(5000.0, 1000.0, 303.15, 303.15);
|
||||||
|
assert!(q.abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaporator_duty_positive_when_water_warmer_than_tsat() {
|
||||||
|
let q = evaporator_duty(2000.0, 2000.0, 278.15, 285.15);
|
||||||
|
assert!(q > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn d_eps_c_matches_fd() {
|
||||||
|
let ua = 3000.0;
|
||||||
|
let c = 1500.0;
|
||||||
|
let h = 1e-3 * c;
|
||||||
|
let gp = (c + h) * phase_change_effectiveness(ua, c + h);
|
||||||
|
let gm = (c - h) * phase_change_effectiveness(ua, c - h);
|
||||||
|
let fd = (gp - gm) / (2.0 * h);
|
||||||
|
let an = d_eps_c_d_c(ua, c);
|
||||||
|
assert!((an - fd).abs() / fd.abs().max(1e-9) < 1e-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ pub mod params;
|
|||||||
pub mod pipe;
|
pub mod pipe;
|
||||||
pub mod polynomials;
|
pub mod polynomials;
|
||||||
pub mod port;
|
pub mod port;
|
||||||
|
pub mod probe;
|
||||||
pub mod pump;
|
pub mod pump;
|
||||||
pub mod python_components;
|
pub mod python_components;
|
||||||
pub mod refrigerant_boundary;
|
pub mod refrigerant_boundary;
|
||||||
@@ -150,6 +151,7 @@ pub use port::{
|
|||||||
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId,
|
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId,
|
||||||
Port, PortKind,
|
Port, PortKind,
|
||||||
};
|
};
|
||||||
|
pub use probe::{Probe, ProbeMeasure};
|
||||||
pub use pump::{Pump, PumpCurves};
|
pub use pump::{Pump, PumpCurves};
|
||||||
pub use python_components::{
|
pub use python_components::{
|
||||||
PyAirSinkReal, PyAirSourceReal, PyBrineSinkReal, PyBrineSourceReal, PyCompressorReal,
|
PyAirSinkReal, PyAirSourceReal, PyBrineSinkReal, PyBrineSourceReal, PyCompressorReal,
|
||||||
|
|||||||
907
crates/components/src/probe.rs
Normal file
907
crates/components/src/probe.rs
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
//! Probe — zero-residual measurement tap on a connection line.
|
||||||
|
//!
|
||||||
|
//! Per the calibration redesign (see
|
||||||
|
//! `_bmad-output/implementation-artifacts/calibration-probe-node-plain-embedding.md`),
|
||||||
|
//! **every** calibration measurement lives on a `Probe` node the user drops
|
||||||
|
//! onto a wire. The calibrated z-factor (`z_ua`, `z_dp`, `z_flow`, ...) on a
|
||||||
|
//! component is freed, and a `Probe` elsewhere on the graph supplies the
|
||||||
|
//! matching measurement via its [`Component::measure_output`] override.
|
||||||
|
//!
|
||||||
|
//! ## Design
|
||||||
|
//!
|
||||||
|
//! - **Zero residual, zero Jacobian** — `n_equations() == 0`. The Probe is a
|
||||||
|
//! measurement tap, not a physical element. The calibration `Constraint`
|
||||||
|
//! (one residual, one unknown z-factor) closes the system.
|
||||||
|
//! - **Two ports `inlet`/`outlet`** — same typestate pattern as `Pipe`/`Pump`/
|
||||||
|
//! `Node`. The Probe splices into an edge A→B (becomes A→Probe→B) exactly
|
||||||
|
//! like `Pipe` does in `apps/web/src/lib/edgeInsert.ts`, so the UI drop-on-
|
||||||
|
//! wire interaction reuses `insertOnEdge`.
|
||||||
|
//! - **Reads live edge state** via [`Component::set_system_context`]. The
|
||||||
|
//! Probe stores the `(ṁ, P, h)` triples for both its incident edges (the
|
||||||
|
//! two halves of the spliced wire) and uses them in `measure_output`.
|
||||||
|
//!
|
||||||
|
//! ## Measure kinds
|
||||||
|
//!
|
||||||
|
//! [`ProbeMeasure`] is the exhaustive list the user named. SST/SDT/DGT/DSH/
|
||||||
|
//! SH/SC all derive from `(P, T)` on the same edge via CoolProp — the user
|
||||||
|
//! picks the semantic kind for clarity, the math is P/T/Tsat:
|
||||||
|
//!
|
||||||
|
//! | Kind | Formula |
|
||||||
|
//! |------|---------|
|
||||||
|
//! | [`Sst`](ProbeMeasure::Sst) / [`Sdt`](ProbeMeasure::Sdt) | `Tsat(P_edge)` |
|
||||||
|
//! | [`Dgt`](ProbeMeasure::Dgt) / [`T`](ProbeMeasure::T) | `T_edge` |
|
||||||
|
//! | [`Dsh`](ProbeMeasure::Dsh) / [`Sh`](ProbeMeasure::Sh) | `T_edge − Tsat(P_edge)` |
|
||||||
|
//! | [`Sc`](ProbeMeasure::Sc) | `Tsat(P_edge) − T_edge` |
|
||||||
|
//! | [`P`](ProbeMeasure::P) | `P_edge` |
|
||||||
|
//! | [`MassFlow`](ProbeMeasure::MassFlow) | `ṁ_edge` |
|
||||||
|
//! | [`Enthalpy`](ProbeMeasure::Enthalpy) | `h_edge` |
|
||||||
|
//! | [`Capacity`](ProbeMeasure::Capacity) | `ṁ_edge · |h_inlet − h_outlet|` |
|
||||||
|
//!
|
||||||
|
//! `Tsat(P)` reuses the `tsat_k` pattern from
|
||||||
|
//! `crates/components/src/heat_exchanger/bphx_exchanger.rs:543-559` (clamps
|
||||||
|
//! the query pressure into the detected saturation domain via
|
||||||
|
//! `sat_domain::clamp_to_saturation_domain`).
|
||||||
|
|
||||||
|
use crate::heat_exchanger::sat_domain;
|
||||||
|
use crate::port::{Connected, Disconnected, Port};
|
||||||
|
use crate::state_machine::StateManageable;
|
||||||
|
use crate::{
|
||||||
|
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput,
|
||||||
|
OperationalState, ResidualVector, StateSlice,
|
||||||
|
};
|
||||||
|
use entropyk_core::{CalibIndices, MassFlow, Power};
|
||||||
|
use entropyk_fluids::{FluidBackend, FluidId as BackendFluidId, FluidState, Property, Quality};
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Semantic quantity a [`Probe`] extracts from its edge.
|
||||||
|
///
|
||||||
|
/// Exhaustive list per the user mandate (see module docs). SST/SDT and DSH/SH
|
||||||
|
/// share formulas by design — the user picks the label that matches the
|
||||||
|
/// physical sensor location (suction vs discharge).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ProbeMeasure {
|
||||||
|
/// Saturated suction temperature = `Tsat(P_edge)`.
|
||||||
|
Sst,
|
||||||
|
/// Saturated discharge temperature = `Tsat(P_edge)` (same formula, label only).
|
||||||
|
Sdt,
|
||||||
|
/// Discharge gas temperature = `T_edge` (raw temperature at the probe).
|
||||||
|
Dgt,
|
||||||
|
/// Discharge superheat = `T_edge − Tsat(P_edge)`.
|
||||||
|
Dsh,
|
||||||
|
/// (Suction) superheat = `T_edge − Tsat(P_edge)` (same formula as Dsh).
|
||||||
|
Sh,
|
||||||
|
/// Subcooling = `Tsat(P_edge) − T_edge`.
|
||||||
|
Sc,
|
||||||
|
/// Raw temperature = `T_edge`.
|
||||||
|
T,
|
||||||
|
/// Raw pressure = `P_edge`.
|
||||||
|
P,
|
||||||
|
/// Mass flow rate = `ṁ_edge`.
|
||||||
|
MassFlow,
|
||||||
|
/// Heat duty = `ṁ · |Δh|` across the probe (see [`Probe`] module docs).
|
||||||
|
Capacity,
|
||||||
|
/// Raw specific enthalpy = `h_edge`.
|
||||||
|
Enthalpy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProbeMeasure {
|
||||||
|
/// Parses a measure-kind string from the JSON config (case-insensitive).
|
||||||
|
/// Accepts the canonical names plus a few common aliases.
|
||||||
|
pub fn parse(s: &str) -> Result<Self, String> {
|
||||||
|
let lower = s.trim().to_ascii_lowercase();
|
||||||
|
let m = match lower.as_str() {
|
||||||
|
"sst" => ProbeMeasure::Sst,
|
||||||
|
"sdt" => ProbeMeasure::Sdt,
|
||||||
|
"dgt" => ProbeMeasure::Dgt,
|
||||||
|
"dsh" => ProbeMeasure::Dsh,
|
||||||
|
"sh" | "superheat" => ProbeMeasure::Sh,
|
||||||
|
"sc" | "subcooling" => ProbeMeasure::Sc,
|
||||||
|
"t" | "temperature" => ProbeMeasure::T,
|
||||||
|
"p" | "pressure" => ProbeMeasure::P,
|
||||||
|
"massflow" | "mass_flow" | "mass flow" | "massflowrate" | "mass_flow_rate" => {
|
||||||
|
ProbeMeasure::MassFlow
|
||||||
|
}
|
||||||
|
"capacity" | "heatduty" | "heat_transfer_rate" | "heatrate" | "duty" => {
|
||||||
|
ProbeMeasure::Capacity
|
||||||
|
}
|
||||||
|
"enthalpy" | "h" => ProbeMeasure::Enthalpy,
|
||||||
|
other => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown Probe measure '{other}' (expected one of: SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy)"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical JSON name for this measure (round-trips through `parse`).
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProbeMeasure::Sst => "SST",
|
||||||
|
ProbeMeasure::Sdt => "SDT",
|
||||||
|
ProbeMeasure::Dgt => "DGT",
|
||||||
|
ProbeMeasure::Dsh => "DSH",
|
||||||
|
ProbeMeasure::Sh => "SH",
|
||||||
|
ProbeMeasure::Sc => "SC",
|
||||||
|
ProbeMeasure::T => "T",
|
||||||
|
ProbeMeasure::P => "P",
|
||||||
|
ProbeMeasure::MassFlow => "MassFlow",
|
||||||
|
ProbeMeasure::Capacity => "Capacity",
|
||||||
|
ProbeMeasure::Enthalpy => "Enthalpy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps this measure to the solver-side [`MeasuredOutput`] the calibration
|
||||||
|
/// constraint will ask for. SST/SDT → `SaturationTemperature`; DSH/SH →
|
||||||
|
/// `Superheat`; SC → `Subcooling`; DGT/T → `Temperature`; P → `Pressure`;
|
||||||
|
/// MassFlow → `MassFlowRate`; Capacity → `Capacity`; Enthalpy →
|
||||||
|
/// `HeatTransferRate` is NOT right, so Enthalpy returns `Temperature` as a
|
||||||
|
/// placeholder (the Probe still serves it via `measure_output(Temperature)`
|
||||||
|
/// returning T, and Enthalpy is read directly via the measure-output hook
|
||||||
|
/// below — see [`Probe::measure_output`] which honours the configured kind).
|
||||||
|
pub fn to_measured_output(&self) -> MeasuredOutput {
|
||||||
|
match self {
|
||||||
|
ProbeMeasure::Sst | ProbeMeasure::Sdt => MeasuredOutput::SaturationTemperature,
|
||||||
|
ProbeMeasure::Dsh | ProbeMeasure::Sh => MeasuredOutput::Superheat,
|
||||||
|
ProbeMeasure::Sc => MeasuredOutput::Subcooling,
|
||||||
|
ProbeMeasure::Dgt | ProbeMeasure::T => MeasuredOutput::Temperature,
|
||||||
|
ProbeMeasure::P => MeasuredOutput::Pressure,
|
||||||
|
ProbeMeasure::MassFlow => MeasuredOutput::MassFlowRate,
|
||||||
|
ProbeMeasure::Capacity => MeasuredOutput::Capacity,
|
||||||
|
// No dedicated Enthalpy variant in MeasuredOutput — collapse onto
|
||||||
|
// HeatTransferRate so the constraint resolves; measure_output
|
||||||
|
// detects the configured Enthalpy kind and returns h_edge.
|
||||||
|
ProbeMeasure::Enthalpy => MeasuredOutput::HeatTransferRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero-residual measurement tap on a connection line.
|
||||||
|
///
|
||||||
|
/// See the module docs for the calibration redesign context.
|
||||||
|
///
|
||||||
|
/// Like `Pipe` and `Pump`, `Probe` uses a typestate (`Disconnected` →
|
||||||
|
/// `Connected`) so the compiler enforces port connection before the component
|
||||||
|
/// is added to a system.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Probe<State> {
|
||||||
|
measure: ProbeMeasure,
|
||||||
|
fluid_id_str: String,
|
||||||
|
port_inlet: Port<State>,
|
||||||
|
port_outlet: Port<State>,
|
||||||
|
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||||
|
circuit_id: CircuitId,
|
||||||
|
operational_state: OperationalState,
|
||||||
|
calib_indices: CalibIndices,
|
||||||
|
// Live edge-state indices populated by `set_system_context`.
|
||||||
|
inlet_m_idx: Option<usize>,
|
||||||
|
inlet_p_idx: Option<usize>,
|
||||||
|
inlet_h_idx: Option<usize>,
|
||||||
|
outlet_m_idx: Option<usize>,
|
||||||
|
outlet_p_idx: Option<usize>,
|
||||||
|
outlet_h_idx: Option<usize>,
|
||||||
|
_state: PhantomData<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<State> std::fmt::Debug for Probe<State> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Probe")
|
||||||
|
.field("measure", &self.measure)
|
||||||
|
.field("fluid", &self.fluid_id_str)
|
||||||
|
.field("has_backend", &self.fluid_backend.is_some())
|
||||||
|
.field("circuit_id", &self.circuit_id)
|
||||||
|
.field("operational_state", &self.operational_state)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe<Disconnected> {
|
||||||
|
/// Creates a new disconnected Probe.
|
||||||
|
///
|
||||||
|
/// `fluid` is used for `Tsat`/`SH`/`SC` derivation via the configured
|
||||||
|
/// fluid backend. The backend itself is injected later by
|
||||||
|
/// [`Component::set_fluid_backend_from_builder`] when the system is built.
|
||||||
|
pub fn new(
|
||||||
|
measure: ProbeMeasure,
|
||||||
|
fluid: impl Into<String>,
|
||||||
|
port_inlet: Port<Disconnected>,
|
||||||
|
port_outlet: Port<Disconnected>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
measure,
|
||||||
|
fluid_id_str: fluid.into(),
|
||||||
|
port_inlet,
|
||||||
|
port_outlet,
|
||||||
|
fluid_backend: None,
|
||||||
|
circuit_id: CircuitId::default(),
|
||||||
|
operational_state: OperationalState::default(),
|
||||||
|
calib_indices: CalibIndices::default(),
|
||||||
|
inlet_m_idx: None,
|
||||||
|
inlet_p_idx: None,
|
||||||
|
inlet_h_idx: None,
|
||||||
|
outlet_m_idx: None,
|
||||||
|
outlet_p_idx: None,
|
||||||
|
outlet_h_idx: None,
|
||||||
|
_state: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches a fluid backend for property queries (Tsat, T from P,h).
|
||||||
|
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||||
|
self.fluid_backend = Some(backend);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects the Probe to its inlet and outlet ports, transitioning to
|
||||||
|
/// `Probe<Connected>`. Mirrors `Pipe::connect` / `Pump::connect`.
|
||||||
|
pub fn connect(
|
||||||
|
self,
|
||||||
|
inlet: Port<Disconnected>,
|
||||||
|
outlet: Port<Disconnected>,
|
||||||
|
) -> Result<Probe<Connected>, ComponentError> {
|
||||||
|
let (p_in, _) = self
|
||||||
|
.port_inlet
|
||||||
|
.connect(inlet)
|
||||||
|
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||||
|
let (p_out, _) = self
|
||||||
|
.port_outlet
|
||||||
|
.connect(outlet)
|
||||||
|
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||||
|
Ok(Probe {
|
||||||
|
measure: self.measure,
|
||||||
|
fluid_id_str: self.fluid_id_str,
|
||||||
|
port_inlet: p_in,
|
||||||
|
port_outlet: p_out,
|
||||||
|
fluid_backend: self.fluid_backend,
|
||||||
|
circuit_id: self.circuit_id,
|
||||||
|
operational_state: self.operational_state,
|
||||||
|
calib_indices: self.calib_indices,
|
||||||
|
inlet_m_idx: self.inlet_m_idx,
|
||||||
|
inlet_p_idx: self.inlet_p_idx,
|
||||||
|
inlet_h_idx: self.inlet_h_idx,
|
||||||
|
outlet_m_idx: self.outlet_m_idx,
|
||||||
|
outlet_p_idx: self.outlet_p_idx,
|
||||||
|
outlet_h_idx: self.outlet_h_idx,
|
||||||
|
_state: PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe<Connected> {
|
||||||
|
/// Returns the configured measure kind.
|
||||||
|
pub fn measure(&self) -> ProbeMeasure {
|
||||||
|
self.measure
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the fluid identifier string the Probe derives properties for.
|
||||||
|
pub fn fluid_id_str(&self) -> &str {
|
||||||
|
&self.fluid_id_str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the inlet port.
|
||||||
|
pub fn port_inlet(&self) -> &Port<Connected> {
|
||||||
|
&self.port_inlet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the outlet port.
|
||||||
|
pub fn port_outlet(&self) -> &Port<Connected> {
|
||||||
|
&self.port_outlet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns both ports as a slice.
|
||||||
|
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
|
||||||
|
[&self.port_inlet, &self.port_outlet]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// \(T_{\mathrm{sat}}(P)\) [K] for the configured fluid, domain-clamped.
|
||||||
|
/// Mirrors `BphxExchanger::tsat_k` (`bphx_exchanger.rs:544-559`).
|
||||||
|
fn tsat_k(&self, p_pa: f64) -> Result<f64, ComponentError> {
|
||||||
|
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
||||||
|
ComponentError::CalculationFailed(
|
||||||
|
"Probe: FluidBackend required for Tsat/SH/SC derivation".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let p_pa =
|
||||||
|
sat_domain::clamp_to_saturation_domain(backend, &self.fluid_id_str, p_pa).unwrap_or(p_pa);
|
||||||
|
backend
|
||||||
|
.property(
|
||||||
|
BackendFluidId::new(&self.fluid_id_str),
|
||||||
|
Property::Temperature,
|
||||||
|
FluidState::from_px(
|
||||||
|
entropyk_core::Pressure::from_pascals(p_pa),
|
||||||
|
Quality::new(0.5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(ComponentError::from_fluid_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temperature at the probe edge from `(P, h)` via the fluid backend.
|
||||||
|
fn temperature_k(&self, p_pa: f64, h_j_kg: f64) -> Result<f64, ComponentError> {
|
||||||
|
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
||||||
|
ComponentError::CalculationFailed(
|
||||||
|
"Probe: FluidBackend required for temperature derivation".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let t = backend
|
||||||
|
.property(
|
||||||
|
BackendFluidId::new(&self.fluid_id_str),
|
||||||
|
Property::Temperature,
|
||||||
|
FluidState::from_ph(
|
||||||
|
entropyk_core::Pressure::from_pascals(p_pa),
|
||||||
|
entropyk_core::Enthalpy::from_joules_per_kg(h_j_kg),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(ComponentError::from_fluid_error)?;
|
||||||
|
if !t.is_finite() || t <= 0.0 {
|
||||||
|
return Err(ComponentError::CalculationFailed(format!(
|
||||||
|
"Probe: non-physical temperature {t} for P={p_pa} Pa, h={h_j_kg} J/kg"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads `(ṁ, P, h)` of the inlet edge from the global state, or `None`
|
||||||
|
/// when the indices have not been wired yet.
|
||||||
|
fn inlet_state(&self, state: &StateSlice) -> Option<(f64, f64, f64)> {
|
||||||
|
let (m, p, h) = (
|
||||||
|
self.inlet_m_idx?,
|
||||||
|
self.inlet_p_idx?,
|
||||||
|
self.inlet_h_idx?,
|
||||||
|
);
|
||||||
|
if m >= state.len() || p >= state.len() || h >= state.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((state[m], state[p], state[h]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the outlet-edge `(P, h)` from the global state, or `None`.
|
||||||
|
fn outlet_state(&self, state: &StateSlice) -> Option<(f64, f64)> {
|
||||||
|
let (p, h) = (self.outlet_p_idx?, self.outlet_h_idx?);
|
||||||
|
if p >= state.len() || h >= state.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((state[p], state[h]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for Probe<Connected> {
|
||||||
|
fn set_system_context(
|
||||||
|
&mut self,
|
||||||
|
_state_offset: usize,
|
||||||
|
external_edge_state_indices: &[(usize, usize, usize)],
|
||||||
|
) {
|
||||||
|
// The Probe is a 2-port splice; layout mirrors Pipe/Pump:
|
||||||
|
// [0] = incoming edge (upstream→probe), [1] = outgoing edge (probe→downstream).
|
||||||
|
// The Probe measures at its location; for SST/SDT/DGT/DSH/SH/SC/T/P we
|
||||||
|
// use the inlet edge (the edge the user dropped the probe onto). For
|
||||||
|
// Capacity we use both edges (ṁ·|Δh| across the splice).
|
||||||
|
if let Some(&(m, p, h)) = external_edge_state_indices.first() {
|
||||||
|
self.inlet_m_idx = Some(m);
|
||||||
|
self.inlet_p_idx = Some(p);
|
||||||
|
self.inlet_h_idx = Some(h);
|
||||||
|
}
|
||||||
|
if let Some(&(m, p, h)) = external_edge_state_indices.get(1) {
|
||||||
|
self.outlet_m_idx = Some(m);
|
||||||
|
self.outlet_p_idx = Some(p);
|
||||||
|
self.outlet_h_idx = Some(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_residuals(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
residuals: &mut ResidualVector,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
// Two topology residuals any 2-port splice must impose to keep the
|
||||||
|
// DoF balance closed (mirrors `Anchor` and edge-coupled `Pipe`):
|
||||||
|
// r0 = P_out − P_in (zero-resistance pressure pass-through)
|
||||||
|
// r1 = h_out − h_in (adiabatic enthalpy pass-through)
|
||||||
|
// These are NOT calibration residuals — they are the continuity that
|
||||||
|
// makes the Probe a transparent tap. The calibration `Constraint`
|
||||||
|
// (measure − target) supplies the only calibration-related residual.
|
||||||
|
if residuals.len() < 2 {
|
||||||
|
return Err(ComponentError::InvalidResidualDimensions {
|
||||||
|
expected: 2,
|
||||||
|
actual: residuals.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
|
||||||
|
self.inlet_p_idx,
|
||||||
|
self.inlet_h_idx,
|
||||||
|
self.outlet_p_idx,
|
||||||
|
self.outlet_h_idx,
|
||||||
|
) {
|
||||||
|
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
|
||||||
|
_ => {
|
||||||
|
return Err(ComponentError::InvalidState(
|
||||||
|
"Probe requires live inlet and outlet edge state indices".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let max_idx = in_p_idx.max(in_h_idx).max(out_p_idx).max(out_h_idx);
|
||||||
|
if max_idx >= state.len() {
|
||||||
|
return Err(ComponentError::InvalidStateDimensions {
|
||||||
|
expected: max_idx + 1,
|
||||||
|
actual: state.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
residuals[0] = state[out_p_idx] - state[in_p_idx];
|
||||||
|
residuals[1] = state[out_h_idx] - state[in_h_idx];
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jacobian_entries(
|
||||||
|
&self,
|
||||||
|
_state: &StateSlice,
|
||||||
|
jacobian: &mut JacobianBuilder,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
// ∂r0/∂P_out = +1, ∂r0/∂P_in = −1 ; ∂r1/∂h_out = +1, ∂r1/∂h_in = −1
|
||||||
|
let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
|
||||||
|
self.inlet_p_idx,
|
||||||
|
self.inlet_h_idx,
|
||||||
|
self.outlet_p_idx,
|
||||||
|
self.outlet_h_idx,
|
||||||
|
) {
|
||||||
|
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
|
||||||
|
_ => {
|
||||||
|
return Err(ComponentError::InvalidState(
|
||||||
|
"Probe Jacobian requires live inlet and outlet edge state indices".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
jacobian.add_entry(0, out_p_idx, 1.0);
|
||||||
|
jacobian.add_entry(0, in_p_idx, -1.0);
|
||||||
|
jacobian.add_entry(1, out_h_idx, 1.0);
|
||||||
|
jacobian.add_entry(1, in_h_idx, -1.0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn equation_roles(&self) -> Vec<crate::EquationRole> {
|
||||||
|
vec![
|
||||||
|
crate::EquationRole::Continuity { quantity: "P" },
|
||||||
|
crate::EquationRole::Continuity { quantity: "h" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn n_equations(&self) -> usize {
|
||||||
|
// 2 topology-continuity residuals (P, h) — see `compute_residuals`.
|
||||||
|
// NOT a calibration residual: the calibration Constraint supplies that.
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_ports(&self) -> &[ConnectedPort] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn port_names(&self) -> Vec<String> {
|
||||||
|
vec!["inlet".to_string(), "outlet".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||||
|
// Single series path through the tap.
|
||||||
|
vec![(0, 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn port_mass_flows(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
) -> Result<Vec<MassFlow>, ComponentError> {
|
||||||
|
let m_in = match self.inlet_m_idx {
|
||||||
|
Some(idx) if idx < state.len() => state[idx],
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
let m_out = match self.outlet_m_idx {
|
||||||
|
Some(idx) if idx < state.len() => state[idx],
|
||||||
|
_ => m_in,
|
||||||
|
};
|
||||||
|
Ok(vec![
|
||||||
|
MassFlow::from_kg_per_s(m_in),
|
||||||
|
MassFlow::from_kg_per_s(-m_out),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn port_enthalpies(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||||
|
let h_in = match self.inlet_h_idx {
|
||||||
|
Some(idx) if idx < state.len() => state[idx],
|
||||||
|
_ => self.port_inlet.enthalpy().to_joules_per_kg(),
|
||||||
|
};
|
||||||
|
let h_out = match self.outlet_h_idx {
|
||||||
|
Some(idx) if idx < state.len() => state[idx],
|
||||||
|
_ => self.port_outlet.enthalpy().to_joules_per_kg(),
|
||||||
|
};
|
||||||
|
Ok(vec![
|
||||||
|
entropyk_core::Enthalpy::from_joules_per_kg(h_in),
|
||||||
|
entropyk_core::Enthalpy::from_joules_per_kg(h_out),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||||
|
// A Probe owns no z-factors; the slot is kept only for trait
|
||||||
|
// compatibility with the System finalize path.
|
||||||
|
self.calib_indices = indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_fluid_backend_from_builder(
|
||||||
|
&mut self,
|
||||||
|
backend: Arc<dyn FluidBackend>,
|
||||||
|
) {
|
||||||
|
if self.fluid_backend.is_none() {
|
||||||
|
self.fluid_backend = Some(Arc::clone(&backend));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
|
||||||
|
// A measurement tap is adiabatic.
|
||||||
|
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||||
|
// Resolve which edge we measure on. SST/SDT use the inlet edge's
|
||||||
|
// pressure; DGT/T/P/MassFlow/Enthalpy also use the inlet edge (the
|
||||||
|
// physical sensor location). For DSH/SH/SC we need (P, T) on the same
|
||||||
|
// edge. For Capacity we use ṁ and |Δh| across the splice.
|
||||||
|
let (m_in, p_in, h_in) = self.inlet_state(state)?;
|
||||||
|
// When the configured measure is Enthalpy, return h_edge regardless of
|
||||||
|
// the `kind` the constraint asks for (we map Enthalpy →
|
||||||
|
// HeatTransferRate at the routing layer because MeasuredOutput has no
|
||||||
|
// Enthalpy variant).
|
||||||
|
if self.measure == ProbeMeasure::Enthalpy {
|
||||||
|
return Some(h_in);
|
||||||
|
}
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
MeasuredOutput::SaturationTemperature => Some(self.tsat_k(p_in).ok()?),
|
||||||
|
MeasuredOutput::Pressure => Some(p_in),
|
||||||
|
MeasuredOutput::MassFlowRate => Some(m_in.abs()),
|
||||||
|
MeasuredOutput::Temperature => {
|
||||||
|
// DGT/T → raw temperature at the probe. Falls back to None
|
||||||
|
// when the backend is unavailable or returns a non-physical T.
|
||||||
|
self.temperature_k(p_in, h_in).ok()
|
||||||
|
}
|
||||||
|
MeasuredOutput::Superheat | MeasuredOutput::Subcooling => {
|
||||||
|
let tsat = self.tsat_k(p_in).ok()?;
|
||||||
|
let t = self.temperature_k(p_in, h_in).ok()?;
|
||||||
|
if !t.is_finite() || t <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
MeasuredOutput::Superheat => Some(t - tsat),
|
||||||
|
_ => Some(tsat - t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => {
|
||||||
|
// Capacity = ṁ · |Δh| across the probe splice. Uses both edges
|
||||||
|
// when available; if the outlet index isn't wired (defensive),
|
||||||
|
// falls back to zero duty.
|
||||||
|
if let Some((_, h_out)) = self.outlet_state(state) {
|
||||||
|
let dh = (h_in - h_out).abs();
|
||||||
|
let q = m_in.abs() * dh;
|
||||||
|
if q.is_finite() {
|
||||||
|
return Some(q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"Probe(measure={}, fluid={}, circuit={})",
|
||||||
|
self.measure.as_str(),
|
||||||
|
self.fluid_id_str,
|
||||||
|
self.circuit_id.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_params(&self) -> crate::ComponentParams {
|
||||||
|
crate::ComponentParams::new("Probe")
|
||||||
|
.with_param("measure", self.measure.as_str().to_string())
|
||||||
|
.with_param("fluid", self.fluid_id_str.clone())
|
||||||
|
.with_param("circuitId", self.circuit_id.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateManageable for Probe<Connected> {
|
||||||
|
fn state(&self) -> OperationalState {
|
||||||
|
self.operational_state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||||
|
if self.operational_state.can_transition_to(state) {
|
||||||
|
self.operational_state = state;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ComponentError::InvalidStateTransition {
|
||||||
|
from: self.operational_state,
|
||||||
|
to: state,
|
||||||
|
reason: "Transition not allowed".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||||||
|
self.operational_state.can_transition_to(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn circuit_id(&self) -> &CircuitId {
|
||||||
|
&self.circuit_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||||||
|
self.circuit_id = circuit_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::port::FluidId;
|
||||||
|
use entropyk_core::{Enthalpy, Pressure};
|
||||||
|
use entropyk_fluids::TestBackend;
|
||||||
|
|
||||||
|
fn build_probe(measure: ProbeMeasure) -> Probe<Connected> {
|
||||||
|
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
|
||||||
|
let mk = || {
|
||||||
|
Port::new(
|
||||||
|
FluidId::new("R134a"),
|
||||||
|
Pressure::from_bar(3.0),
|
||||||
|
Enthalpy::from_joules_per_kg(400_000.0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let probe_disc = Probe::new(measure, "R134a", mk(), mk()).with_fluid_backend(backend);
|
||||||
|
// The connect() call needs a second pair of ports (mirrors Pipe/Pump
|
||||||
|
// CLI idiom — both pairs carry identical nominal values).
|
||||||
|
probe_disc.connect(mk(), mk()).expect("probe connect")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: equip a Probe with live edge indices pointing at a synthetic
|
||||||
|
/// state vector `[ṁ_in, P_in, h_in, ṁ_out, P_out, h_out]`.
|
||||||
|
fn wire_state_indices(probe: &mut Probe<Connected>) {
|
||||||
|
probe.inlet_m_idx = Some(0);
|
||||||
|
probe.inlet_p_idx = Some(1);
|
||||||
|
probe.inlet_h_idx = Some(2);
|
||||||
|
probe.outlet_m_idx = Some(3);
|
||||||
|
probe.outlet_p_idx = Some(4);
|
||||||
|
probe.outlet_h_idx = Some(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_imposes_continuity_only() {
|
||||||
|
// The Probe is a zero-resistance tap: it contributes 2 topology
|
||||||
|
// residuals (P_out = P_in, h_out = h_in) so the spliced edge stays
|
||||||
|
// DoF-balanced. It does NOT contribute a calibration residual —
|
||||||
|
// the calibration Constraint (measure − target) supplies that.
|
||||||
|
let probe = build_probe(ProbeMeasure::Sh);
|
||||||
|
assert_eq!(probe.n_equations(), 2);
|
||||||
|
let roles = probe.equation_roles();
|
||||||
|
assert_eq!(roles.len(), 2);
|
||||||
|
|
||||||
|
// Continuity residuals: inlet edge state (ṁ=0.1, P=5e5, h=300e3),
|
||||||
|
// outlet edge state (ṁ=0.1, P=5e5, h=300e3) → r0 = 0, r1 = 0.
|
||||||
|
let mut probe = build_probe(ProbeMeasure::Sh);
|
||||||
|
wire_state_indices(&mut probe);
|
||||||
|
let state = vec![0.1, 5.0e5, 300_000.0, 0.1, 5.0e5, 300_000.0];
|
||||||
|
let mut residuals = vec![0.0; 2];
|
||||||
|
probe
|
||||||
|
.compute_residuals(&state, &mut residuals)
|
||||||
|
.expect("residuals compute");
|
||||||
|
assert!(residuals[0].abs() < 1e-9, "P continuity: {}", residuals[0]);
|
||||||
|
assert!(residuals[1].abs() < 1e-9, "h continuity: {}", residuals[1]);
|
||||||
|
|
||||||
|
// Mismatched inlet/outlet → non-zero continuity residuals.
|
||||||
|
let state2 = vec![0.1, 5.0e5, 300_000.0, 0.1, 4.0e5, 290_000.0];
|
||||||
|
probe
|
||||||
|
.compute_residuals(&state2, &mut residuals)
|
||||||
|
.expect("residuals compute");
|
||||||
|
assert!((residuals[0] - (-1.0e5)).abs() < 1e-3);
|
||||||
|
assert!((residuals[1] - (-10_000.0)).abs() < 1e-6);
|
||||||
|
|
||||||
|
// Jacobian: ∂r0/∂P_out=+1, ∂r0/∂P_in=−1, ∂r1/∂h_out=+1, ∂r1/∂h_in=−1.
|
||||||
|
let mut jac = JacobianBuilder::new();
|
||||||
|
probe
|
||||||
|
.jacobian_entries(&state, &mut jac)
|
||||||
|
.expect("jacobian computes");
|
||||||
|
let entries = jac.entries();
|
||||||
|
assert!(entries.contains(&(0, 4, 1.0)), "missing ∂r0/∂P_out");
|
||||||
|
assert!(entries.contains(&(0, 1, -1.0)), "missing ∂r0/∂P_in");
|
||||||
|
assert!(entries.contains(&(1, 5, 1.0)), "missing ∂r1/∂h_out");
|
||||||
|
assert!(entries.contains(&(1, 2, -1.0)), "missing ∂r1/∂h_in");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_port_names() {
|
||||||
|
let probe = build_probe(ProbeMeasure::Sh);
|
||||||
|
assert_eq!(probe.port_names(), vec!["inlet", "outlet"]);
|
||||||
|
assert_eq!(probe.flow_paths(), vec![(0, 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_measure_parse_round_trip() {
|
||||||
|
for kind in [
|
||||||
|
ProbeMeasure::Sst,
|
||||||
|
ProbeMeasure::Sdt,
|
||||||
|
ProbeMeasure::Dgt,
|
||||||
|
ProbeMeasure::Dsh,
|
||||||
|
ProbeMeasure::Sh,
|
||||||
|
ProbeMeasure::Sc,
|
||||||
|
ProbeMeasure::T,
|
||||||
|
ProbeMeasure::P,
|
||||||
|
ProbeMeasure::MassFlow,
|
||||||
|
ProbeMeasure::Capacity,
|
||||||
|
ProbeMeasure::Enthalpy,
|
||||||
|
] {
|
||||||
|
let parsed = ProbeMeasure::parse(kind.as_str()).expect("parses");
|
||||||
|
assert_eq!(parsed, kind, "round-trip for {:?}", kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_measure_parse_rejects_unknown() {
|
||||||
|
assert!(ProbeMeasure::parse("NOPE").is_err());
|
||||||
|
assert!(ProbeMeasure::parse("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_measure_parse_accepts_aliases() {
|
||||||
|
assert_eq!(
|
||||||
|
ProbeMeasure::parse("superheat").unwrap(),
|
||||||
|
ProbeMeasure::Sh
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProbeMeasure::parse("mass_flow_rate").unwrap(),
|
||||||
|
ProbeMeasure::MassFlow
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ProbeMeasure::parse("subcooling").unwrap(),
|
||||||
|
ProbeMeasure::Sc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_returns_none_without_state_indices() {
|
||||||
|
// No `set_system_context` call → indices are None → measure_output
|
||||||
|
// must return None gracefully instead of panicking.
|
||||||
|
let probe = build_probe(ProbeMeasure::Sh);
|
||||||
|
let state = vec![0.0; 6];
|
||||||
|
assert!(probe
|
||||||
|
.measure_output(MeasuredOutput::Superheat, &state)
|
||||||
|
.is_none());
|
||||||
|
assert!(probe
|
||||||
|
.measure_output(MeasuredOutput::Pressure, &state)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanity-check the Tsat derivation against TestBackend's R134a table at a
|
||||||
|
/// known pressure. R134a at 3 bar saturated → Tsat ≈ 273.4 K on TestBackend
|
||||||
|
/// (the table is approximate; we just assert the value is in a sane band).
|
||||||
|
#[test]
|
||||||
|
fn probe_saturation_temperature_in_sane_band() {
|
||||||
|
let mut probe = build_probe(ProbeMeasure::Sst);
|
||||||
|
wire_state_indices(&mut probe);
|
||||||
|
// 3 bar, h whatever — Tsat only uses P.
|
||||||
|
let state = vec![0.05, 3.0e5, 400_000.0, 0.05, 3.0e5, 400_000.0];
|
||||||
|
let tsat = probe
|
||||||
|
.measure_output(MeasuredOutput::SaturationTemperature, &state)
|
||||||
|
.expect("Tsat must resolve");
|
||||||
|
assert!(
|
||||||
|
(250.0..=320.0).contains(&tsat),
|
||||||
|
"Tsat out of sane band: {tsat}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_pressure_and_mass_flow_passthrough() {
|
||||||
|
let mut probe = build_probe(ProbeMeasure::P);
|
||||||
|
wire_state_indices(&mut probe);
|
||||||
|
let state = vec![0.42, 5.0e5, 250_000.0, 0.42, 5.0e5, 250_000.0];
|
||||||
|
let p = probe
|
||||||
|
.measure_output(MeasuredOutput::Pressure, &state)
|
||||||
|
.unwrap();
|
||||||
|
assert!((p - 5.0e5).abs() < 1e-6);
|
||||||
|
probe.measure = ProbeMeasure::MassFlow;
|
||||||
|
let m = probe
|
||||||
|
.measure_output(MeasuredOutput::MassFlowRate, &state)
|
||||||
|
.unwrap();
|
||||||
|
assert!((m - 0.42).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_enthalpy_kind_returns_h_edge() {
|
||||||
|
let mut probe = build_probe(ProbeMeasure::Enthalpy);
|
||||||
|
wire_state_indices(&mut probe);
|
||||||
|
let state = vec![0.1, 3.0e5, 412_300.0, 0.1, 3.0e5, 412_300.0];
|
||||||
|
// Enthalpy maps to HeatTransferRate in to_measured_output, but the
|
||||||
|
// measure_output override detects the configured Enthalpy kind and
|
||||||
|
// returns h_edge regardless.
|
||||||
|
let h = probe
|
||||||
|
.measure_output(MeasuredOutput::HeatTransferRate, &state)
|
||||||
|
.unwrap();
|
||||||
|
assert!((h - 412_300.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_capacity_is_mass_flow_times_delta_h() {
|
||||||
|
let mut probe = build_probe(ProbeMeasure::Capacity);
|
||||||
|
wire_state_indices(&mut probe);
|
||||||
|
// Inlet h = 420 kJ/kg, outlet h = 400 kJ/kg, ṁ = 0.5 kg/s → Q = 10 kW.
|
||||||
|
let state = vec![0.5, 3.0e5, 420_000.0, 0.5, 3.0e5, 400_000.0];
|
||||||
|
let q = probe
|
||||||
|
.measure_output(MeasuredOutput::Capacity, &state)
|
||||||
|
.unwrap();
|
||||||
|
assert!((q - 10_000.0).abs() < 1e-6, "expected 10 kW, got {q}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_superheat_subcooling_signs() {
|
||||||
|
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
|
||||||
|
let mk = || {
|
||||||
|
Port::new(
|
||||||
|
FluidId::new("R134a"),
|
||||||
|
Pressure::from_bar(3.0),
|
||||||
|
Enthalpy::from_joules_per_kg(400_000.0),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// Probe with SH measure: should be T_edge − Tsat(P_edge).
|
||||||
|
let mut sh_probe = Probe::new(ProbeMeasure::Sh, "R134a", mk(), mk())
|
||||||
|
.with_fluid_backend(Arc::clone(&backend))
|
||||||
|
.connect(mk(), mk())
|
||||||
|
.unwrap();
|
||||||
|
wire_state_indices(&mut sh_probe);
|
||||||
|
let tsat = sh_probe.tsat_k(3.0e5).expect("tsat resolves");
|
||||||
|
// Pick h that gives a known T via TestBackend. We can't know T without
|
||||||
|
// querying, so just assert SH and SC are negatives of each other for
|
||||||
|
// the same (P, h).
|
||||||
|
let state = vec![0.1, 3.0e5, 430_000.0, 0.1, 3.0e5, 430_000.0];
|
||||||
|
let sh = sh_probe
|
||||||
|
.measure_output(MeasuredOutput::Superheat, &state)
|
||||||
|
.expect("SH resolves");
|
||||||
|
|
||||||
|
let mut sc_probe = Probe::new(ProbeMeasure::Sc, "R134a", mk(), mk())
|
||||||
|
.with_fluid_backend(Arc::clone(&backend))
|
||||||
|
.connect(mk(), mk())
|
||||||
|
.unwrap();
|
||||||
|
wire_state_indices(&mut sc_probe);
|
||||||
|
let sc = sc_probe
|
||||||
|
.measure_output(MeasuredOutput::Subcooling, &state)
|
||||||
|
.expect("SC resolves");
|
||||||
|
assert!(
|
||||||
|
(sh + sc).abs() < 1e-6,
|
||||||
|
"SH and SC must be opposite signs for same (P,h): SH={sh}, SC={sc}, tsat={tsat}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_to_measured_output_mapping_is_exhaustive() {
|
||||||
|
// Every variant maps to a non-panic MeasuredOutput.
|
||||||
|
for kind in [
|
||||||
|
ProbeMeasure::Sst,
|
||||||
|
ProbeMeasure::Sdt,
|
||||||
|
ProbeMeasure::Dgt,
|
||||||
|
ProbeMeasure::Dsh,
|
||||||
|
ProbeMeasure::Sh,
|
||||||
|
ProbeMeasure::Sc,
|
||||||
|
ProbeMeasure::T,
|
||||||
|
ProbeMeasure::P,
|
||||||
|
ProbeMeasure::MassFlow,
|
||||||
|
ProbeMeasure::Capacity,
|
||||||
|
ProbeMeasure::Enthalpy,
|
||||||
|
] {
|
||||||
|
let _ = kind.to_measured_output();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_signature_and_params_include_measure_and_fluid() {
|
||||||
|
let probe = build_probe(ProbeMeasure::Sst);
|
||||||
|
let sig = probe.signature();
|
||||||
|
assert!(sig.contains("SST"), "signature: {sig}");
|
||||||
|
assert!(sig.contains("R134a"));
|
||||||
|
let params = probe.to_params();
|
||||||
|
assert_eq!(params.component_type, "Probe");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,10 +115,11 @@ pub use entropyk_components::{
|
|||||||
IncompressibleSplitter, IsenthalpicExpansionValve, IsentropicCompressor, JacobianBuilder,
|
IncompressibleSplitter, IsenthalpicExpansionValve, IsentropicCompressor, JacobianBuilder,
|
||||||
LmtdModel, MchxCondenserCoil, MockExternalModel, Node, NodeMeasurements, NodePhase,
|
LmtdModel, MchxCondenserCoil, MockExternalModel, Node, NodeMeasurements, NodePhase,
|
||||||
OperationalState, PerformanceCurves, PhaseRegion, Pipe, PipeGeometry, Polynomial1D,
|
OperationalState, PerformanceCurves, PhaseRegion, Pipe, PipeGeometry, Polynomial1D,
|
||||||
Polynomial2D, Pump, PumpCurves, RefrigerantSink, RefrigerantSource, RegistryError,
|
Polynomial2D, Probe, ProbeMeasure, Pump, PumpCurves, RefrigerantSink, RefrigerantSource,
|
||||||
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, ShellAndTubeHx,
|
RegistryError, ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves,
|
||||||
SstSdtCoefficients, StateHistory, StateManageable, StateTransitionError, SystemState,
|
ShellAndTubeHx, SstSdtCoefficients, StateHistory, StateManageable, StateTransitionError,
|
||||||
ThermalLoad, ThreadSafeExternalModel, UaMode, ValveCharacteristics, ValveFlowModel,
|
SystemState, ThermalLoad, ThreadSafeExternalModel, UaMode, ValveCharacteristics,
|
||||||
|
ValveFlowModel,
|
||||||
};
|
};
|
||||||
pub use entropyk_components::{ReversingMode, ReversingValve};
|
pub use entropyk_components::{ReversingMode, ReversingValve};
|
||||||
|
|
||||||
@@ -147,11 +148,11 @@ pub use entropyk_solver::{
|
|||||||
AddEdgeError, AntoineCoefficients, CircuitConvergence, CircuitId as SolverCircuitId,
|
AddEdgeError, AntoineCoefficients, CircuitConvergence, CircuitId as SolverCircuitId,
|
||||||
ComponentOutput, Constraint, ConstraintError, ConstraintId, ConvergedState,
|
ComponentOutput, Constraint, ConstraintError, ConstraintId, ConvergedState,
|
||||||
ConvergenceCriteria, ConvergenceReason, ConvergenceReport, ConvergenceStatus, CyclePerformance,
|
ConvergenceCriteria, ConvergenceReason, ConvergenceReport, ConvergenceStatus, CyclePerformance,
|
||||||
DomainViolation, FallbackConfig, FallbackSolver, FlowEdge, HomotopyConfig, InitializerConfig,
|
DomainViolation, FaerLuSolver, FallbackConfig, FallbackSolver, FlowEdge, HomotopyConfig,
|
||||||
InitializerError, JacobianFreezingConfig, JacobianMatrix, LinearSolver, MacroComponent,
|
InitializerConfig, InitializerError, JacobianFreezingConfig, JacobianMatrix, LinearSolver,
|
||||||
MacroComponentSnapshot, NalgebraLuSolver, NewtonConfig, PicardConfig, PortMapping,
|
MacroComponent, MacroComponentSnapshot, NalgebraLuSolver, NewtonConfig, PicardConfig,
|
||||||
SmartInitializer, SolveOutcome, Solver, SolverError, SolverStrategy, System, ThermalCoupling,
|
PortMapping, SmartInitializer, SolveOutcome, Solver, SolverError, SolverStrategy, System,
|
||||||
TimeoutConfig, TopologyError,
|
ThermalCoupling, TimeoutConfig, TopologyError,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -526,6 +526,42 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calibration factors (z_ua, z_dp, z_flow, z_flow_eco, z_power, z_etav)
|
||||||
|
// promoted to free unknowns via control loops. Each `Some(idx)` slot means
|
||||||
|
// the solver computed a value for that factor at `state[idx]`. The generic
|
||||||
|
// `actuator` slot (physical opening / fan speed) is already covered by the
|
||||||
|
// bounded-variable path above, so we only surface the six named Z-factors.
|
||||||
|
for (name, indices) in system.calib_indices_by_name() {
|
||||||
|
for (factor, slot) in [
|
||||||
|
("z_flow", indices.z_flow),
|
||||||
|
("z_flow_eco", indices.z_flow_eco),
|
||||||
|
("z_dp", indices.z_dp),
|
||||||
|
("z_ua", indices.z_ua),
|
||||||
|
("z_power", indices.z_power),
|
||||||
|
("z_etav", indices.z_etav),
|
||||||
|
] {
|
||||||
|
if let Some(idx) = slot {
|
||||||
|
if idx < state.len() {
|
||||||
|
let id = format!("{}__{}", name, factor);
|
||||||
|
// Skip if already emitted by the bounded-variable path.
|
||||||
|
if out.iter().any(|v| v.id == id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(SolvedVariable {
|
||||||
|
id,
|
||||||
|
component: Some(name.clone()),
|
||||||
|
variable: factor.to_string(),
|
||||||
|
value: state[idx],
|
||||||
|
// Calibration factors are bounded to [0.5, 2.0]
|
||||||
|
// (see entropyk_core::CalibValidationError).
|
||||||
|
min: 0.5,
|
||||||
|
max: 2.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if skipped > 0 {
|
if skipped > 0 {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
skipped,
|
skipped,
|
||||||
@@ -547,9 +583,7 @@ pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVar
|
|||||||
/// user-facing `"opening"` label.
|
/// user-facing `"opening"` label.
|
||||||
fn derive_solved_variable_label(id: &str, component_id: Option<&str>) -> String {
|
fn derive_solved_variable_label(id: &str, component_id: Option<&str>) -> String {
|
||||||
let stripped = match component_id {
|
let stripped = match component_id {
|
||||||
Some(c) => id
|
Some(c) => id.strip_prefix(&format!("{}__", c)).unwrap_or(id),
|
||||||
.strip_prefix(&format!("{}__", c))
|
|
||||||
.unwrap_or(id),
|
|
||||||
None => id.rfind("__").map(|i| &id[i + 2..]).unwrap_or(id),
|
None => id.rfind("__").map(|i| &id[i + 2..]).unwrap_or(id),
|
||||||
};
|
};
|
||||||
match stripped {
|
match stripped {
|
||||||
@@ -781,6 +815,9 @@ mod tests {
|
|||||||
// Component unknown → fall back to last "__"-separated segment.
|
// Component unknown → fall back to last "__"-separated segment.
|
||||||
assert_eq!(derive_solved_variable_label("glob__z_dp", None), "z_dp");
|
assert_eq!(derive_solved_variable_label("glob__z_dp", None), "z_dp");
|
||||||
// No separator and no component → id itself.
|
// No separator and no component → id itself.
|
||||||
assert_eq!(derive_solved_variable_label("global_var", None), "global_var");
|
assert_eq!(
|
||||||
|
derive_solved_variable_label("global_var", None),
|
||||||
|
"global_var"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ repository = "https://github.com/entropyk/entropyk"
|
|||||||
entropyk-components = { path = "../components" }
|
entropyk-components = { path = "../components" }
|
||||||
entropyk-core = { path = "../core" }
|
entropyk-core = { path = "../core" }
|
||||||
entropyk-solver-core = { path = "../solver-core" }
|
entropyk-solver-core = { path = "../solver-core" }
|
||||||
|
faer = "0.24"
|
||||||
nalgebra = "0.33"
|
nalgebra = "0.33"
|
||||||
petgraph = "0.6"
|
petgraph = "0.6"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
@@ -29,6 +30,10 @@ criterion = "0.5"
|
|||||||
name = "lu_solve"
|
name = "lu_solve"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "lu_backends"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "residual_jacobian_assembly"
|
name = "residual_jacobian_assembly"
|
||||||
harness = false
|
harness = false
|
||||||
|
|||||||
85
crates/solver/benches/lu_backends.rs
Normal file
85
crates/solver/benches/lu_backends.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
//! Story 1.5 comparative benchmark: nalgebra vs faer dense LU backends.
|
||||||
|
//!
|
||||||
|
//! Measures factorization (`set_linearisation`) and per-RHS solve
|
||||||
|
//! (`solve_in_place`) on the mock-cycle Jacobian (9×9) and a synthetic 50×50
|
||||||
|
//! dense Jacobian — the sizes Entropyk Newton actually sees.
|
||||||
|
|
||||||
|
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||||
|
use entropyk_solver::linear::NalgebraLuSolver;
|
||||||
|
use entropyk_solver::linear_faer::FaerLuSolver;
|
||||||
|
use entropyk_solver::LinearSolver;
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
|
||||||
|
let mut entries = Vec::with_capacity(n * n);
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
let v = if i == j {
|
||||||
|
4.0 + 0.01 * (i as f64)
|
||||||
|
} else {
|
||||||
|
1.0 / (1.0 + (i as f64 - j as f64).abs())
|
||||||
|
};
|
||||||
|
entries.push((i, j, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_pair(c: &mut Criterion, label: &str, entries: &[(usize, usize, f64)], n: usize) {
|
||||||
|
let mut group = c.benchmark_group(label);
|
||||||
|
let rhs: Vec<f64> = (0..n)
|
||||||
|
.map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut nal = NalgebraLuSolver::new();
|
||||||
|
nal.set_problem(n, n).unwrap();
|
||||||
|
let mut faer = FaerLuSolver::new();
|
||||||
|
faer.set_problem(n, n).unwrap();
|
||||||
|
|
||||||
|
group.bench_function(BenchmarkId::new("factor", "nalgebra"), |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
black_box(nal.set_linearisation(black_box(entries))).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
group.bench_function(BenchmarkId::new("factor", "faer"), |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
black_box(faer.set_linearisation(black_box(entries))).unwrap();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nal.set_linearisation(entries).unwrap();
|
||||||
|
faer.set_linearisation(entries).unwrap();
|
||||||
|
group.bench_function(BenchmarkId::new("solve", "nalgebra"), |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut rhs = rhs.clone();
|
||||||
|
let _ = black_box(nal.solve_in_place(black_box(&mut rhs)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
group.bench_function(BenchmarkId::new("solve", "faer"), |b| {
|
||||||
|
b.iter(|| {
|
||||||
|
let mut rhs = rhs.clone();
|
||||||
|
let _ = black_box(faer.solve_in_place(black_box(&mut rhs)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
group.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_lu_backends(c: &mut Criterion) {
|
||||||
|
let (system, state) = common::build_mock_system();
|
||||||
|
let jacobian = common::assemble_jacobian(&system, &state);
|
||||||
|
let n = jacobian.nrows();
|
||||||
|
let jm = jacobian.as_matrix();
|
||||||
|
let entries: Vec<(usize, usize, f64)> = (0..n)
|
||||||
|
.flat_map(|i| (0..n).map(move |j| (i, j, jm[(i, j)])))
|
||||||
|
.collect();
|
||||||
|
bench_pair(c, "lu_mock_9x9", &entries, n);
|
||||||
|
|
||||||
|
let entries50 = dense_entries(50);
|
||||||
|
bench_pair(c, "lu_dense_50x50", &entries50, 50);
|
||||||
|
|
||||||
|
let _ = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(benches, bench_lu_backends);
|
||||||
|
criterion_main!(benches);
|
||||||
@@ -34,6 +34,7 @@ pub mod initializer;
|
|||||||
pub mod inverse;
|
pub mod inverse;
|
||||||
pub mod jacobian;
|
pub mod jacobian;
|
||||||
pub mod linear;
|
pub mod linear;
|
||||||
|
pub mod linear_faer;
|
||||||
pub mod macro_component;
|
pub mod macro_component;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod scaling;
|
pub mod scaling;
|
||||||
@@ -64,7 +65,8 @@ pub use initializer::{
|
|||||||
};
|
};
|
||||||
pub use inverse::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
|
pub use inverse::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
|
||||||
pub use jacobian::JacobianMatrix;
|
pub use jacobian::JacobianMatrix;
|
||||||
pub use linear::NalgebraLuSolver;
|
pub use linear::{make_linear_backend, set_linear_backend_override, DenseLu, NalgebraLuSolver};
|
||||||
|
pub use linear_faer::FaerLuSolver;
|
||||||
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
|
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
|
||||||
pub use metadata::SimulationMetadata;
|
pub use metadata::SimulationMetadata;
|
||||||
pub use scaling::{equilibrate, unscale_dx};
|
pub use scaling::{equilibrate, unscale_dx};
|
||||||
|
|||||||
@@ -356,3 +356,139 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Strangler dispatch (Story 1.5) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Process-wide override for the default dense backend (set by the CLI from
|
||||||
|
/// `solver.linear_backend`). Precedence: explicit `make_linear_backend` name
|
||||||
|
/// → this override → `ENTROPYK_LINEAR_BACKEND` env → `"nalgebra"`.
|
||||||
|
static BACKEND_OVERRIDE: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
|
||||||
|
|
||||||
|
/// Sets the process-wide default dense backend (`Some("faer")` / `Some("nalgebra")`,
|
||||||
|
/// `None` to clear). Additive, additive-only configuration surface.
|
||||||
|
pub fn set_linear_backend_override(name: Option<&str>) {
|
||||||
|
if let Ok(mut guard) = BACKEND_OVERRIDE.write() {
|
||||||
|
*guard = name.map(str::to_string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dense LU backend in use: nalgebra (default, legacy path) or faer
|
||||||
|
/// (opt-in). Strategies hold this enum directly so the `set_matrix` fast path
|
||||||
|
/// stays available; it also implements [`LinearSolver`] for registry boxing.
|
||||||
|
pub enum DenseLu {
|
||||||
|
/// nalgebra dense LU (default).
|
||||||
|
Nalgebra(NalgebraLuSolver),
|
||||||
|
/// faer dense LU (Story 1.5 opt-in).
|
||||||
|
Faer(crate::linear_faer::FaerLuSolver),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DenseLu {
|
||||||
|
/// Inherent fast path shared by both backends (see each backend's docs).
|
||||||
|
pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
|
||||||
|
match self {
|
||||||
|
Self::Nalgebra(b) => b.set_matrix(matrix),
|
||||||
|
Self::Faer(b) => b.set_matrix(matrix),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinearSolver for DenseLu {
|
||||||
|
fn set_problem(
|
||||||
|
&mut self,
|
||||||
|
n_equations: usize,
|
||||||
|
n_unknowns: usize,
|
||||||
|
) -> Result<(), SolverCoreError> {
|
||||||
|
match self {
|
||||||
|
Self::Nalgebra(b) => b.set_problem(n_equations, n_unknowns),
|
||||||
|
Self::Faer(b) => b.set_problem(n_equations, n_unknowns),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_linearisation(
|
||||||
|
&mut self,
|
||||||
|
entries: &[(usize, usize, f64)],
|
||||||
|
) -> Result<(), SolverCoreError> {
|
||||||
|
match self {
|
||||||
|
Self::Nalgebra(b) => b.set_linearisation(entries),
|
||||||
|
Self::Faer(b) => b.set_linearisation(entries),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
|
||||||
|
match self {
|
||||||
|
Self::Nalgebra(b) => b.solve_in_place(rhs),
|
||||||
|
Self::Faer(b) => b.solve_in_place(rhs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn n(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Nalgebra(b) => b.n(),
|
||||||
|
Self::Faer(b) => b.n(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the dense backend selected by name (or by override/env/default).
|
||||||
|
pub fn make_linear_backend(name: Option<&str>) -> Result<DenseLu, SolverCoreError> {
|
||||||
|
let selected = name
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| BACKEND_OVERRIDE.read().ok().and_then(|g| g.clone()))
|
||||||
|
.or_else(|| std::env::var("ENTROPYK_LINEAR_BACKEND").ok())
|
||||||
|
.unwrap_or_else(|| "nalgebra".to_string());
|
||||||
|
match selected.as_str() {
|
||||||
|
"nalgebra" => Ok(DenseLu::Nalgebra(NalgebraLuSolver::new())),
|
||||||
|
"faer" => Ok(DenseLu::Faer(crate::linear_faer::FaerLuSolver::new())),
|
||||||
|
other => Err(SolverCoreError::Usage {
|
||||||
|
message: format!("unknown linear backend '{other}' (use 'nalgebra' or 'faer')"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dispatch_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_builds_named_backends_and_rejects_unknown() {
|
||||||
|
assert!(matches!(
|
||||||
|
make_linear_backend(Some("nalgebra")).unwrap(),
|
||||||
|
DenseLu::Nalgebra(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
make_linear_backend(Some("faer")).unwrap(),
|
||||||
|
DenseLu::Faer(_)
|
||||||
|
));
|
||||||
|
assert!(make_linear_backend(Some("mumps")).is_err());
|
||||||
|
// Env-independent check: an explicit override always wins.
|
||||||
|
set_linear_backend_override(Some("nalgebra"));
|
||||||
|
assert!(matches!(
|
||||||
|
make_linear_backend(None).unwrap(),
|
||||||
|
DenseLu::Nalgebra(_)
|
||||||
|
));
|
||||||
|
set_linear_backend_override(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn override_selects_default_and_explicit_name_wins() {
|
||||||
|
set_linear_backend_override(Some("faer"));
|
||||||
|
assert!(matches!(
|
||||||
|
make_linear_backend(None).unwrap(),
|
||||||
|
DenseLu::Faer(_)
|
||||||
|
));
|
||||||
|
// Explicit name wins over the override.
|
||||||
|
assert!(matches!(
|
||||||
|
make_linear_backend(Some("nalgebra")).unwrap(),
|
||||||
|
DenseLu::Nalgebra(_)
|
||||||
|
));
|
||||||
|
set_linear_backend_override(None);
|
||||||
|
// After clearing, selection falls back to env-or-nalgebra: just check
|
||||||
|
// it builds a working backend.
|
||||||
|
let mut b = make_linear_backend(None).unwrap();
|
||||||
|
b.set_problem(1, 1).unwrap();
|
||||||
|
b.set_linearisation(&[(0, 0, 2.0)]).unwrap();
|
||||||
|
let mut rhs = [4.0];
|
||||||
|
b.solve_in_place(&mut rhs).unwrap();
|
||||||
|
assert!((rhs[0] - 2.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
310
crates/solver/src/linear_faer.rs
Normal file
310
crates/solver/src/linear_faer.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
//! `FaerLuSolver`: dense faer LU backend behind the [`LinearSolver`] lifecycle
|
||||||
|
//! (FR3, Story 1.5 — strangler migration; nalgebra stays the default).
|
||||||
|
//!
|
||||||
|
//! Same contract and pipeline as [`crate::linear::NalgebraLuSolver`] (post-1.4
|
||||||
|
//! audit): identical triplet assembly (zero-fill + `+=`), identical Ruiz
|
||||||
|
//! equilibration (`crate::scaling::equilibrate`), factorization computed once
|
||||||
|
//! in `set_linearisation`, bounds validated BEFORE mutation with the factor
|
||||||
|
//! invalidated on out-of-bounds, zero-panic error paths, zero heap allocation
|
||||||
|
//! in `solve_in_place` (faer solves in place on the stored factorization,
|
||||||
|
//! over a column-major view of the pre-allocated scratch). The only
|
||||||
|
//! intentional difference is the factorization engine: faer's partial-pivot
|
||||||
|
//! LU instead of nalgebra's LU — results match within tolerance, not bitwise.
|
||||||
|
|
||||||
|
use entropyk_solver_core::{LinearSolver, SolverCoreError};
|
||||||
|
use faer::linalg::solvers::{PartialPivLu, SolveCore};
|
||||||
|
use nalgebra::DMatrix;
|
||||||
|
|
||||||
|
/// Stored faer factorization plus the equilibration scalings used to build it.
|
||||||
|
struct Factorized {
|
||||||
|
d_r: Vec<f64>,
|
||||||
|
d_c: Vec<f64>,
|
||||||
|
lu: PartialPivLu<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dense LU backend (faer) behind the object-safe [`LinearSolver`] trait.
|
||||||
|
///
|
||||||
|
/// Scratch buffers are allocated once in `set_problem` so `solve_in_place`
|
||||||
|
/// performs zero heap allocation.
|
||||||
|
pub struct FaerLuSolver {
|
||||||
|
n_rows: usize,
|
||||||
|
n_cols: usize,
|
||||||
|
/// Assembled (unscaled) matrix (nalgebra storage, shared with the
|
||||||
|
/// equilibration pipeline).
|
||||||
|
matrix: Option<DMatrix<f64>>,
|
||||||
|
factor: Option<Factorized>,
|
||||||
|
/// Scratch: scaled rhs / unscaled step (allocated in `set_problem`).
|
||||||
|
delta: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FaerLuSolver {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FaerLuSolver {
|
||||||
|
/// Creates an unconfigured backend (call [`LinearSolver::set_problem`]).
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
n_rows: 0,
|
||||||
|
n_cols: 0,
|
||||||
|
matrix: None,
|
||||||
|
factor: None,
|
||||||
|
delta: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inherent fast path: copy an already-assembled dense matrix and factor
|
||||||
|
/// it (one `copy_from`, no triplet conversion).
|
||||||
|
pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
|
||||||
|
let stored = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
|
||||||
|
message: "set_problem must be called before set_matrix".to_string(),
|
||||||
|
})?;
|
||||||
|
if stored.nrows() != matrix.nrows() || stored.ncols() != matrix.ncols() {
|
||||||
|
return Err(SolverCoreError::Usage {
|
||||||
|
message: format!(
|
||||||
|
"matrix shape {}x{} does not match problem {}x{}",
|
||||||
|
matrix.nrows(),
|
||||||
|
matrix.ncols(),
|
||||||
|
stored.nrows(),
|
||||||
|
stored.ncols()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
stored.copy_from(matrix);
|
||||||
|
self.factorize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equilibrate + scale + factorize the stored matrix (square path).
|
||||||
|
fn factorize(&mut self) -> Result<(), SolverCoreError> {
|
||||||
|
let Some(matrix) = self.matrix.as_ref() else {
|
||||||
|
self.factor = None;
|
||||||
|
return Err(SolverCoreError::Usage {
|
||||||
|
message: "set_problem must be called before factorizing".to_string(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if matrix.nrows() != matrix.ncols() {
|
||||||
|
// Non-square problems have no LU path; solve_in_place reports Usage.
|
||||||
|
self.factor = None;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let n = matrix.nrows();
|
||||||
|
let (d_r, d_c) = crate::scaling::equilibrate(matrix);
|
||||||
|
// Scaled matrix as a faer Mat (column-major, one copy at factor time).
|
||||||
|
let scaled = faer::Mat::from_fn(n, n, |i, j| matrix[(i, j)] * d_r[i] * d_c[j]);
|
||||||
|
self.factor = Some(Factorized {
|
||||||
|
d_r,
|
||||||
|
d_c,
|
||||||
|
lu: PartialPivLu::new(scaled.as_ref()),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinearSolver for FaerLuSolver {
|
||||||
|
fn set_problem(
|
||||||
|
&mut self,
|
||||||
|
n_equations: usize,
|
||||||
|
n_unknowns: usize,
|
||||||
|
) -> Result<(), SolverCoreError> {
|
||||||
|
if n_equations == 0 || n_unknowns == 0 {
|
||||||
|
return Err(SolverCoreError::Usage {
|
||||||
|
message: format!("zero-sized problem {n_equations}x{n_unknowns}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.n_rows = n_equations;
|
||||||
|
self.n_cols = n_unknowns;
|
||||||
|
self.matrix = Some(DMatrix::zeros(n_equations, n_unknowns));
|
||||||
|
self.factor = None;
|
||||||
|
// Scratch buffer for the zero-allocation solve path.
|
||||||
|
self.delta = vec![0.0; n_unknowns];
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_linearisation(
|
||||||
|
&mut self,
|
||||||
|
entries: &[(usize, usize, f64)],
|
||||||
|
) -> Result<(), SolverCoreError> {
|
||||||
|
let (n_rows, n_cols) = (self.n_rows, self.n_cols);
|
||||||
|
if self.matrix.is_none() {
|
||||||
|
return Err(SolverCoreError::Usage {
|
||||||
|
message: "set_problem must be called before set_linearisation".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Validate bounds BEFORE touching the matrix (1.4 audit contract).
|
||||||
|
for &(row, col, _) in entries {
|
||||||
|
if row >= n_rows || col >= n_cols {
|
||||||
|
self.factor = None;
|
||||||
|
return Err(SolverCoreError::Usage {
|
||||||
|
message: format!("entry ({row},{col}) out of bounds {n_rows}x{n_cols}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let matrix = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
|
||||||
|
message: "set_problem must be called before set_linearisation".to_string(),
|
||||||
|
})?;
|
||||||
|
matrix.fill(0.0);
|
||||||
|
for &(row, col, value) in entries {
|
||||||
|
matrix[(row, col)] += value;
|
||||||
|
}
|
||||||
|
self.factorize()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
|
||||||
|
if rhs.len() != self.n_rows || self.n_rows != self.n_cols {
|
||||||
|
return Err(SolverCoreError::Usage {
|
||||||
|
message: format!(
|
||||||
|
"solve_in_place needs a square rhs of len {}, got {} (problem {}x{})",
|
||||||
|
self.n_rows,
|
||||||
|
rhs.len(),
|
||||||
|
self.n_rows,
|
||||||
|
self.n_cols
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let n = self.n_rows;
|
||||||
|
// Disjoint field borrows: factorization + scratch (zero alloc).
|
||||||
|
let Self { factor, delta, .. } = self;
|
||||||
|
let factor = factor.as_ref().ok_or_else(|| SolverCoreError::Usage {
|
||||||
|
message: "set_linearisation must be called before solve_in_place".to_string(),
|
||||||
|
})?;
|
||||||
|
// Scaled right-hand side: D_r · b (into the scratch).
|
||||||
|
for i in 0..n {
|
||||||
|
delta[i] = rhs[i] * factor.d_r[i];
|
||||||
|
}
|
||||||
|
// faer solves in place on a column-major view of the scratch.
|
||||||
|
let rhs_view = faer::MatMut::from_column_major_slice_mut(&mut delta[..n], n, 1);
|
||||||
|
factor.lu.solve_in_place_with_conj(faer::Conj::No, rhs_view);
|
||||||
|
// Undo the column scaling in place: x_i = D_c,i · y_i.
|
||||||
|
for i in 0..n {
|
||||||
|
delta[i] *= factor.d_c[i];
|
||||||
|
}
|
||||||
|
if delta[..n].iter().all(|v| v.is_finite()) {
|
||||||
|
rhs.copy_from_slice(&delta[..n]);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"faer LU solve produced a non-finite step - Jacobian may contain NaN/Inf"
|
||||||
|
);
|
||||||
|
Err(SolverCoreError::InvalidSystem {
|
||||||
|
message: "linear solve produced a non-finite step".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn n(&self) -> usize {
|
||||||
|
self.n_rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::jacobian::JacobianMatrix;
|
||||||
|
|
||||||
|
/// Deterministic dense system (well-conditioned, diagonally dominant).
|
||||||
|
fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
let v = if i == j {
|
||||||
|
4.0 + 0.1 * (i as f64)
|
||||||
|
} else {
|
||||||
|
1.0 / (1.0 + (i as f64 - j as f64).abs())
|
||||||
|
};
|
||||||
|
entries.push((i, j, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity_with_jacobian_matrix_solve() {
|
||||||
|
for n in [1, 2, 5, 12] {
|
||||||
|
let entries = dense_entries(n);
|
||||||
|
let jm = JacobianMatrix::from_builder(&entries, n, n);
|
||||||
|
let residuals: Vec<f64> = (0..n)
|
||||||
|
.map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let legacy = jm.solve(&residuals).expect("legacy solve");
|
||||||
|
|
||||||
|
let mut backend = FaerLuSolver::new();
|
||||||
|
backend.set_problem(n, n).unwrap();
|
||||||
|
backend.set_linearisation(&entries).unwrap();
|
||||||
|
let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
|
||||||
|
backend.solve_in_place(&mut rhs).unwrap();
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
// faer ≠ nalgebra bitwise: tolerance, not exact equality.
|
||||||
|
assert!(
|
||||||
|
(legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
|
||||||
|
"n={n} i={i}: legacy={} faer={}",
|
||||||
|
legacy[i],
|
||||||
|
rhs[i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factorization_reuse_across_rhs() {
|
||||||
|
let n = 6;
|
||||||
|
let entries = dense_entries(n);
|
||||||
|
let jm = JacobianMatrix::from_builder(&entries, n, n);
|
||||||
|
let mut backend = FaerLuSolver::new();
|
||||||
|
backend.set_problem(n, n).unwrap();
|
||||||
|
backend.set_matrix(jm.as_matrix()).unwrap();
|
||||||
|
|
||||||
|
for k in 0..2 {
|
||||||
|
let residuals: Vec<f64> = (0..n).map(|i| (i + k) as f64 * 3.1 - 5.0).collect();
|
||||||
|
let legacy = jm.solve(&residuals).expect("legacy solve");
|
||||||
|
let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
|
||||||
|
backend.solve_in_place(&mut rhs).unwrap();
|
||||||
|
for i in 0..n {
|
||||||
|
assert!(
|
||||||
|
(legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
|
||||||
|
"reuse k={k} i={i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_paths_and_stale_factor_invalidation() {
|
||||||
|
let mut backend = FaerLuSolver::new();
|
||||||
|
assert!(backend.set_problem(0, 2).is_err());
|
||||||
|
backend.set_problem(2, 2).unwrap();
|
||||||
|
// Solve before set_linearisation → Usage.
|
||||||
|
assert!(matches!(
|
||||||
|
backend.solve_in_place(&mut [1.0, 2.0]),
|
||||||
|
Err(SolverCoreError::Usage { .. })
|
||||||
|
));
|
||||||
|
// Factor a good matrix, then an OOB entry: the stale factorization
|
||||||
|
// must be invalidated (1.4 audit regression).
|
||||||
|
backend
|
||||||
|
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
|
||||||
|
.unwrap();
|
||||||
|
assert!(backend.set_linearisation(&[(2, 0, 1.0)]).is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
backend.solve_in_place(&mut [1.0, 2.0]),
|
||||||
|
Err(SolverCoreError::Usage { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(backend.n(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_finite_matrix_entries_are_rejected() {
|
||||||
|
let mut backend = FaerLuSolver::new();
|
||||||
|
backend.set_problem(2, 2).unwrap();
|
||||||
|
backend
|
||||||
|
.set_linearisation(&[(0, 0, f64::NAN), (1, 1, 1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let mut rhs = vec![1.0, 2.0];
|
||||||
|
assert!(matches!(
|
||||||
|
backend.solve_in_place(&mut rhs),
|
||||||
|
Err(SolverCoreError::InvalidSystem { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,10 +345,13 @@ impl Solver for NewtonConfig {
|
|||||||
let mut frozen_count: usize = 0;
|
let mut frozen_count: usize = 0;
|
||||||
let mut force_recompute: bool = true;
|
let mut force_recompute: bool = true;
|
||||||
|
|
||||||
// LinearSolver backend (FR3): factorizes at each fresh assembly and
|
// LinearSolver backend (FR3/Story 1.5): nalgebra by default, faer via
|
||||||
// reuses the factorization (with cached scalings) across frozen
|
// config/env dispatch. Factorizes at each fresh assembly and reuses
|
||||||
// iterations and repeated solves — bit-identical to JacobianMatrix::solve.
|
// the factorization (with cached scalings) across frozen iterations.
|
||||||
let mut linear_backend = crate::linear::NalgebraLuSolver::new();
|
let mut linear_backend =
|
||||||
|
crate::linear::make_linear_backend(None).map_err(|e| SolverError::InvalidSystem {
|
||||||
|
message: format!("Failed to select linear backend: {e}"),
|
||||||
|
})?;
|
||||||
linear_backend
|
linear_backend
|
||||||
.set_problem(n_equations, n_state)
|
.set_problem(n_equations, n_state)
|
||||||
.map_err(|e| SolverError::InvalidSystem {
|
.map_err(|e| SolverError::InvalidSystem {
|
||||||
|
|||||||
@@ -122,9 +122,12 @@ impl Solver for PtcConfig {
|
|||||||
let mut jacobian_builder = JacobianBuilder::new();
|
let mut jacobian_builder = JacobianBuilder::new();
|
||||||
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
|
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
|
||||||
|
|
||||||
// LinearSolver backend (FR3): factorizes at each fresh assembly;
|
// LinearSolver backend (FR3/Story 1.5): nalgebra by default, faer via
|
||||||
// bit-identical to JacobianMatrix::solve.
|
// config/env dispatch; factorizes at each fresh assembly.
|
||||||
let mut linear_backend = crate::linear::NalgebraLuSolver::new();
|
let mut linear_backend =
|
||||||
|
crate::linear::make_linear_backend(None).map_err(|e| SolverError::InvalidSystem {
|
||||||
|
message: format!("Failed to select linear backend: {e}"),
|
||||||
|
})?;
|
||||||
linear_backend
|
linear_backend
|
||||||
.set_problem(n_equations, n_state)
|
.set_problem(n_equations, n_state)
|
||||||
.map_err(|e| SolverError::InvalidSystem {
|
.map_err(|e| SolverError::InvalidSystem {
|
||||||
|
|||||||
@@ -335,6 +335,26 @@ impl Solver for PicardConfig {
|
|||||||
let mut divergence_count: usize = 0;
|
let mut divergence_count: usize = 0;
|
||||||
let mut previous_norm: f64;
|
let mut previous_norm: f64;
|
||||||
|
|
||||||
|
// Solver bounds mask (pressure floor + bounded control variables):
|
||||||
|
// Picard's relaxed update has no line search, so without clipping it
|
||||||
|
// can drive a calibration z-factor far outside its bounds (observed:
|
||||||
|
// z_ua → −2.8 on an SDT calibration), then crash the components.
|
||||||
|
// Saturated-controller actuator slots are EXCLUDED: they carry their
|
||||||
|
// own saturation semantics (anti-windup integrator + S(x) inside the
|
||||||
|
// controller residual) and hard-clamping them here breaks that dynamic.
|
||||||
|
let mut clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
|
||||||
|
.map(|i| system.get_solver_bounds_for_state_index(i))
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let n_sat = system.saturated_controllers_mut().count();
|
||||||
|
for i in 0..n_sat {
|
||||||
|
let u_idx = system.saturated_u_index(i);
|
||||||
|
if u_idx < clipping_mask.len() {
|
||||||
|
clipping_mask[u_idx] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
|
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
|
||||||
let mut best_state: Vec<f64> = vec![0.0; n_state];
|
let mut best_state: Vec<f64> = vec![0.0; n_state];
|
||||||
let mut best_residual: f64;
|
let mut best_residual: f64;
|
||||||
@@ -425,6 +445,16 @@ impl Solver for PicardConfig {
|
|||||||
} else {
|
} else {
|
||||||
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
||||||
}
|
}
|
||||||
|
// Clamp the relaxed/extrapolated iterate into solver bounds (see
|
||||||
|
// the mask comment above): bounded control variables (calibration
|
||||||
|
// z-factors, openings) and pressure floors.
|
||||||
|
for (i, s) in state.iter_mut().enumerate() {
|
||||||
|
if let Some((min, max)) = &clipping_mask[i] {
|
||||||
|
if min.is_finite() && max.is_finite() && min <= max {
|
||||||
|
*s = s.clamp(*min, *max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute new residuals. Recoverable domain violation → typed
|
// Compute new residuals. Recoverable domain violation → typed
|
||||||
// outcome; fatal → InvalidSystem flattening.
|
// outcome; fatal → InvalidSystem flattening.
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ pub struct System {
|
|||||||
/// Registry of component names for constraint validation.
|
/// Registry of component names for constraint validation.
|
||||||
/// Maps human-readable names (e.g., "evaporator") to NodeIndex.
|
/// Maps human-readable names (e.g., "evaporator") to NodeIndex.
|
||||||
component_names: HashMap<String, NodeIndex>,
|
component_names: HashMap<String, NodeIndex>,
|
||||||
|
/// Per-component calibration indices snapshot, captured at `finalize()` time.
|
||||||
|
/// Lets external readers (e.g. result extraction) learn which Z-factors
|
||||||
|
/// (z_ua, z_dp, z_flow, …) were promoted to free unknowns and where they
|
||||||
|
/// live in the state vector, so the solved values can be surfaced to the user.
|
||||||
|
calib_indices_by_name: HashMap<String, entropyk_core::CalibIndices>,
|
||||||
finalized: bool,
|
finalized: bool,
|
||||||
total_state_len: usize,
|
total_state_len: usize,
|
||||||
/// When `true` (default), `finalize` rejects **over-constrained** systems.
|
/// When `true` (default), `finalize` rejects **over-constrained** systems.
|
||||||
@@ -246,6 +251,7 @@ impl System {
|
|||||||
saturated_controllers: Vec::new(),
|
saturated_controllers: Vec::new(),
|
||||||
free_actuators: Vec::new(),
|
free_actuators: Vec::new(),
|
||||||
component_names: HashMap::new(),
|
component_names: HashMap::new(),
|
||||||
|
calib_indices_by_name: HashMap::new(),
|
||||||
finalized: false,
|
finalized: false,
|
||||||
total_state_len: 0,
|
total_state_len: 0,
|
||||||
enforce_dof_gate: true,
|
enforce_dof_gate: true,
|
||||||
@@ -682,6 +688,10 @@ impl System {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the per-component calib map so external readers (result
|
||||||
|
// extraction) can surface solved Z-factor values (z_ua, z_dp, …).
|
||||||
|
self.calib_indices_by_name = comp_calib_indices;
|
||||||
|
|
||||||
// Wire physical thermal couplings (Story 3.4 completion): each coupling
|
// Wire physical thermal couplings (Story 3.4 completion): each coupling
|
||||||
// owns one state unknown Q [W] at `coupling_state_index(i)`. The
|
// owns one state unknown Q [W] at `coupling_state_index(i)`. The
|
||||||
// cold-side receiver component (e.g. `ThermalLoad`) reads Q in its
|
// cold-side receiver component (e.g. `ThermalLoad`) reads Q in its
|
||||||
@@ -1145,6 +1155,15 @@ impl System {
|
|||||||
self.component_names.keys().map(|s| s.as_str())
|
self.component_names.keys().map(|s| s.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-component `CalibIndices` snapshot captured at `finalize()`.
|
||||||
|
/// Each `Some(idx)` slot (z_ua, z_dp, z_flow, z_flow_eco, z_power, z_etav,
|
||||||
|
/// actuator) means that factor was promoted to a free solver unknown and
|
||||||
|
/// its solved value lives at `state[idx]`. Used by result extraction to
|
||||||
|
/// surface solved calibration/control values to the user.
|
||||||
|
pub fn calib_indices_by_name(&self) -> &HashMap<String, entropyk_core::CalibIndices> {
|
||||||
|
&self.calib_indices_by_name
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a reference to the component stored at the given node index.
|
/// Returns a reference to the component stored at the given node index.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
@@ -2514,11 +2533,11 @@ impl System {
|
|||||||
self.total_state_len + self.inverse_control.mapping_count() + i
|
self.total_state_len + self.inverse_control.mapping_count() + i
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saturated_base_index(&self) -> usize {
|
pub fn saturated_base_index(&self) -> usize {
|
||||||
self.total_state_len + self.inverse_control.mapping_count() + self.coupling_residual_count()
|
self.total_state_len + self.inverse_control.mapping_count() + self.coupling_residual_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saturated_u_index(&self, i: usize) -> usize {
|
pub fn saturated_u_index(&self, i: usize) -> usize {
|
||||||
self.saturated_base_index() + 2 * i
|
self.saturated_base_index() + 2 * i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
crates/solver/tests/faer_backend_parity.rs
Normal file
83
crates/solver/tests/faer_backend_parity.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//! Story 1.5 AC#2/#3: the faer dense backend solves the reference cycles with
|
||||||
|
//! results matching the nalgebra baseline within tolerance (not bitwise —
|
||||||
|
//! different factorization engines), via the strangler dispatch.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use entropyk_solver::linear::{make_linear_backend, set_linear_backend_override, DenseLu};
|
||||||
|
use entropyk_solver::{LinearSolver, Solver};
|
||||||
|
|
||||||
|
/// Per-value tolerance matching the golden-snapshot comparison policy.
|
||||||
|
const STATE_RTOL: f64 = 1e-6;
|
||||||
|
const STATE_ATOL: f64 = 1.0; // Pa / J/kg absolute floor
|
||||||
|
|
||||||
|
fn solve_all_cycles() -> Vec<Vec<f64>> {
|
||||||
|
[
|
||||||
|
common::build_reference_cycle_a(),
|
||||||
|
common::build_reference_cycle_b(),
|
||||||
|
common::build_reference_cycle_c(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut system| {
|
||||||
|
common::solve_reference_system_with_state(&mut system)
|
||||||
|
.state
|
||||||
|
.clone()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn faer_path_dispatch_and_reference_cycle_parity() {
|
||||||
|
// 1. Unknown override must fail the solve with the dispatch error (the
|
||||||
|
// dispatch is on the strategy path, not dead code).
|
||||||
|
set_linear_backend_override(Some("definitely-not-a-backend"));
|
||||||
|
let mut system = common::build_reference_cycle_a();
|
||||||
|
let result = entropyk_solver::FallbackSolver::default_solver().solve(&mut system);
|
||||||
|
assert!(result.is_err(), "unknown backend must fail the solve");
|
||||||
|
|
||||||
|
// 2. Baseline (nalgebra) on the three reference cycles.
|
||||||
|
set_linear_backend_override(Some("nalgebra"));
|
||||||
|
let baseline = solve_all_cycles();
|
||||||
|
|
||||||
|
// 3. Same cycles on faer; converged states must match within tolerance.
|
||||||
|
set_linear_backend_override(Some("faer"));
|
||||||
|
let faer = solve_all_cycles();
|
||||||
|
|
||||||
|
set_linear_backend_override(None);
|
||||||
|
|
||||||
|
assert_eq!(baseline.len(), faer.len());
|
||||||
|
for (cycle_idx, (base, alt)) in baseline.iter().zip(faer.iter()).enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
base.len(),
|
||||||
|
alt.len(),
|
||||||
|
"cycle {cycle_idx}: state length differs"
|
||||||
|
);
|
||||||
|
for (i, (b, a)) in base.iter().zip(alt.iter()).enumerate() {
|
||||||
|
let scale = b.abs().max(a.abs()).max(STATE_ATOL);
|
||||||
|
assert!(
|
||||||
|
(b - a).abs() <= STATE_RTOL * scale,
|
||||||
|
"cycle {cycle_idx} state[{i}]: nalgebra={b} faer={a} (rel diff {})",
|
||||||
|
(b - a).abs() / scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dense_lu_enum_dispatches_both_engines() {
|
||||||
|
for name in ["nalgebra", "faer"] {
|
||||||
|
let mut backend = make_linear_backend(Some(name)).unwrap();
|
||||||
|
backend.set_problem(2, 2).unwrap();
|
||||||
|
backend
|
||||||
|
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
|
||||||
|
.unwrap();
|
||||||
|
let mut rhs = vec![4.0, 3.0];
|
||||||
|
backend.solve_in_place(&mut rhs).unwrap();
|
||||||
|
assert!((rhs[0] - 2.0).abs() < 1e-9, "{name}: {}", rhs[0]);
|
||||||
|
assert!((rhs[1] - 3.0).abs() < 1e-9, "{name}: {}", rhs[1]);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
make_linear_backend(Some("faer")).unwrap(),
|
||||||
|
DenseLu::Faer(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
21
docs/benchmarks/faer-vs-nalgebra.md
Normal file
21
docs/benchmarks/faer-vs-nalgebra.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# faer vs nalgebra dense LU — comparative report (Story 1.5, 2026-07-19)
|
||||||
|
|
||||||
|
Environment: Windows 11 x64, rustc 1.89 (stable, pinned), release profile, criterion 0.5.
|
||||||
|
Backends: `NalgebraLuSolver` (nalgebra 0.33 LU) vs `FaerLuSolver` (faer 0.24.4 partial-pivot LU), both behind the `LinearSolver` trait with identical Ruiz equilibration and zero-alloc solve.
|
||||||
|
|
||||||
|
| Benchmark | nalgebra | faer | ratio (faer/nalgebra) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| factor 9×9 (mock cycle Jacobian) | 308 ns | 1.23 µs | **4.0× slower** |
|
||||||
|
| solve-per-RHS 9×9 | 83 ns | 245 ns | 3.0× slower |
|
||||||
|
| factor 50×50 (synthetic dense) | 19.4 µs | 20.4 µs | 1.05× slower |
|
||||||
|
| solve-per-RHS 50×50 | 589 ns | 1.11 µs | 1.9× slower |
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
At Entropyk's current system sizes (n ≤ ~100), **faer is NOT faster than nalgebra** — the assembled-matrix conversion (nalgebra → faer column-major Mat) costs at factor time on small n, and faer's threading edge only materializes at larger n. faer stays an **opt-in** alternative (`solver.linear_backend = "faer"` / `ENTROPYK_LINEAR_BACKEND=faer`); **nalgebra remains the default** — the strangler data does not justify retirement, and none was planned in this story.
|
||||||
|
|
||||||
|
Revisit when: (a) block-dense/Schur systems push n into the hundreds (Epic 2 / Story 1.8), (b) faer-sparse becomes relevant, (c) batch/parallel solves can exploit faer's rayon threading (Epic 4).
|
||||||
|
|
||||||
|
Parity (AC#3): reference cycles A/B/C converge identically within 1e-6 relative on both backends; full solver corpus green on both paths (38/38 test binaries, 0 FAILED).
|
||||||
|
|
||||||
|
Reproduce: `cargo bench -p entropyk-solver --bench lu_backends`
|
||||||
Reference in New Issue
Block a user