Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -7,6 +7,10 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
[lib]
name = "entropyk_cli"
path = "src/lib.rs"
[[bin]]
name = "entropyk-cli"
path = "src/main.rs"
@@ -16,11 +20,12 @@ entropyk = { path = "../entropyk" }
entropyk-core = { path = "../core" }
entropyk-components = { path = "../components" }
entropyk-solver = { path = "../solver" }
entropyk-fluids = { path = "../fluids" }
entropyk-fluids = { path = "../fluids", features = ["coolprop"] }
clap = { version = "4.4", features = ["derive", "color"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "0.8"
anyhow = "1.0"
thiserror = { workspace = true }
tracing = "0.1"

View File

@@ -0,0 +1,19 @@
{
"schema_version": "2",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"name": "Circuit 0",
"components": [
{"type": "FloodedEvaporator", "name": "evap", "ua": 8000.0, "secondary_fluid": "Water", "quality_control": false},
{"type": "BrineSource", "name": "src", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5},
{"type": "BrineSink", "name": "sink", "fluid": "Water", "p_back_bar": 2.0}
],
"edges": [
{"from": "src:outlet", "to": "evap:secondary_inlet"},
{"from": "evap:secondary_outlet", "to": "sink:inlet"}
]
}],
"solver": {"strategy": "newton", "max_iterations": 10, "tolerance": 1e-4}
}

View File

@@ -1,94 +1,32 @@
{
"name": "BPHX Evaporator + Condenser — standalone runnable examples",
"fluid": "R410A",
"name": "BPHX Evaporator and Condenser Bounded Test",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Evaporator circuit (R410A / Water)",
"components": [
{
"type": "RefrigerantSource",
"name": "ref_src_evap",
"fluid": "R410A",
"p_set_bar": 4.0,
"quality": 0.2
},
{
"type": "BphxEvaporator",
"name": "evap",
"refrigerant": "R410A",
"secondary_fluid": "Water",
"mode": "dx",
"target_superheat_k": 5.0,
"dh_m": 0.003,
"area_m2": 0.5,
"n_plates": 20,
"hot_fluid": "Water",
"hot_t_inlet_c": 12.0,
"hot_pressure_bar": 2.0,
"hot_mass_flow_kg_s": 0.5,
"cold_fluid": "R410A",
"cold_t_inlet_c": 2.0,
"cold_pressure_bar": 4.0,
"cold_mass_flow_kg_s": 0.1
},
{
"type": "RefrigerantSink",
"name": "ref_snk_evap",
"fluid": "R410A",
"p_back_bar": 4.0
}
{ "type": "RefrigerantSource", "name": "src", "fluid": "R134a", "p_set_bar": 5.0, "quality": 0.3 },
{ "type": "BphxEvaporator", "name": "evap", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 12.0, "secondary_mass_flow_kg_s": 0.5, "secondary_cp_j_per_kgk": 4186.0 },
{ "type": "RefrigerantSink", "name": "sink", "fluid": "R134a", "p_back_bar": 5.0 }
],
"edges": [
{ "from": "ref_src_evap:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "ref_snk_evap:inlet" }
{ "from": "src:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "sink:inlet" }
]
},
{
"id": 1,
"name": "Condenser circuit (R410A / Water)",
"components": [
{
"type": "RefrigerantSource",
"name": "ref_src_cond",
"fluid": "R410A",
"p_set_bar": 18.0,
"quality": 1.0
},
{
"type": "BphxCondenser",
"name": "cond",
"refrigerant": "R410A",
"secondary_fluid": "Water",
"target_subcooling_k": 3.0,
"dh_m": 0.003,
"area_m2": 0.5,
"n_plates": 20,
"hot_fluid": "R410A",
"hot_t_inlet_c": 45.0,
"hot_pressure_bar": 18.0,
"hot_mass_flow_kg_s": 0.1,
"cold_fluid": "Water",
"cold_t_inlet_c": 25.0,
"cold_pressure_bar": 2.0,
"cold_mass_flow_kg_s": 0.5
},
{
"type": "RefrigerantSink",
"name": "ref_snk_cond",
"fluid": "R410A",
"p_back_bar": 18.0
}
{ "type": "RefrigerantSource", "name": "src2", "fluid": "R134a", "p_set_bar": 15.0, "quality": 1.0 },
{ "type": "BphxCondenser", "name": "cond", "ua": 2000.0, "refrigerant": "R134a", "secondary_fluid": "Water", "secondary_inlet_temp_c": 30.0, "secondary_mass_flow_kg_s": 0.4, "secondary_cp_j_per_kgk": 4186.0 },
{ "type": "RefrigerantSink", "name": "sink2", "fluid": "R134a", "p_back_bar": 15.0 }
],
"edges": [
{ "from": "ref_src_cond:outlet", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "ref_snk_cond:inlet" }
{ "from": "src2:outlet", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "sink2:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 50,
"tolerance": 1e-6
}
"solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Capillary smoke",
"components": [
{
"type": "CapillaryTube",
"name": "cap",
"diameter_m": 0.0012,
"length_m": 1.8,
"n_segments": 24,
"p_inlet_bar": 12.0,
"h_inlet_kj_kg": 250.0,
"p_outlet_bar": 3.5,
"h_outlet_kj_kg": 250.0
}
],
"edges": []
}
],
"solver": {
"strategy": "newton",
"max_iterations": 50,
"tolerance": 1e-6
}
}

View File

@@ -0,0 +1,107 @@
{
"name": "Air-Cooled Chiller R134a (4-Port Modelica Style)",
"description": "Full emergent-pressure chiller. Condenser on air (AirSource→cond→AirSink), evaporator on chilled water (BrineSource→evap→BrineSink). MassFlowSource_T: Free P + Fixed ṁ/T; sinks Fixed P. secondary_humidity_ratio MUST match AirSource psychrometrics (W at T_dry, RH, P).",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + secondary loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.70,
"t_cond_k": 318.15,
"t_evap_k": 278.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 6.5e-5,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "Condenser",
"name": "cond",
"ua": 2500.0,
"emergent_pressure": true,
"subcooling_k": 5.0,
"secondary_fluid": "Air",
"secondary_humidity_ratio": 0.01412,
"dp_model": "isobaric",
"secondary_rated_pressure_drop_pa": 150,
"secondary_rated_m_flow_kg_s": 1.2
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 278.15,
"emergent_pressure": true
},
{
"type": "Evaporator",
"name": "evap",
"ua": 1468.0,
"emergent_pressure": true,
"secondary_fluid": "Water",
"dp_model": "isobaric",
"secondary_rated_pressure_drop_pa": 40000,
"secondary_rated_m_flow_kg_s": 0.4778
},
{
"type": "AirSource",
"name": "cond_air_in",
"p_set_bar": 1.01325,
"t_dry_c": 35.0,
"rh": 40.0,
"m_flow_kg_s": 1.2,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": true
},
{
"type": "AirSink",
"name": "cond_air_out",
"p_back_bar": 1.01325,
"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.4778,
"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_air_in:outlet", "to": "cond:secondary_inlet" },
{ "from": "cond:secondary_outlet", "to": "cond_air_out:inlet" },
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 300,
"tolerance": 1e-6
}
}

View File

@@ -0,0 +1,107 @@
{
"name": "Water-cooled chiller with FloodedEvaporator (4-port, square DoF)",
"description": "Honest machine topology: emergent refrigerant pressures + live secondary water loops. Flooded evaporator has NO quality_control residual (compressor suction). Budget target: n_eq = n_unk (19).",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + secondary loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.70,
"t_cond_k": 313.15,
"t_evap_k": 278.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 5.0e-5,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "Condenser",
"name": "cond",
"ua": 2200.0,
"emergent_pressure": true,
"subcooling_k": 5.0,
"secondary_fluid": "Water",
"dp_model": "msh",
"tube_length_m": 6.0,
"tube_diameter_m": 0.0095,
"n_parallel_tubes": 2,
"secondary_rated_pressure_drop_pa": 30000,
"secondary_rated_m_flow_kg_s": 0.45
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 278.15,
"emergent_pressure": true
},
{
"type": "FloodedEvaporator",
"name": "evap",
"ua": 9000.0,
"refrigerant": "R134a",
"secondary_fluid": "Water",
"quality_control": false,
"secondary_rated_pressure_drop_pa": 40000,
"secondary_rated_m_flow_kg_s": 0.55
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 30.0,
"m_flow_kg_s": 0.45,
"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.55,
"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": "newton",
"max_iterations": 300,
"tolerance": 1e-6
}
}

View File

@@ -0,0 +1,105 @@
{
"name": "Water-cooled chiller — ΔT rating on evaporator loop",
"description": "Evap loop: Free ṁ + Fixed T_out=7 °C (ΔT=5 K from 12 °C). Cond loop keeps Fixed ṁ (stable anchor).",
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + secondary loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.70,
"t_cond_k": 313.15,
"t_evap_k": 278.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 5.0e-5,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "Condenser",
"name": "cond",
"ua": 2200.0,
"emergent_pressure": true,
"subcooling_k": 5.0,
"secondary_fluid": "Water",
"dp_model": "msh",
"tube_length_m": 6.0,
"tube_diameter_m": 0.0095,
"n_parallel_tubes": 2
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 278.15,
"emergent_pressure": true
},
{
"type": "FloodedEvaporator",
"name": "evap",
"ua": 9000.0,
"refrigerant": "R134a",
"secondary_fluid": "Water",
"quality_control": false
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 30.0,
"m_flow_kg_s": 0.45,
"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.55,
"fix_pressure": false,
"fix_temperature": true,
"fix_mass_flow": false
},
{
"type": "BrineSink",
"name": "evap_water_out",
"fluid": "Water",
"p_back_bar": 3.0,
"t_set_c": 7.0,
"fix_pressure": true,
"fix_temperature": 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": "newton",
"max_iterations": 300,
"tolerance": 1e-6
}
}

View File

@@ -1,143 +0,0 @@
{
"name": "Chiller MCHX Condensers - Démonstration CLI",
"description": "Démontre l'utilisation des MchxCondenserCoil (4 coils) et FloodedEvaporator dans le pipeline CLI. Utilise des Placeholder pour simuler compresseur et vanne. Topology linéaire pour compatibilité CLI graphe.",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "Placeholder",
"name": "comp_0",
"n_equations": 2
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 0.8
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "comp_0:outlet",
"to": "mchx_0a:inlet"
},
{
"from": "mchx_0a:outlet",
"to": "mchx_0b:inlet"
},
{
"from": "mchx_0b:outlet",
"to": "exv_0:inlet"
},
{
"from": "exv_0:outlet",
"to": "evap_0:inlet"
},
{
"from": "evap_0:outlet",
"to": "comp_0:inlet"
}
]
},
{
"id": 1,
"components": [
{
"type": "Placeholder",
"name": "comp_1",
"n_equations": 2
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1a",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1b",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 0.9
},
{
"type": "Placeholder",
"name": "exv_1",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_1",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "comp_1:outlet",
"to": "mchx_1a:inlet"
},
{
"from": "mchx_1a:outlet",
"to": "mchx_1b:inlet"
},
{
"from": "mchx_1b:outlet",
"to": "exv_1:inlet"
},
{
"from": "exv_1:outlet",
"to": "evap_1:inlet"
},
{
"from": "evap_1:outlet",
"to": "comp_1:inlet"
}
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 100,
"tolerance": 1e-6
},
"metadata": {
"note": "Demo MCHX 4 coils + FloodedEvap 2 circuits via CLI",
"mchx_coil_0_fan": "100% (design point)",
"mchx_coil_1_fan": "80% (anti-override actif)",
"mchx_coil_2_fan": "100% (design point)",
"mchx_coil_3_fan": "90%",
"glycol_type": "MEG 35%",
"t_air_celsius": 35.0
}
}

View File

@@ -0,0 +1,57 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Circuit A",
"enabled": true,
"components": [
{ "type": "IsentropicCompressor", "name": "comp_a", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond_a", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv_a", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap_a", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in_a", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out_a", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in_a", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out_a", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": [
{ "from": "comp_a:outlet", "to": "cond_a:inlet" },
{ "from": "cond_a:outlet", "to": "exv_a:inlet" },
{ "from": "exv_a:outlet", "to": "evap_a:inlet" },
{ "from": "evap_a:outlet", "to": "comp_a:inlet" },
{ "from": "cond_water_in_a:outlet", "to": "cond_a:secondary_inlet" },
{ "from": "cond_a:secondary_outlet", "to": "cond_water_out_a:inlet" },
{ "from": "evap_water_in_a:outlet", "to": "evap_a:secondary_inlet" },
{ "from": "evap_a:secondary_outlet", "to": "evap_water_out_a:inlet" }
]
},
{
"id": 1,
"name": "Circuit B",
"enabled": true,
"components": [
{ "type": "IsentropicCompressor", "name": "comp_b", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond_b", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv_b", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap_b", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in_b", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out_b", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in_b", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out_b", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": [
{ "from": "comp_b:outlet", "to": "cond_b:inlet" },
{ "from": "cond_b:outlet", "to": "exv_b:inlet" },
{ "from": "exv_b:outlet", "to": "evap_b:inlet" },
{ "from": "evap_b:outlet", "to": "comp_b:inlet" },
{ "from": "cond_water_in_b:outlet", "to": "cond_b:secondary_inlet" },
{ "from": "cond_b:secondary_outlet", "to": "cond_water_out_b:inlet" },
{ "from": "evap_water_in_b:outlet", "to": "evap_b:secondary_inlet" },
{ "from": "evap_b:secondary_outlet", "to": "evap_water_out_b:inlet" }
]
}
],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}

View File

@@ -0,0 +1,34 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp",
"isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
"emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 766.0,
"emergent_pressure": true, "subcooling_k": 5.0,
"secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0,
"emergent_pressure": true,
"secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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-6 }
}

View File

@@ -0,0 +1,28 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true, "orifice_kv": 2.0e-6, "orifice_opening_init": 0.5, "orifice_opening_min": 0.02, "orifice_opening_max": 1.0 },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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-6 }
}

View File

@@ -0,0 +1,28 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 2500.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010, "flooded_head_pressure_target_c": 45.0 },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "AirSource", "name": "cond_air_in", "p_set_bar": 1.01325, "t_dry_c": 5.0, "rh": 50.0, "m_flow_kg_s": 0.6 },
{ "type": "AirSink", "name": "cond_air_out", "p_back_bar": 1.01325 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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_air_in:outlet", "to": "cond:secondary_inlet" },
{ "from": "cond:secondary_outlet", "to": "cond_air_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-6 }
}

View File

@@ -0,0 +1,36 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92, "liquid_injection": true },
{ "type": "Condenser", "name": "cond", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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" }
]
}],
"controls": [
{
"type": "SaturatedController", "id": "dgt_limiter",
"measure": { "component": "comp", "output": "temperature" },
"actuator": { "component": "comp", "factor": "injection", "initial": 0.15, "min": 0.0, "max": 0.3 },
"target": 330.0, "gain": -0.5, "band": 5.0
}
],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}

View File

@@ -0,0 +1,28 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92, "slide_valve_sst_target_c": 3.0 },
{ "type": "Condenser", "name": "cond", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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-6 }
}

View File

@@ -0,0 +1,36 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 766.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true, "orifice_kv": 2.0e-6, "orifice_opening_init": 0.5, "orifice_opening_min": 0.02, "orifice_opening_max": 1.0 },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "superheat_regulated": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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" }
]
}],
"controls": [
{
"type": "SaturatedController", "id": "sh_control",
"measure": { "component": "evap", "output": "superheat" },
"actuator": { "component": "exv", "factor": "opening", "initial": 0.5, "min": 0.02, "max": 1.0 },
"target": 5.0, "gain": -0.8, "band": 1.0, "alpha": 5e-2
}
],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}

View File

@@ -0,0 +1,32 @@
{
"fluid": "R410A",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 313.15, "t_evap_k": 276.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 5.0e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 2000.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "Anchor", "name": "anchor", "mode": "probe" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 276.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1800.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "HeatSource", "name": "heat_source", "q_w": 500.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 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": [
{ "from": "comp:outlet", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "anchor:inlet" },
{ "from": "anchor:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "heat_source:inlet" },
{ "from": "heat_source: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-6 }
}

View File

@@ -0,0 +1,41 @@
{
"fluid": "R410A",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 313.15, "t_evap_k": 276.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 5.0e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 2000.0, "emergent_pressure": true, "subcooling_k": 5.0 },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 276.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1800.0, "emergent_pressure": true, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"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": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
]
},
{
"id": 1,
"components": [
{ "type": "BrineSource", "name": "ws", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.9 },
{ "type": "ThermalLoad", "name": "load", "design_mass_flow_kg_s": 0.9 },
{ "type": "BrineSink", "name": "wsk", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": [
{ "from": "ws:outlet", "to": "load:inlet" },
{ "from": "load:outlet", "to": "wsk:inlet" }
]
}
],
"thermal_couplings": [
{ "hot_circuit": 0, "hot_component": "cond", "cold_circuit": 1, "cold_component": "load", "ua": 2000.0, "efficiency": 1.0 }
],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}

View File

@@ -1,106 +0,0 @@
{
"name": "Chiller eau glacée R410A - Eurovent",
"description": "Système complet de production d'eau glacée avec cycle R410A",
"fluid": "R410A",
"circuits": [
{
"id": 0,
"name": "Circuit réfrigérant R410A",
"components": [
{
"type": "Compressor",
"name": "comp",
"fluid": "R410A",
"speed_rpm": 2900,
"displacement_m3": 0.000030,
"efficiency": 0.85,
"m1": 0.85, "m2": 2.5,
"m3": 500, "m4": 1500, "m5": -2.5, "m6": 1.8,
"m7": 600, "m8": 1600, "m9": -3.0, "m10": 2.0
},
{
"type": "HeatExchanger",
"name": "condenser",
"ua": 5000,
"hot_fluid": "R410A",
"hot_t_inlet_c": 45,
"hot_pressure_bar": 24,
"hot_mass_flow_kg_s": 0.05,
"cold_fluid": "Water",
"cold_t_inlet_c": 30,
"cold_pressure_bar": 1,
"cold_mass_flow_kg_s": 0.4
},
{
"type": "ExpansionValve",
"name": "exv",
"fluid": "R410A",
"opening": 1.0
},
{
"type": "Evaporator",
"name": "evaporator",
"ua": 6000,
"t_sat_k": 275.15,
"superheat_k": 5
}
],
"edges": [
{ "from": "comp:outlet", "to": "condenser:inlet" },
{ "from": "condenser:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evaporator:inlet" },
{ "from": "evaporator:outlet", "to": "comp:inlet" }
]
},
{
"id": 1,
"name": "Circuit eau glacée",
"components": [
{
"type": "Pump",
"name": "pump"
},
{
"type": "Pump",
"name": "load"
}
],
"edges": [
{ "from": "pump:outlet", "to": "load:inlet" },
{ "from": "load:outlet", "to": "pump:inlet" }
]
}
],
"thermal_couplings": [
{
"hot_circuit": 0,
"cold_circuit": 1,
"ua": 6000,
"efficiency": 0.95
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 100,
"tolerance": 1e-6
},
"design_conditions": {
"chilled_water_inlet_c": 12,
"chilled_water_outlet_c": 7,
"chilled_water_flow_kg_s": 0.5,
"ambient_air_c": 35,
"cooling_capacity_kw": 10.5,
"cop_estimated": 3.5
},
"metadata": {
"author": "Entropyk",
"version": "1.0",
"application": "Water chiller for HVAC"
}
}

View File

@@ -1,78 +0,0 @@
{
"name": "Chiller R410A - Minimal Working Example",
"description": "Système chiller simplifié avec placeholders (comme eurovent.rs)",
"fluid": "R410A",
"circuits": [
{
"id": 0,
"name": "Circuit réfrigérant R410A",
"components": [
{
"type": "Placeholder",
"name": "comp",
"n_equations": 2
},
{
"type": "Condenser",
"name": "cond",
"ua": 5000
},
{
"type": "Placeholder",
"name": "exv",
"n_equations": 1
},
{
"type": "Evaporator",
"name": "evap",
"ua": 6000,
"t_sat_k": 275.15,
"superheat_k": 5
}
],
"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" }
]
},
{
"id": 1,
"name": "Circuit eau glacée",
"components": [
{
"type": "Placeholder",
"name": "pump",
"n_equations": 2
},
{
"type": "Placeholder",
"name": "load",
"n_equations": 1
}
],
"edges": [
{ "from": "pump:outlet", "to": "load:inlet" },
{ "from": "load:outlet", "to": "pump:inlet" }
]
}
],
"thermal_couplings": [
{
"hot_circuit": 0,
"cold_circuit": 1,
"ua": 6000,
"efficiency": 0.95
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 100,
"tolerance": 1e-6
}
}

View File

@@ -1,181 +0,0 @@
{
"name": "Chiller Air-Glycol 2 Circuits - Screw Economisé + MCHX",
"description": "Machine frigorifique 2 circuits indépendants. R134a, condenseurs MCHX (4 coils, air 35°C), évaporateurs noyés (MEG 35%, 12→7°C), compresseurs vis économisés VFD.",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "screw_0:outlet",
"to": "mchx_0a:inlet"
},
{
"from": "mchx_0a:outlet",
"to": "mchx_0b:inlet"
},
{
"from": "mchx_0b:outlet",
"to": "exv_0:inlet"
},
{
"from": "exv_0:outlet",
"to": "evap_0:inlet"
},
{
"from": "evap_0:outlet",
"to": "screw_0:inlet"
}
]
},
{
"id": 1,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_1",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1a",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1b",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "Placeholder",
"name": "exv_1",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_1",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "screw_1:outlet",
"to": "mchx_1a:inlet"
},
{
"from": "mchx_1a:outlet",
"to": "mchx_1b:inlet"
},
{
"from": "mchx_1b:outlet",
"to": "exv_1:inlet"
},
{
"from": "exv_1:outlet",
"to": "evap_1:inlet"
},
{
"from": "evap_1:outlet",
"to": "screw_1:inlet"
}
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 150,
"tolerance": 1e-6,
"timeout_ms": 5000,
"verbose": false
},
"metadata": {
"refrigerant": "R134a",
"application": "Air-cooled chiller",
"glycol_type": "MEG 35%",
"glycol_inlet_celsius": 12.0,
"glycol_outlet_celsius": 7.0,
"ambient_air_celsius": 35.0,
"nominal_capacity_kw": 400.0,
"n_coils": 4,
"n_circuits": 2
}
}

View File

@@ -1,181 +0,0 @@
{
"name": "Chiller Air-Glycol - Screw MCHX Run (Compatible)",
"description": "Simulation chiller 2 circuits avec ScrewEconomizerCompressor et MchxCondenserCoil. Les composants utilisent les n_equations compatibles avec le graphe (2 par arête).",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "screw_0:outlet",
"to": "mchx_0a:inlet"
},
{
"from": "mchx_0a:outlet",
"to": "mchx_0b:inlet"
},
{
"from": "mchx_0b:outlet",
"to": "exv_0:inlet"
},
{
"from": "exv_0:outlet",
"to": "evap_0:inlet"
},
{
"from": "evap_0:outlet",
"to": "screw_0:inlet"
}
]
},
{
"id": 1,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_1",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1a",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1b",
"ua_nominal_kw_k": 15.0,
"n_air_exponent": 0.5,
"air_inlet_temp_c": 35.0,
"fan_speed": 1.0
},
{
"type": "Placeholder",
"name": "exv_1",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_1",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "screw_1:outlet",
"to": "mchx_1a:inlet"
},
{
"from": "mchx_1a:outlet",
"to": "mchx_1b:inlet"
},
{
"from": "mchx_1b:outlet",
"to": "exv_1:inlet"
},
{
"from": "exv_1:outlet",
"to": "evap_1:inlet"
},
{
"from": "evap_1:outlet",
"to": "screw_1:inlet"
}
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 200,
"tolerance": 1e-4,
"timeout_ms": 10000,
"verbose": false
},
"metadata": {
"refrigerant": "R134a",
"application": "Air-cooled chiller, screw with economizer",
"glycol_type": "MEG 35%",
"glycol_inlet_celsius": 12.0,
"glycol_outlet_celsius": 7.0,
"ambient_air_celsius": 35.0,
"n_coils": 4,
"n_circuits": 2,
"design_capacity_kw": 400
}
}

View File

@@ -1,90 +0,0 @@
{
"name": "Chiller Screw Economisé MCHX - Validation",
"description": "Fichier de validation pour tester le parsing du config sans lancer la simulation",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0"
},
{
"type": "Placeholder",
"name": "splitter_0",
"n_equations": 1
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua_nominal_kw_k": 15.0,
"air_inlet_temp_c": 35.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua_nominal_kw_k": 15.0,
"air_inlet_temp_c": 35.0
},
{
"type": "Placeholder",
"name": "merger_0",
"n_equations": 1
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{
"from": "screw_0:outlet",
"to": "splitter_0:inlet"
},
{
"from": "splitter_0:out_a",
"to": "mchx_0a:inlet"
},
{
"from": "splitter_0:out_b",
"to": "mchx_0b:inlet"
},
{
"from": "mchx_0a:outlet",
"to": "merger_0:in_a"
},
{
"from": "mchx_0b:outlet",
"to": "merger_0:in_b"
},
{
"from": "merger_0:outlet",
"to": "exv_0:inlet"
},
{
"from": "exv_0:outlet",
"to": "evap_0:inlet"
},
{
"from": "evap_0:outlet",
"to": "screw_0:inlet"
}
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 100,
"tolerance": 1e-6
}
}

View File

@@ -0,0 +1,93 @@
{
"name": "Water-Cooled Chiller R410A (4-Port Modelica Style)",
"description": "Full emergent-pressure chiller cycle with both heat exchangers on water loops. Condenser water: BrineSource(30C) → cond:secondary_inlet → cond:secondary_outlet → BrineSink. Chilled water: BrineSource(12C) → evap:secondary_inlet → evap:secondary_outlet → BrineSink. Secondary sides are real graph edges — the duty Q is solved from the live edge state.",
"fluid": "R410A",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Refrigerant + secondary loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.70,
"t_cond_k": 313.15,
"t_evap_k": 276.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 5.0e-5,
"speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "Condenser",
"name": "cond",
"ua": 2000.0,
"emergent_pressure": true,
"subcooling_k": 5.0,
"secondary_fluid": "Water"
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 276.15,
"emergent_pressure": true
},
{
"type": "Evaporator",
"name": "evap",
"ua": 1800.0,
"emergent_pressure": true,
"secondary_fluid": "Water"
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 30.0,
"m_flow_kg_s": 0.40
},
{
"type": "BrineSink",
"name": "cond_water_out",
"fluid": "Water",
"p_back_bar": 2.0
},
{
"type": "BrineSource",
"name": "evap_water_in",
"fluid": "Water",
"p_set_bar": 3.0,
"t_set_c": 12.0,
"m_flow_kg_s": 0.50
},
{
"type": "BrineSink",
"name": "evap_water_out",
"fluid": "Water",
"p_back_bar": 3.0
}
],
"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-6
}
}

View File

@@ -1,106 +0,0 @@
{
"name": "Pompe à chaleur air-eau R410A - A7/W35",
"description": "Pompe à chaleur air-eau Eurovent A7/W35 avec R410A",
"fluid": "R410A",
"circuits": [
{
"id": 0,
"name": "Circuit réfrigérant R410A",
"components": [
{
"type": "Compressor",
"name": "comp",
"fluid": "R410A",
"speed_rpm": 2900,
"displacement_m3": 0.000025,
"efficiency": 0.82,
"m1": 0.88, "m2": 2.2,
"m3": 450, "m4": 1400, "m5": -2.0, "m6": 1.5,
"m7": 550, "m8": 1500, "m9": -2.5, "m10": 1.8
},
{
"type": "Condenser",
"name": "condenser",
"ua": 4500
},
{
"type": "ExpansionValve",
"name": "exv",
"fluid": "R410A",
"opening": 1.0
},
{
"type": "HeatExchanger",
"name": "evaporator",
"ua": 6000,
"hot_fluid": "Air",
"hot_t_inlet_c": 7,
"hot_pressure_bar": 1.01,
"hot_mass_flow_kg_s": 0.8,
"cold_fluid": "R410A",
"cold_t_inlet_c": 0,
"cold_pressure_bar": 7,
"cold_mass_flow_kg_s": 0.04
}
],
"edges": [
{ "from": "comp:outlet", "to": "condenser:inlet" },
{ "from": "condenser:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evaporator:inlet" },
{ "from": "evaporator:outlet", "to": "comp:inlet" }
]
},
{
"id": 1,
"name": "Circuit eau chauffage",
"components": [
{
"type": "Pump",
"name": "pump"
},
{
"type": "Pump",
"name": "radiators"
}
],
"edges": [
{ "from": "pump:outlet", "to": "radiators:inlet" },
{ "from": "radiators:outlet", "to": "pump:inlet" }
]
}
],
"thermal_couplings": [
{
"hot_circuit": 0,
"cold_circuit": 1,
"ua": 4500,
"efficiency": 0.95
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 100,
"tolerance": 1e-6
},
"design_conditions": {
"source": "air_exterieur",
"t_air_exterieur_c": 7,
"t_evaporation_c": 0,
"t_condensation_c": 50,
"eau_chauffage_entree_c": 30,
"eau_chauffage_sortie_c": 45,
"debit_eau_kg_s": 0.3,
"puissance_chauffage_kw": 10,
"cop_estime": 3.2
},
"metadata": {
"application": "Heat pump for residential heating",
"standard": "Eurovent A7/W35"
}
}

View File

@@ -0,0 +1,93 @@
{
"name": "Air-Source Heat Pump R410A (4-Port Modelica Style)",
"description": "Heat pump cycle: evaporator absorbs heat from outside air (AirSource → evap:secondary_inlet → evap:secondary_outlet → AirSink), condenser delivers heat to a hot-water loop (BrineSource → cond:secondary_inlet → cond:secondary_outlet → BrineSink). Both secondary sides are real graph edges. Uses emergent-pressure mode for genuine ε-NTU coupling.",
"fluid": "R410A",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"name": "Heat pump refrigerant + secondary loops",
"components": [
{
"type": "IsentropicCompressor",
"name": "comp",
"isentropic_efficiency": 0.70,
"t_cond_k": 323.15,
"t_evap_k": 265.15,
"superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 5.0e-5,
"speed_hz": 50.0,
"volumetric_efficiency": 0.90
},
{
"type": "Condenser",
"name": "cond",
"ua": 2000.0,
"emergent_pressure": true,
"subcooling_k": 5.0,
"secondary_fluid": "Water"
},
{
"type": "IsenthalpicExpansionValve",
"name": "exv",
"t_evap_k": 265.15,
"emergent_pressure": true
},
{
"type": "Evaporator",
"name": "evap",
"ua": 1800.0,
"emergent_pressure": true,
"secondary_fluid": "Air",
"secondary_humidity_ratio": 0.005
},
{
"type": "BrineSource",
"name": "cond_water_in",
"fluid": "Water",
"p_set_bar": 2.5,
"t_set_c": 40.0,
"m_flow_kg_s": 0.40
},
{
"type": "BrineSink",
"name": "cond_water_out",
"fluid": "Water",
"p_back_bar": 2.5
},
{
"type": "AirSource",
"name": "evap_air_in",
"p_set_bar": 1.01325,
"t_dry_c": 7.0,
"rh": 80.0,
"m_flow_kg_s": 1.0
},
{
"type": "AirSink",
"name": "evap_air_out",
"p_back_bar": 1.01325
}
],
"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_air_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet", "to": "evap_air_out:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 300,
"tolerance": 1e-6
}
}

View File

@@ -0,0 +1,28 @@
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 6.5e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "Condenser", "name": "cond", "ua": 2500.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010, "fan_head_pressure_target_c": 45.0 },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 278.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "emergent_pressure": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 },
{ "type": "AirSource", "name": "cond_air_in", "p_set_bar": 1.01325, "t_dry_c": 35.0, "rh": 50.0, "m_flow_kg_s": 0.6 },
{ "type": "AirSink", "name": "cond_air_out", "p_back_bar": 1.01325 },
{ "type": "AirSource", "name": "evap_air_in", "p_set_bar": 1.01325, "t_dry_c": 7.0, "rh": 50.0, "m_flow_kg_s": 0.5 },
{ "type": "AirSink", "name": "evap_air_out", "p_back_bar": 1.01325 }
],
"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_air_in:outlet", "to": "cond:secondary_inlet" },
{ "from": "cond:secondary_outlet", "to": "cond_air_out:inlet" },
{ "from": "evap_air_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet", "to": "evap_air_out:inlet" }
]
}],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}

View File

@@ -0,0 +1,30 @@
{
"fluid": "R410A",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.70, "t_cond_k": 313.15, "t_evap_k": 276.15, "superheat_k": 5.0, "emergent_pressure": true, "displacement_m3": 5.0e-5, "speed_hz": 50.0, "volumetric_efficiency": 0.92 },
{ "type": "ReversingValve", "name": "rv", "pressure_drop_kpa": 25.0, "pressure_drop_coeff": 5.0e5 },
{ "type": "Condenser", "name": "cond", "ua": 2000.0, "emergent_pressure": true, "subcooling_k": 5.0, "secondary_fluid": "Water" },
{ "type": "IsenthalpicExpansionValve", "name": "exv", "t_evap_k": 276.15, "emergent_pressure": true },
{ "type": "Evaporator", "name": "evap", "ua": 1800.0, "emergent_pressure": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 40.0, "m_flow_kg_s": 0.4 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "AirSource", "name": "evap_air_in", "p_set_bar": 1.01325, "t_dry_c": 7.0, "rh": 50.0, "m_flow_kg_s": 0.5 },
{ "type": "AirSink", "name": "evap_air_out", "p_back_bar": 1.01325 }
],
"edges": [
{ "from": "comp:outlet", "to": "rv:inlet" },
{ "from": "rv: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_air_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet", "to": "evap_air_out:inlet" }
]
}],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}

View File

@@ -0,0 +1,88 @@
{
"name": "Four-Port Air-Water Heat Exchanger",
"fluid": "Water",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"components": [
{
"type": "BrineSource",
"name": "hot_water_in",
"fluid": "Water",
"p_set_bar": 2.0,
"t_set_c": 60.0,
"m_flow_kg_s": 0.5
},
{
"type": "HeatExchanger",
"name": "hx",
"ua": 3000.0,
"hot_fluid_id": "Water",
"cold_fluid_id": "Air",
"cold_humidity_ratio": 0.010
},
{
"type": "BrineSink",
"name": "hot_water_out",
"fluid": "Water",
"p_back_bar": 2.0
},
{
"type": "AirSource",
"name": "cold_air_in",
"p_set_bar": 1.01325,
"t_dry_c": 20.0,
"rh": 50.0,
"m_flow_kg_s": 1.0
},
{
"type": "Fan",
"name": "supply_fan",
"fluid": "Air",
"speed_ratio": 1.0,
"air_density_kg_per_m3": 1.204,
"design_flow_m3_s": 0.83,
"curve_p0": 250.0,
"curve_p1": 0.0,
"curve_p2": -20.0,
"eff_e0": 0.65,
"eff_e1": 0.0,
"eff_e2": 0.0
},
{
"type": "AirSink",
"name": "cold_air_out",
"p_back_bar": 1.01325
}
],
"edges": [
{
"from": "hot_water_in:outlet",
"to": "hx:hot_inlet"
},
{
"from": "hx:hot_outlet",
"to": "hot_water_out:inlet"
},
{
"from": "cold_air_in:outlet",
"to": "supply_fan:inlet"
},
{
"from": "supply_fan:outlet",
"to": "hx:cold_inlet"
},
{
"from": "hx:cold_outlet",
"to": "cold_air_out:inlet"
}
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 300,
"tolerance": 1e-6
}
}

View File

@@ -0,0 +1,10 @@
{
"base_config": "chiller_r134a_emergent_pressure.json",
"metric": "iplv",
"points": [
{ "load_fraction": 1.00, "condenser_secondary_inlet_c": 29.4, "compressor_speed_hz": 50.0 },
{ "load_fraction": 0.75, "condenser_secondary_inlet_c": 23.9, "compressor_speed_hz": 38.0 },
{ "load_fraction": 0.50, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 26.0 },
{ "load_fraction": 0.25, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 14.0 }
]
}

View File

@@ -0,0 +1,8 @@
{
"base_config": "chiller_r134a_emergent_pressure.json",
"design_load_w": 10000.0,
"design_outdoor_c": -10.0,
"threshold_outdoor_c": 16.0,
"cd": 0.25,
"backup_cop": 1.0
}

View File

@@ -1,47 +0,0 @@
{
"name": "Chiller R410A - Single Circuit (Working)",
"description": "Circuit réfrigérant simple sans couplage thermique (fonctionne)",
"fluid": "R410A",
"circuits": [
{
"id": 0,
"name": "Circuit réfrigérant R410A",
"components": [
{
"type": "Placeholder",
"name": "comp",
"n_equations": 2
},
{
"type": "Placeholder",
"name": "cond",
"n_equations": 2
},
{
"type": "Placeholder",
"name": "exv",
"n_equations": 2
},
{
"type": "Placeholder",
"name": "evap",
"n_equations": 2
}
],
"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" }
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 100,
"tolerance": 1e-6
}
}

View File

@@ -1,30 +0,0 @@
{
"name": "Water loop with two real pumps",
"description": "Single circuit: two pumps in a loop (integration test for real Pump)",
"fluid": "Water",
"circuits": [
{
"id": 0,
"name": "Water loop",
"components": [
{
"type": "Pump",
"name": "pump1"
},
{
"type": "Pump",
"name": "pump2"
}
],
"edges": [
{ "from": "pump1:outlet", "to": "pump2:inlet" },
{ "from": "pump2:outlet", "to": "pump1:inlet" }
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 200,
"tolerance": 1e-6
}
}

View File

@@ -311,6 +311,9 @@ fn process_single_file(
state: None,
performance: None,
error: Some(e.to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
}
}
@@ -417,6 +420,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -427,6 +433,9 @@ mod tests {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -467,6 +476,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -477,6 +489,9 @@ mod tests {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -497,11 +512,17 @@ mod tests {
convergence: Some(crate::run::ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
iterations: Some(10),
strategy: None,
iteration_history: vec![],
}),
iterations: Some(10),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -512,6 +533,9 @@ mod tests {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -536,6 +560,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -546,6 +573,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 1000,
},
];
@@ -600,6 +630,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 100,
}],
};
@@ -620,6 +653,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -630,6 +666,9 @@ mod tests {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 60,
},
SimulationResult {
@@ -640,6 +679,9 @@ mod tests {
state: None,
performance: None,
error: Some("fail".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];

View File

@@ -3,14 +3,35 @@
//! This module defines the JSON schema for scenario configuration files
//! and provides utilities for loading and validating them.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::{CliError, CliResult};
/// The current Model IR schema version emitted and accepted by this build.
///
/// The IR is versioned so the schema (and any embedded standard/norm references)
/// can evolve without silently misreading older files. See [`SUPPORTED_SCHEMA_VERSIONS`].
pub const CURRENT_SCHEMA_VERSION: &str = "2";
/// Schema versions this build can load. `"1"` is the original flat
/// `circuits/components/edges` schema; `"2"` adds `controls`, `subsystems`,
/// `instances` and `connections`. Both are read by the same loader.
pub const SUPPORTED_SCHEMA_VERSIONS: &[&str] = &["1", "2"];
fn default_schema_version() -> String {
// Absent `schema_version` means a legacy v1 document.
"1".to_string()
}
/// Root configuration for a simulation scenario.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ScenarioConfig {
/// Model IR schema version (`"1"` legacy flat graph, `"2"` adds
/// controls/subsystems/instances/connections). Absent ⇒ `"1"`.
#[serde(default = "default_schema_version")]
pub schema_version: String,
/// Scenario name.
#[serde(default)]
pub name: Option<String>,
@@ -28,13 +49,190 @@ pub struct ScenarioConfig {
/// Solver configuration.
#[serde(default)]
pub solver: SolverConfig,
/// Optional solver start-value guesses. These are numerical initialization
/// hints only; they do not impose thermodynamic boundary conditions.
#[serde(default)]
pub initialization: Option<InitializationConfig>,
/// Steady-state control loops (co-solved saturated-PI controllers).
#[serde(default)]
pub controls: Vec<ControlConfig>,
/// Reusable subsystem templates (parameterized assemblies of components +
/// internal edges, exposing a reduced external port set). Flattened into
/// `circuits` at load time — the solver never sees the hierarchy.
#[serde(default)]
pub subsystems: HashMap<String, SubsystemTemplate>,
/// Instantiations of `subsystems` templates. Each instance is expanded into
/// concrete, name-prefixed components/edges in its target circuit.
#[serde(default)]
pub instances: Vec<InstanceConfig>,
/// External connections between instance ports (and/or literal `component:port`
/// endpoints). Resolved and routed into the appropriate circuit during flattening.
#[serde(default)]
pub connections: Vec<EdgeConfig>,
/// Optional metadata.
#[serde(default)]
pub metadata: Option<HashMap<String, serde_json::Value>>,
}
/// A reusable subsystem template: a parameterized assembly of components and
/// internal edges, exposing a reduced set of external ports.
///
/// This is Modelica-style `model` composition. Templates are declared once under
/// `subsystems` and instantiated any number of times via `instances`, each with
/// its own parameter overrides. At load time every instance is **flattened** into
/// the flat `circuits/components/edges` graph (component names prefixed by
/// `"{instance}."`), so the solver and every downstream consumer are unchanged.
///
/// # Parameter substitution
/// Any component parameter whose JSON value is a string of the form `"$name"` is
/// replaced by the resolved value of parameter `name` (instance override, falling
/// back to the template default in `params`). Because `ua`, `secondary_*`,
/// `isentropic_efficiency`, `t_cond_k`, … all flow through the component
/// `params` catch-all, this covers essentially every physical knob.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SubsystemTemplate {
/// Default parameter values, overridable per instance.
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
/// Components inside the template (names are local to the template).
pub components: Vec<ComponentConfig>,
/// Internal edges between the template's own components.
#[serde(default)]
pub edges: Vec<EdgeConfig>,
/// External ports: maps an exposed port name to an internal `"component:port"`
/// endpoint. `connections` reference these as `"{instance}.{external_port}"`.
#[serde(default)]
pub ports: HashMap<String, String>,
}
/// An instantiation of a [`SubsystemTemplate`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InstanceConfig {
/// Name of the template to instantiate (key in `subsystems`).
#[serde(rename = "of")]
pub template: String,
/// Unique instance name; becomes the `"{name}."` prefix on expanded components.
pub name: String,
/// Target circuit id the expanded components/edges are placed into (default 0).
#[serde(default)]
pub circuit: usize,
/// Parameter overrides for this instance (override template defaults).
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
}
/// A steady-state control loop declaration.
///
/// Currently supports the `SaturatedController` type: a saturated-PI loop with
/// anti-windup that is co-solved inside the Newton system. It drives a measured
/// plant output (`measure`) to `target` by manipulating an actuator factor
/// (`actuator`) within `[min, max]` bounds.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ControlConfig {
/// Controller type. Only `"SaturatedController"` is supported today.
#[serde(rename = "type", default = "default_control_type")]
pub control_type: String,
/// Unique identifier for this control loop.
pub id: String,
/// The measured plant output to regulate.
pub measure: MeasureConfig,
/// The manipulated actuator.
pub actuator: ActuatorConfig,
/// Target value for the measured output (SI units: W, K, Pa, kg/s).
pub target: f64,
/// Loop gain `K` (sign must match actuator→output sensitivity).
#[serde(default = "default_control_gain")]
pub gain: f64,
/// Saturation band half-width `Q > 0`.
#[serde(default = "default_control_band")]
pub band: f64,
/// If set, uses the C¹ smooth saturation with this `eps`; otherwise hard clamp.
#[serde(default)]
pub smooth_eps: Option<f64>,
/// Optional **override / selector** objectives. When non-empty, the primary
/// `measure`/`target`/`gain` above becomes the first objective and each entry
/// here is folded in via its `combine` (`min`/`max`) selector — one actuator
/// driven by several competing objectives (setpoint + envelope protections).
/// The fold order encodes priority (later = higher priority).
#[serde(default)]
pub objectives: Vec<ObjectiveConfig>,
/// Selector smoothing sharpness for the override network (`softMin`/`softMax`).
/// Smaller ⇒ sharper selection, larger ⇒ smoother/more robust. Default `1e-3`.
#[serde(default)]
pub alpha: Option<f64>,
}
/// One objective in an override/selector control network.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObjectiveConfig {
/// Name of the component whose output is measured.
pub component: String,
/// Output kind: one of `capacity`, `heatTransferRate`, `superheat`,
/// `subcooling`, `saturationTemperature`, `massFlowRate`, `pressure`,
/// `temperature`.
pub output: String,
/// Target value for this objective (SI units: W, K, Pa, kg/s).
pub setpoint: f64,
/// Normalization/sign gain for this objective's error `gain·(setpointmeasure)`.
#[serde(default = "default_control_gain")]
pub gain: f64,
/// Selector applied between the running accumulator and this objective:
/// `"min"` or `"max"`.
pub combine: String,
}
/// Reference to a measurable component output.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MeasureConfig {
/// Name of the component whose output is measured.
pub component: String,
/// Output kind: one of `capacity`, `heatTransferRate`, `superheat`,
/// `subcooling`, `saturationTemperature`, `massFlowRate`, `pressure`,
/// `temperature`.
pub output: String,
}
/// Reference to a manipulated actuator on a component.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ActuatorConfig {
/// Name of the component carrying the actuator.
pub component: String,
/// Calibration Z-factor to manipulate: `z_flow`, `z_dp`, `z_ua`, `z_power`, or `z_etav`
/// (legacy `f_*` names and BOLT `Z_*` spellings are also accepted).
pub factor: String,
/// Initial actuator value (nominal, e.g. 1.0).
#[serde(default = "default_actuator_initial")]
pub initial: f64,
/// Lower bound for the actuator.
pub min: f64,
/// Upper bound for the actuator.
pub max: f64,
}
fn default_control_type() -> String {
"SaturatedController".to_string()
}
fn default_control_gain() -> f64 {
1.0
}
fn default_control_band() -> f64 {
1.0
}
fn default_actuator_initial() -> f64 {
1.0
}
/// Thermal coupling configuration between two circuits.
#[derive(Debug, Clone, Serialize, Deserialize)]
///
/// A coupling is **physical** only when both `hot_component` and
/// `cold_component` are set: the hot component's measured duty (e.g. a real
/// ε-NTU `Condenser` capacity) is transferred, scaled by `efficiency` and
/// `duty_scale`, into the cold circuit's receiver energy balance. Without them the coupling
/// is an inert legacy stub (Q pinned to 0) and the CLI warns.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThermalCouplingConfig {
/// Hot circuit ID.
pub hot_circuit: usize,
@@ -45,18 +243,43 @@ pub struct ThermalCouplingConfig {
/// Heat exchanger efficiency (0.0 to 1.0).
#[serde(default = "default_efficiency")]
pub efficiency: f64,
/// Signed multiplier applied to the measured duty before injection.
///
/// Use `1.0` for condenser water heating and `-1.0` for chilled-water
/// cooling from an evaporator duty.
#[serde(default = "default_duty_scale")]
pub duty_scale: f64,
/// Name of the hot-circuit component whose measured duty is transferred
/// (e.g. a `Condenser` with a configured secondary stream).
#[serde(default)]
pub hot_component: Option<String>,
/// Name of the cold-circuit `ThermalLoad` component that receives the heat.
#[serde(default)]
pub cold_component: Option<String>,
}
fn default_efficiency() -> f64 {
0.95
}
fn default_duty_scale() -> f64 {
1.0
}
/// Configuration for a single circuit.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CircuitConfig {
/// Circuit ID (default: 0).
#[serde(default)]
pub id: usize,
/// Whether this circuit is energized (default: true). A disabled circuit is
/// staged OFF: its components, edges and any thermal coupling that references
/// it are excluded from the solve entirely (zero mass flow, zero duty), which
/// is the physically correct steady-state behaviour for an unenergized
/// refrigerant circuit. Used for multi-circuit part-load staging (e.g. running
/// only circuit A of a dual-circuit chiller).
#[serde(default = "default_true")]
pub enabled: bool,
/// Components in this circuit.
pub components: Vec<ComponentConfig>,
/// Edge connections between components.
@@ -67,8 +290,12 @@ pub struct CircuitConfig {
pub initial_state: Option<InitialStateConfig>,
}
fn default_true() -> bool {
true
}
/// Configuration for a component.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ComponentConfig {
/// Component type (e.g., "Compressor", "Condenser", "Evaporator", "ExpansionValve", "HeatExchanger").
#[serde(rename = "type")]
@@ -99,7 +326,7 @@ pub struct ComponentConfig {
}
/// Configuration for a condenser bank (multi-circuit, multi-coil).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CondenserBankConfig {
/// Number of circuits.
pub circuits: usize,
@@ -108,7 +335,7 @@ pub struct CondenserBankConfig {
}
/// Side conditions for a heat exchanger (hot or cold fluid).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SideConditionsConfig {
/// Fluid name (e.g., "R134a", "Water", "Air").
pub fluid: String,
@@ -131,7 +358,7 @@ fn default_mass_flow() -> f64 {
}
/// Compressor AHRI 540 coefficients configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Ahri540Config {
/// Flow coefficient M1.
pub m1: f64,
@@ -157,7 +384,7 @@ pub struct Ahri540Config {
}
/// Configuration for an edge between components.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EdgeConfig {
/// Source component and port (e.g., "comp1:outlet").
pub from: String,
@@ -166,7 +393,7 @@ pub struct EdgeConfig {
}
/// Initial state configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct InitialStateConfig {
/// Initial pressure in bar.
pub pressure_bar: Option<f64>,
@@ -176,10 +403,28 @@ pub struct InitialStateConfig {
pub temperature_k: Option<f64>,
}
/// Explicit numerical start-value guesses for system initialization.
///
/// These fields are intentionally top-level so design/start guesses do not look
/// like physical component inputs. Legacy component-level fields (`t_cond_k`,
/// `t_evap_k`, `superheat_k`) are still accepted as compatibility shims.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct InitializationConfig {
/// Initial condensing saturation temperature guess [K].
#[serde(default)]
pub t_cond_guess_k: Option<f64>,
/// Initial evaporating saturation temperature guess [K].
#[serde(default)]
pub t_evap_guess_k: Option<f64>,
/// Initial suction superheat guess [K].
#[serde(default)]
pub superheat_guess_k: Option<f64>,
}
/// Solver configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SolverConfig {
/// Solver strategy: "newton", "picard", or "fallback".
/// Solver strategy: "newton" or "picard".
#[serde(default = "default_solver_strategy")]
pub strategy: String,
/// Maximum iterations.
@@ -197,7 +442,7 @@ pub struct SolverConfig {
}
fn default_solver_strategy() -> String {
"fallback".to_string()
"newton".to_string()
}
fn default_max_iterations() -> usize {
@@ -233,6 +478,8 @@ impl ScenarioConfig {
let config: Self = serde_json::from_str(&content).map_err(CliError::InvalidConfig)?;
let mut config = config;
config.flatten_instances()?;
config.validate()?;
Ok(config)
@@ -241,12 +488,207 @@ impl ScenarioConfig {
/// Load a scenario configuration from a JSON string.
pub fn from_json(json: &str) -> CliResult<Self> {
let config: Self = serde_json::from_str(json).map_err(CliError::InvalidConfig)?;
let mut config = config;
config.flatten_instances()?;
config.validate()?;
Ok(config)
}
/// Emit the canonical Model IR JSON Schema (draft-07) as a pretty JSON string.
///
/// The schema is derived directly from the Rust `ScenarioConfig` structs via
/// `schemars`, so it can never drift from what the loader accepts. This is the
/// single source of truth the UI, external tooling, and future Python/WASM
/// bindings validate against. Exposed through the CLI `schema` command.
pub fn json_schema() -> String {
let schema = schemars::schema_for!(ScenarioConfig);
serde_json::to_string_pretty(&schema)
.unwrap_or_else(|e| format!("{{\"error\":\"failed to serialize schema: {e}\"}}"))
}
/// Flatten every [`InstanceConfig`] into concrete circuits.
///
/// Each instance's components are cloned with names prefixed by `"{instance}."`,
/// parameter references (`"$name"`) are substituted, internal edges are rewritten
/// with the prefixed names, and the result is merged into the target circuit.
/// External `connections` referencing `"{instance}.{external_port}"` are resolved
/// via each template's `ports` map and routed into the circuit of their source
/// endpoint. After this pass, `subsystems`/`instances`/`connections` are consumed
/// and the config is an ordinary flat `circuits` graph — the solver never sees the
/// hierarchy. A no-op when `instances` and `connections` are both empty.
pub fn flatten_instances(&mut self) -> CliResult<()> {
if self.instances.is_empty() && self.connections.is_empty() {
return Ok(());
}
// Expand each instance into its target circuit. Collect first (immutable
// borrow of templates), then apply (mutable borrow of circuits).
struct Expansion {
circuit: usize,
components: Vec<ComponentConfig>,
edges: Vec<EdgeConfig>,
}
let mut expansions = Vec::with_capacity(self.instances.len());
for inst in &self.instances {
let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
CliError::Config(format!(
"instance '{}' references unknown subsystem template '{}'. Available: {}",
inst.name,
inst.template,
self.subsystems
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
))
})?;
// Resolve params: template defaults overlaid by instance overrides.
let mut params = tmpl.params.clone();
for (k, v) in &inst.params {
params.insert(k.clone(), v.clone());
}
let prefix = format!("{}.", inst.name);
let mut new_components = Vec::with_capacity(tmpl.components.len());
for comp in &tmpl.components {
let mut c = comp.clone();
c.name = format!("{}{}", prefix, comp.name);
substitute_component_params(&mut c, &params).map_err(|missing| {
CliError::Config(format!(
"instance '{}' component '{}' references undefined parameter '${}'",
inst.name, comp.name, missing
))
})?;
new_components.push(c);
}
let mut new_edges = Vec::with_capacity(tmpl.edges.len());
for edge in &tmpl.edges {
new_edges.push(EdgeConfig {
from: prefix_internal_endpoint(&edge.from, &prefix),
to: prefix_internal_endpoint(&edge.to, &prefix),
});
}
expansions.push(Expansion {
circuit: inst.circuit,
components: new_components,
edges: new_edges,
});
}
for exp in expansions {
let circuit = self.circuit_mut(exp.circuit);
circuit.components.extend(exp.components);
circuit.edges.extend(exp.edges);
}
// Resolve external connections (instance port refs or literal component:port).
let connections = std::mem::take(&mut self.connections);
let mut resolved = Vec::with_capacity(connections.len());
for conn in &connections {
let (from_ep, from_circuit) = self.resolve_external_endpoint(&conn.from)?;
let (to_ep, _to_circuit) = self.resolve_external_endpoint(&conn.to)?;
resolved.push((from_circuit, from_ep, to_ep));
}
for (circuit, from, to) in resolved {
self.circuit_mut(circuit)
.edges
.push(EdgeConfig { from, to });
}
Ok(())
}
/// Get a mutable reference to the circuit with `id`, creating it if absent.
fn circuit_mut(&mut self, id: usize) -> &mut CircuitConfig {
if let Some(pos) = self.circuits.iter().position(|c| c.id == id) {
return &mut self.circuits[pos];
}
self.circuits.push(CircuitConfig {
id,
enabled: true,
components: Vec::new(),
edges: Vec::new(),
initial_state: None,
});
let last = self.circuits.len() - 1;
&mut self.circuits[last]
}
/// Resolve a connection endpoint to a concrete `"component:port"` string and the
/// circuit id it belongs to.
///
/// - A literal `"component:port"` endpoint is returned unchanged; its circuit is
/// inferred from the component name (falling back to circuit 0).
/// - An external port reference `"{instance}.{external_port}"` (no colon) is
/// resolved through the instance's template `ports` map into
/// `"{instance}.{internal_component}:{port}"`.
fn resolve_external_endpoint(&self, endpoint: &str) -> CliResult<(String, usize)> {
if endpoint.contains(':') {
let comp = endpoint.split(':').next().unwrap_or("");
let circuit = self.circuit_of_component(comp).unwrap_or(0);
return Ok((endpoint.to_string(), circuit));
}
let (inst_name, port_name) = endpoint.split_once('.').ok_or_else(|| {
CliError::Config(format!(
"connection endpoint '{}' must be 'component:port' or 'instance.external_port'",
endpoint
))
})?;
let inst = self
.instances
.iter()
.find(|i| i.name == inst_name)
.ok_or_else(|| {
CliError::Config(format!(
"connection endpoint '{}' references unknown instance '{}'",
endpoint, inst_name
))
})?;
let tmpl = self.subsystems.get(&inst.template).ok_or_else(|| {
CliError::Config(format!(
"instance '{}' references unknown subsystem template '{}'",
inst.name, inst.template
))
})?;
let internal = tmpl.ports.get(port_name).ok_or_else(|| {
CliError::Config(format!(
"instance '{}' (template '{}') exposes no external port '{}'. Available: {}",
inst_name,
inst.template,
port_name,
tmpl.ports.keys().cloned().collect::<Vec<_>>().join(", ")
))
})?;
Ok((format!("{}.{}", inst_name, internal), inst.circuit))
}
/// Find which circuit contains a component (by name).
fn circuit_of_component(&self, name: &str) -> Option<usize> {
self.circuits
.iter()
.find(|c| c.components.iter().any(|comp| comp.name == name))
.map(|c| c.id)
}
/// Validate the configuration.
pub fn validate(&self) -> CliResult<()> {
if !SUPPORTED_SCHEMA_VERSIONS.contains(&self.schema_version.as_str()) {
return Err(CliError::Config(format!(
"unsupported schema_version '{}'. This build supports: {}",
self.schema_version,
SUPPORTED_SCHEMA_VERSIONS.join(", ")
)));
}
if self.fluid.is_empty() {
return Err(CliError::Config("fluid field is required".to_string()));
}
@@ -305,6 +747,32 @@ impl ScenarioConfig {
}
}
/// Substitute `"$name"` parameter references in a component's `params` map with the
/// resolved parameter values. Returns `Err(name)` if a referenced parameter is
/// undefined. Values that are not `"$..."` strings are left untouched.
fn substitute_component_params(
comp: &mut ComponentConfig,
params: &HashMap<String, serde_json::Value>,
) -> Result<(), String> {
for value in comp.params.values_mut() {
if let Some(name) = value.as_str().and_then(|s| s.strip_prefix('$')) {
let resolved = params.get(name).ok_or_else(|| name.to_string())?.clone();
*value = resolved;
}
}
Ok(())
}
/// Prefix the component part of an internal `"component:port"` endpoint with `prefix`.
/// Endpoints without a `:` are prefixed wholesale (defensive; internal edges should
/// always use `component:port`).
fn prefix_internal_endpoint(endpoint: &str, prefix: &str) -> String {
match endpoint.split_once(':') {
Some((comp, port)) => format!("{}{}:{}", prefix, comp, port),
None => format!("{}{}", prefix, endpoint),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -325,6 +793,23 @@ mod tests {
assert_eq!(config.fluid_backend.as_deref(), Some("CoolProp"));
}
#[test]
fn test_parse_initialization_guesses() {
let json = r#"{
"fluid": "R134a",
"initialization": {
"t_cond_guess_k": 318.15,
"t_evap_guess_k": 278.15,
"superheat_guess_k": 7.0
}
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
let init = config.initialization.expect("initialization config");
assert_eq!(init.t_cond_guess_k, Some(318.15));
assert_eq!(init.t_evap_guess_k, Some(278.15));
assert_eq!(init.superheat_guess_k, Some(7.0));
}
#[test]
fn test_parse_full_config() {
let json = r#"
@@ -414,4 +899,234 @@ mod tests {
assert_eq!(bank.circuits, 2);
assert_eq!(bank.coils_per_circuit, 3);
}
// ---- arch-4: subsystem templates + instances (flattening) ----
const TWO_CIRCUIT_TEMPLATE: &str = r#"
{
"fluid": "R134a",
"subsystems": {
"SimpleCircuit": {
"params": { "ua_cond": 5000.0, "ua_evap": 6000.0, "t_sec_evap_c": 12.0 },
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.7 },
{ "type": "Condenser", "name": "cond", "ua": "$ua_cond", "subcooling_k": 5.0 },
{ "type": "IsenthalpicExpansionValve", "name": "exv" },
{ "type": "Evaporator", "name": "evap", "ua": "$ua_evap", "secondary_inlet_temp_c": "$t_sec_evap_c" }
],
"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" }
],
"ports": { "suction": "evap:outlet", "discharge": "comp:outlet" }
}
},
"instances": [
{ "of": "SimpleCircuit", "name": "A", "circuit": 0, "params": { "ua_cond": 7000.0 } },
{ "of": "SimpleCircuit", "name": "B", "circuit": 1, "params": { "t_sec_evap_c": 7.0 } }
]
}"#;
#[test]
fn test_flatten_instances_expands_and_prefixes() {
let config = ScenarioConfig::from_json(TWO_CIRCUIT_TEMPLATE).unwrap();
// Two instances -> two circuits, each with 4 prefixed components + 4 edges.
assert_eq!(config.circuits.len(), 2);
let circ_a = config.circuits.iter().find(|c| c.id == 0).unwrap();
let circ_b = config.circuits.iter().find(|c| c.id == 1).unwrap();
assert_eq!(circ_a.components.len(), 4);
assert_eq!(circ_a.edges.len(), 4);
assert_eq!(circ_b.components.len(), 4);
let names_a: Vec<&str> = circ_a.components.iter().map(|c| c.name.as_str()).collect();
assert!(names_a.contains(&"A.comp"));
assert!(names_a.contains(&"A.evap"));
let names_b: Vec<&str> = circ_b.components.iter().map(|c| c.name.as_str()).collect();
assert!(names_b.contains(&"B.comp"));
// Edges are rewritten with the instance prefix.
assert!(circ_a
.edges
.iter()
.any(|e| e.from == "A.comp:outlet" && e.to == "A.cond:inlet"));
}
#[test]
fn test_flatten_param_substitution_and_override() {
let config = ScenarioConfig::from_json(TWO_CIRCUIT_TEMPLATE).unwrap();
let circ_a = config.circuits.iter().find(|c| c.id == 0).unwrap();
let circ_b = config.circuits.iter().find(|c| c.id == 1).unwrap();
// Instance A overrides ua_cond -> 7000; ua_evap keeps template default 6000.
let cond_a = circ_a
.components
.iter()
.find(|c| c.name == "A.cond")
.unwrap();
assert_eq!(cond_a.params.get("ua").unwrap().as_f64(), Some(7000.0));
let evap_a = circ_a
.components
.iter()
.find(|c| c.name == "A.evap")
.unwrap();
assert_eq!(evap_a.params.get("ua").unwrap().as_f64(), Some(6000.0));
// Template default t_sec_evap_c 12.0 for A.
assert_eq!(
evap_a
.params
.get("secondary_inlet_temp_c")
.unwrap()
.as_f64(),
Some(12.0)
);
// Instance B overrides t_sec_evap_c -> 7.0, keeps ua_cond default 5000.
let cond_b = circ_b
.components
.iter()
.find(|c| c.name == "B.cond")
.unwrap();
assert_eq!(cond_b.params.get("ua").unwrap().as_f64(), Some(5000.0));
let evap_b = circ_b
.components
.iter()
.find(|c| c.name == "B.evap")
.unwrap();
assert_eq!(
evap_b
.params
.get("secondary_inlet_temp_c")
.unwrap()
.as_f64(),
Some(7.0)
);
}
#[test]
fn test_flatten_external_connection_resolves_port() {
// Two instances in the SAME circuit, wired together via external ports.
let json = r#"
{
"fluid": "R134a",
"subsystems": {
"Half": {
"components": [
{ "type": "IsentropicCompressor", "name": "comp", "isentropic_efficiency": 0.7 },
{ "type": "Condenser", "name": "cond", "ua": 5000.0 }
],
"edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ],
"ports": { "out": "cond:outlet", "in": "comp:inlet" }
}
},
"instances": [
{ "of": "Half", "name": "A", "circuit": 0 },
{ "of": "Half", "name": "B", "circuit": 0 }
],
"connections": [
{ "from": "A.out", "to": "B.in" }
]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.circuits.len(), 1);
let circ = &config.circuits[0];
assert_eq!(circ.components.len(), 4);
// A.out -> cond:outlet, B.in -> comp:inlet, both prefixed.
assert!(circ
.edges
.iter()
.any(|e| e.from == "A.cond:outlet" && e.to == "B.comp:inlet"));
}
#[test]
fn test_flatten_unknown_template_errors() {
let json = r#"
{
"fluid": "R134a",
"instances": [ { "of": "DoesNotExist", "name": "A" } ]
}"#;
let err = ScenarioConfig::from_json(json).unwrap_err();
assert!(format!("{err}").contains("unknown subsystem template"));
}
#[test]
fn test_flatten_undefined_param_errors() {
let json = r#"
{
"fluid": "R134a",
"subsystems": {
"T": {
"components": [ { "type": "Condenser", "name": "cond", "ua": "$missing" } ]
}
},
"instances": [ { "of": "T", "name": "A" } ]
}"#;
let err = ScenarioConfig::from_json(json).unwrap_err();
assert!(format!("{err}").contains("undefined parameter"));
}
#[test]
fn test_flatten_no_op_without_instances() {
// A plain config with no instances/connections is untouched.
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [{ "type": "Condenser", "name": "cond", "ua": 5000.0 }],
"edges": []
}]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.circuits.len(), 1);
assert_eq!(config.circuits[0].components.len(), 1);
assert_eq!(config.circuits[0].components[0].name, "cond");
}
// ---- arch-5: schema_version + unified IR schema ----
#[test]
fn test_schema_version_defaults_to_v1() {
let config = ScenarioConfig::from_json(r#"{ "fluid": "R134a" }"#).unwrap();
assert_eq!(config.schema_version, "1");
}
#[test]
fn test_schema_version_v2_accepted() {
let json = r#"{ "schema_version": "2", "fluid": "R134a" }"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.schema_version, "2");
}
#[test]
fn test_schema_version_unknown_rejected() {
let json = r#"{ "schema_version": "99", "fluid": "R134a" }"#;
let err = ScenarioConfig::from_json(json).unwrap_err();
assert!(format!("{err}").contains("unsupported schema_version"));
}
#[test]
fn test_json_schema_emits_ir_fields() {
let schema = ScenarioConfig::json_schema();
// The emitted schema must be the single source of truth covering every IR
// pillar: circuits (v1) + controls/subsystems/instances/connections (v2).
for field in [
"schema_version",
"circuits",
"controls",
"subsystems",
"instances",
"connections",
] {
assert!(
schema.contains(field),
"schema should document IR field '{field}'"
);
}
// Valid JSON, draft-07.
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert!(parsed.get("$schema").is_some());
}
}

View File

@@ -9,7 +9,10 @@
pub mod batch;
pub mod config;
pub mod error;
pub mod qualify;
pub mod rate;
pub mod run;
pub mod seasonal;
pub use batch::{BatchAggregator, BatchSummary, OutputFormat};
pub use config::ScenarioConfig;

View File

@@ -18,6 +18,7 @@ use tracing::Level;
use tracing_subscriber::EnvFilter;
use entropyk_cli::error::{CliError, ExitCode};
use entropyk_cli::seasonal::SeasonalMode;
#[derive(Parser)]
#[command(name = "entropyk-cli")]
@@ -79,6 +80,58 @@ enum Commands {
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
},
/// Qualify a single heat exchanger at a fixed refrigerant regime while
/// sweeping the secondary (air/water/brine) inlet temperature and flow.
Qualify {
/// Path to the JSON qualification configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Rate a full cycle across standardized part-load points and integrate the
/// seasonal efficiency metric (IPLV per AHRI 550/590 or ESEER per Eurovent).
Rate {
/// Path to the JSON rating configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Compute the seasonal heating performance (SCOP) by the EN 14825 bin method,
/// re-solving the coupled cycle at each climate temperature bin.
Scop {
/// Path to the JSON seasonal configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Compute the seasonal cooling performance (SEER) by the EN 14825 bin method,
/// re-solving the coupled cycle at each climate temperature bin.
Seer {
/// Path to the JSON seasonal configuration file
#[arg(short, long, value_name = "FILE")]
config: PathBuf,
/// Path to write the JSON report (default: table to stdout only)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
/// Emit the canonical Model IR JSON Schema (single source of truth for the
/// CLI, the web UI, and external tooling). Derived from the Rust config types.
Schema {
/// Path to write the JSON Schema (default: stdout)
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
},
}
fn main() {
@@ -119,6 +172,15 @@ fn main() {
cli.verbose,
),
Commands::Validate { config } => validate_config(config),
Commands::Qualify { config, output } => run_qualify(config, output, cli.quiet),
Commands::Rate { config, output } => run_rate(config, output, cli.quiet),
Commands::Scop { config, output } => {
run_seasonal(config, output, cli.quiet, SeasonalMode::Scop)
}
Commands::Seer { config, output } => {
run_seasonal(config, output, cli.quiet, SeasonalMode::Seer)
}
Commands::Schema { output } => emit_schema(output),
};
match result {
@@ -193,6 +255,16 @@ fn print_result(result: &entropyk_cli::run::SimulationResult) {
println!(" Time: {} ms", result.elapsed_ms);
if let Some(ref dof) = result.dof {
let tag = if dof.n_equations == dof.n_unknowns {
format!("{} = {}", dof.n_equations, dof.n_unknowns).green()
} else {
format!("{}{}", dof.n_equations, dof.n_unknowns).red()
};
println!(" DoF: {} eqs / {} unk [{}]", dof.n_equations, dof.n_unknowns, tag);
println!(" balance: {}", dof.balance);
}
if let Some(ref error) = result.error {
println!();
println!(" {} {}", "Error:".red(), error);
@@ -209,6 +281,23 @@ fn print_result(result: &entropyk_cli::run::SimulationResult) {
}
}
if let Some(ref perf) = result.performance {
println!();
println!(" {}", "Performance:".cyan());
if let Some(q) = perf.q_cooling_kw {
println!(" Cooling capacity: {:.3} kW", q);
}
if let Some(q) = perf.q_heating_kw {
println!(" Heating capacity: {:.3} kW", q);
}
if let Some(w) = perf.compressor_power_kw {
println!(" Power input: {:.3} kW", w);
}
if let Some(cop) = perf.cop {
println!(" COP / EER: {:.3}", cop);
}
}
println!("{}", "".repeat(40).white());
}
@@ -263,8 +352,239 @@ fn run_batch(
}
}
fn run_qualify(config: PathBuf, output: Option<PathBuf>, quiet: bool) -> Result<(), CliError> {
use entropyk_cli::qualify::run_qualify as run_qualify_impl;
if !quiet {
println!("{}", "".repeat(60).cyan());
println!(
"{}",
" ENTROPYK CLI - Heat Exchanger Qualification"
.cyan()
.bold()
);
println!("{}", "".repeat(60).cyan());
println!();
}
let report = run_qualify_impl(&config, output.as_deref())?;
if quiet {
if output.is_none() {
let json = serde_json::to_string(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
println!("{}", json);
}
return Ok(());
}
let kind_label = match report.exchanger.as_str() {
"evaporator" => "Evaporator (refrigerant absorbs heat)",
"condenser" => "Condenser (refrigerant rejects heat)",
other => other,
};
println!(" Exchanger: {}", kind_label.bold());
println!(" Refrigerant: {}", report.refrigerant);
println!(" UA: {:.0} W/K", report.ua_w_per_k);
println!(
" Regime: {:.3} bar (T_sat = {:.2} °C, held constant)",
report.refrigerant_pressure_bar, report.refrigerant_sat_temp_c
);
println!();
println!(
" {:>8} {:>10} {:>12} {:>8} {:>10} {:>10}",
"T_in[°C]", "C[W/K]", "Q[kW]", "ε[-]", "appr[K]", "T_out[°C]"
);
println!(" {}", "".repeat(66).white());
for row in &report.rows {
println!(
" {:>8.2} {:>10.0} {:>12.3} {:>8.3} {:>10.2} {:>10.2}",
row.secondary_inlet_temp_c,
row.secondary_capacity_rate_w_per_k,
row.q_w / 1000.0,
row.effectiveness,
row.approach_k,
row.secondary_outlet_temp_c,
);
}
println!(" {}", "".repeat(66).white());
if let Some(out) = output {
println!();
println!(" Report written to: {}", out.display());
}
Ok(())
}
fn run_rate(config: PathBuf, output: Option<PathBuf>, quiet: bool) -> Result<(), CliError> {
use entropyk_cli::rate::run_rate as run_rate_impl;
if !quiet {
println!("{}", "".repeat(60).cyan());
println!(
"{}",
" ENTROPYK CLI - Seasonal Rating (IPLV / ESEER)"
.cyan()
.bold()
);
println!("{}", "".repeat(60).cyan());
println!();
}
let report = run_rate_impl(&config, output.as_deref())?;
if quiet {
if output.is_none() {
let json = serde_json::to_string(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
println!("{}", json);
}
return Ok(());
}
println!(" Base config: {}", report.base_config);
println!(" Standard: {}", report.metric.bold());
if !report.standard_reference.is_empty() {
println!(" Reference: {}", report.standard_reference);
}
println!();
println!(
" {:>8} {:>12} {:>10} {:>10} {:>10}",
"Load[%]", "Status", "Q_cool[kW]", "Power[kW]", "EER[-]"
);
println!(" {}", "".repeat(58).white());
for row in &report.points {
let eer = row
.eer
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "".to_string());
let q = row
.q_cooling_kw
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "".to_string());
let p = row
.power_kw
.map(|v| format!("{:.3}", v))
.unwrap_or_else(|| "".to_string());
println!(
" {:>8.0} {:>12} {:>10} {:>10} {:>10}",
row.load_fraction * 100.0,
row.status,
q,
p,
eer,
);
}
println!(" {}", "".repeat(58).white());
println!();
match report.integrated_value {
Some(v) => println!(
" {} = {}",
report.metric.bold(),
format!("{:.3}", v).green().bold()
),
None => println!(
" {}",
"Integrated value unavailable (a part-load point did not converge)".yellow()
),
}
if let Some(out) = output {
println!();
println!(" Report written to: {}", out.display());
}
Ok(())
}
fn run_seasonal(
config: PathBuf,
output: Option<PathBuf>,
quiet: bool,
mode: SeasonalMode,
) -> Result<(), CliError> {
use entropyk_cli::seasonal::run_seasonal as run_seasonal_impl;
let title = match mode {
SeasonalMode::Scop => " ENTROPYK CLI - Seasonal Heating (SCOP, EN 14825)",
SeasonalMode::Seer => " ENTROPYK CLI - Seasonal Cooling (SEER, EN 14825)",
};
if !quiet {
println!("{}", "".repeat(60).cyan());
println!("{}", title.cyan().bold());
println!("{}", "".repeat(60).cyan());
println!();
}
let report = run_seasonal_impl(&config, output.as_deref(), mode)?;
if quiet {
if output.is_none() {
let json = serde_json::to_string(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
println!("{}", json);
}
return Ok(());
}
println!(" Base config: {}", report.base_config);
println!(" Climate: {}", report.climate.bold());
if !report.climate_reference.is_empty() {
println!(" Reference: {}", report.climate_reference);
}
println!(" Total hours: {:.0} h", report.total_hours);
println!();
println!(
" {:>8} {:>7} {:>11} {:>11} {:>8} {:>7} {:>8}",
"T[°C]", "h", "Demand[kW]", "Cap[kW]", "COP_full", "backup", "COP_eff"
);
println!(" {}", "".repeat(72).white());
for b in &report.bins {
let fmt = |v: Option<f64>, p: usize| {
v.map(|x| format!("{:.*}", p, x))
.unwrap_or_else(|| "".to_string())
};
println!(
" {:>8.1} {:>7.0} {:>11.3} {:>11} {:>8} {:>6.0}% {:>8}",
b.temperature_c,
b.hours,
b.demand_w / 1000.0,
fmt(b.capacity_w.map(|w| w / 1000.0), 3),
fmt(b.full_cop, 3),
b.backup_fraction * 100.0,
fmt(b.effective_cop, 3),
);
}
println!(" {}", "".repeat(72).white());
println!();
println!(
" Seasonal useful: {:.1} kWh Seasonal electric: {:.1} kWh",
report.seasonal_useful_kwh, report.seasonal_electric_kwh
);
match report.integrated_value {
Some(v) => println!(
" {} = {}",
report.metric.bold(),
format!("{:.3}", v).green().bold()
),
None => println!(
" {}",
"Seasonal value unavailable (a demanded bin did not converge)".yellow()
),
}
if let Some(out) = output {
println!();
println!(" Report written to: {}", out.display());
}
Ok(())
}
fn validate_config(config: PathBuf) -> Result<(), CliError> {
use entropyk_cli::config::ScenarioConfig;
use entropyk_cli::run::run_simulation;
println!("{}", "".repeat(60).cyan());
println!(
@@ -275,9 +595,85 @@ fn validate_config(config: PathBuf) -> Result<(), CliError> {
println!();
let _cfg = ScenarioConfig::from_file(&config)?;
println!(" {} Configuration is valid", "".green());
println!(" {} Schema / file parse OK", "".green());
println!(" File: {}", config.display());
// Build + finalize the system (and attempt solve) to surface DoF imbalance.
// Real-machine configs must be square: n_equations == n_unknowns.
println!();
println!(" {}", "DoF / topology check (build + solve):".cyan());
let result = run_simulation(&config, None, false)?;
if let Some(ref dof) = result.dof {
let ok = dof.n_equations == dof.n_unknowns;
if ok {
println!(
" {} DoF balanced: {} equations = {} unknowns",
"".green(),
dof.n_equations,
dof.n_unknowns
);
} else {
println!(
" {} DoF imbalance: {} equations vs {} unknowns ({})",
"".red(),
dof.n_equations,
dof.n_unknowns,
dof.balance
);
println!();
for line in dof.summary.lines() {
println!(" {}", line);
}
return Err(CliError::Simulation(format!(
"DoF imbalance: {} eqs vs {} unknowns",
dof.n_equations, dof.n_unknowns
)));
}
// Print compact component ledger
for line in dof.summary.lines().skip(1).take(24) {
println!(" {}", line);
}
}
match result.status {
entropyk_cli::run::SimulationStatus::Converged => {
println!(" {} Solver converged (config is executable)", "".green());
}
entropyk_cli::run::SimulationStatus::Error => {
if let Some(ref err) = result.error {
if err.contains("DoF imbalance") {
return Err(CliError::Simulation(err.clone()));
}
println!(
" {} Schema OK but solve failed: {}",
"!".yellow(),
err.lines().next().unwrap_or(err)
);
}
}
_ => {
println!(
" {} Schema/DoF OK but solver did not converge",
"!".yellow()
);
}
}
Ok(())
}
/// Emit the canonical Model IR JSON Schema, derived from the Rust config types.
fn emit_schema(output: Option<PathBuf>) -> Result<(), CliError> {
use entropyk_cli::config::ScenarioConfig;
let schema = ScenarioConfig::json_schema();
match output {
Some(path) => {
std::fs::write(&path, &schema).map_err(CliError::Io)?;
eprintln!("{} Schema written to {}", "".green(), path.display());
}
None => println!("{}", schema),
}
Ok(())
}

333
crates/cli/src/qualify.rs Normal file
View File

@@ -0,0 +1,333 @@
//! Heat-exchanger qualification harness.
//!
//! Qualifies a single heat exchanger (evaporator or condenser) at a **fixed
//! refrigerant regime** (constant evaporating/condensing pressure) while sweeping
//! the secondary (air/water/brine) inlet temperature and flow. Every output is
//! solved from the genuine ε-NTU coupled model — nothing is imposed.
//!
//! This is the entry point that lets the user "calculer les performances des
//! machines pour différentes conditions d'air et eau" without running a full
//! cycle: the refrigerant side is held constant, the secondary side varies, and
//! the resulting duty / effectiveness / approach respond physically.
//!
//! # Example configuration
//!
//! ```json
//! {
//! "exchanger": "evaporator",
//! "refrigerant": "R134a",
//! "fluid_backend": "Test",
//! "ua_w_per_k": 8000.0,
//! "refrigerant_pressure_bar": 3.5,
//! "sweep": {
//! "secondary_inlet_temp_c": [10.0, 12.0, 15.0],
//! "secondary_mass_flow_kg_s": [1.0, 1.5, 2.0],
//! "secondary_cp_j_per_kgk": 4180.0
//! }
//! }
//! ```
use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use entropyk_components::heat_exchanger::{Condenser, FloodedEvaporator};
use crate::error::{CliError, CliResult};
/// Which exchanger to qualify.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExchangerKind {
Evaporator,
Condenser,
}
impl ExchangerKind {
fn parse(s: &str) -> CliResult<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"evaporator" | "flooded_evaporator" | "evap" => Ok(ExchangerKind::Evaporator),
"condenser" | "cond" => Ok(ExchangerKind::Condenser),
other => Err(CliError::Config(format!(
"Unknown exchanger '{}'. Supported: 'evaporator', 'condenser'",
other
))),
}
}
}
/// Qualification configuration (JSON).
#[derive(Debug, Clone, Deserialize)]
pub struct QualifyConfig {
/// Exchanger to qualify: "evaporator" or "condenser".
pub exchanger: String,
/// Refrigerant fluid id (e.g. "R134a", "R410A").
pub refrigerant: String,
/// Fluid backend: "Test" (default) or "CoolProp".
#[serde(default)]
pub fluid_backend: Option<String>,
/// Overall conductance UA [W/K].
pub ua_w_per_k: f64,
/// Fixed refrigerant regime: evaporating/condensing pressure [bar].
pub refrigerant_pressure_bar: f64,
/// Secondary-stream sweep definition.
pub sweep: SweepConfig,
}
/// Secondary-stream sweep: cartesian product of inlet temperatures × capacity rates.
#[derive(Debug, Clone, Deserialize)]
pub struct SweepConfig {
/// Secondary inlet temperatures to sweep [°C].
pub secondary_inlet_temp_c: Vec<f64>,
/// Secondary heat-capacity rates ṁ·cp to sweep [W/K]. Mutually exclusive with
/// `secondary_mass_flow_kg_s` + `secondary_cp_j_per_kgk`.
#[serde(default)]
pub secondary_capacity_rate_w_per_k: Vec<f64>,
/// Secondary mass flows to sweep [kg/s] (combined with `secondary_cp_j_per_kgk`).
#[serde(default)]
pub secondary_mass_flow_kg_s: Vec<f64>,
/// Secondary specific heat [J/(kg·K)] used with `secondary_mass_flow_kg_s`.
#[serde(default)]
pub secondary_cp_j_per_kgk: Option<f64>,
}
/// A single qualification result row.
#[derive(Debug, Clone, Serialize)]
pub struct QualifyRow {
/// Secondary inlet temperature [°C].
pub secondary_inlet_temp_c: f64,
/// Secondary heat-capacity rate ṁ·cp [W/K].
pub secondary_capacity_rate_w_per_k: f64,
/// Heat duty [W] (>0 absorbed by evaporator, >0 rejected by condenser).
pub q_w: f64,
/// Effectiveness ε = 1 exp(UA/C_sec) [-].
pub effectiveness: f64,
/// Refrigerant saturation temperature [°C] (constant across the sweep).
pub refrigerant_sat_temp_c: f64,
/// Approach |T_sat T_sec,in| [K].
pub approach_k: f64,
/// Secondary outlet temperature [°C].
pub secondary_outlet_temp_c: f64,
}
/// Full qualification report.
#[derive(Debug, Clone, Serialize)]
pub struct QualifyReport {
/// Exchanger kind ("evaporator" / "condenser").
pub exchanger: String,
/// Refrigerant fluid id.
pub refrigerant: String,
/// Conductance UA [W/K].
pub ua_w_per_k: f64,
/// Fixed refrigerant pressure [bar].
pub refrigerant_pressure_bar: f64,
/// Refrigerant saturation temperature [°C] at the fixed regime.
pub refrigerant_sat_temp_c: f64,
/// One row per swept secondary condition.
pub rows: Vec<QualifyRow>,
}
fn build_backend(name: Option<&str>) -> CliResult<Arc<dyn entropyk_fluids::FluidBackend>> {
match name {
Some("CoolProp") => Ok(Arc::new(entropyk_fluids::CoolPropBackend::new())),
Some("Test") | None => Ok(Arc::new(entropyk_fluids::TestBackend::new())),
Some(other) => Err(CliError::Config(format!(
"Unknown fluid backend: '{}'. Supported: 'CoolProp', 'Test'",
other
))),
}
}
/// Resolves the list of secondary capacity rates [W/K] from the sweep config.
fn capacity_rates(sweep: &SweepConfig) -> CliResult<Vec<f64>> {
if !sweep.secondary_capacity_rate_w_per_k.is_empty() {
return Ok(sweep.secondary_capacity_rate_w_per_k.clone());
}
if !sweep.secondary_mass_flow_kg_s.is_empty() {
let cp = sweep.secondary_cp_j_per_kgk.ok_or_else(|| {
CliError::Config(
"sweep.secondary_cp_j_per_kgk is required when using secondary_mass_flow_kg_s"
.to_string(),
)
})?;
return Ok(sweep
.secondary_mass_flow_kg_s
.iter()
.map(|m| m * cp)
.collect());
}
Err(CliError::Config(
"sweep must define either 'secondary_capacity_rate_w_per_k' or 'secondary_mass_flow_kg_s'"
.to_string(),
))
}
/// Runs the qualification sweep and returns a structured report.
pub fn qualify(config: &QualifyConfig) -> CliResult<QualifyReport> {
let kind = ExchangerKind::parse(&config.exchanger)?;
let backend = build_backend(config.fluid_backend.as_deref())?;
let p_pa = config.refrigerant_pressure_bar * 1e5;
if config.sweep.secondary_inlet_temp_c.is_empty() {
return Err(CliError::Config(
"sweep.secondary_inlet_temp_c must contain at least one temperature".to_string(),
));
}
let rates = capacity_rates(&config.sweep)?;
let mut rows = Vec::new();
let mut sat_temp_c = f64::NAN;
for &t_c in &config.sweep.secondary_inlet_temp_c {
let t_k = t_c + 273.15;
for &c_sec in &rates {
let row = match kind {
ExchangerKind::Evaporator => {
let evap = FloodedEvaporator::new(config.ua_w_per_k)
.with_refrigerant(&config.refrigerant)
.with_fluid_backend(Arc::clone(&backend))
.with_secondary_stream(t_k, c_sec);
let r = evap.rate(p_pa).map_err(|e| {
CliError::Simulation(format!("Evaporator rating failed: {}", e))
})?;
sat_temp_c = r.t_evap_k - 273.15;
QualifyRow {
secondary_inlet_temp_c: t_c,
secondary_capacity_rate_w_per_k: c_sec,
q_w: r.q_w,
effectiveness: r.effectiveness,
refrigerant_sat_temp_c: r.t_evap_k - 273.15,
approach_k: r.approach_k.abs(),
secondary_outlet_temp_c: r.secondary_outlet_k - 273.15,
}
}
ExchangerKind::Condenser => {
let cond = Condenser::new(config.ua_w_per_k)
.with_refrigerant(&config.refrigerant)
.with_fluid_backend(Arc::clone(&backend))
.with_secondary_stream(t_k, c_sec);
let r = cond.rate(p_pa).map_err(|e| {
CliError::Simulation(format!("Condenser rating failed: {}", e))
})?;
sat_temp_c = r.t_cond_k - 273.15;
QualifyRow {
secondary_inlet_temp_c: t_c,
secondary_capacity_rate_w_per_k: c_sec,
q_w: r.q_w,
effectiveness: r.effectiveness,
refrigerant_sat_temp_c: r.t_cond_k - 273.15,
approach_k: r.approach_k.abs(),
secondary_outlet_temp_c: r.secondary_outlet_k - 273.15,
}
}
};
rows.push(row);
}
}
Ok(QualifyReport {
exchanger: match kind {
ExchangerKind::Evaporator => "evaporator".to_string(),
ExchangerKind::Condenser => "condenser".to_string(),
},
refrigerant: config.refrigerant.clone(),
ua_w_per_k: config.ua_w_per_k,
refrigerant_pressure_bar: config.refrigerant_pressure_bar,
refrigerant_sat_temp_c: sat_temp_c,
rows,
})
}
/// Loads a qualification config from a JSON file, runs the sweep, and optionally
/// writes the JSON report. Returns the report for further inspection.
pub fn run_qualify(config_path: &Path, output: Option<&Path>) -> CliResult<QualifyReport> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
})?;
let config: QualifyConfig = serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid qualify config: {}", e)))?;
let report = qualify(&config)?;
if let Some(out) = output {
let json = serde_json::to_string_pretty(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
std::fs::write(out, json)
.map_err(|e| CliError::Config(format!("Failed to write {}: {}", out.display(), e)))?;
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn base_config() -> QualifyConfig {
QualifyConfig {
exchanger: "evaporator".to_string(),
refrigerant: "R134a".to_string(),
fluid_backend: Some("Test".to_string()),
ua_w_per_k: 8000.0,
refrigerant_pressure_bar: 3.5,
sweep: SweepConfig {
secondary_inlet_temp_c: vec![10.0, 15.0],
secondary_capacity_rate_w_per_k: vec![],
secondary_mass_flow_kg_s: vec![1.0, 2.0],
secondary_cp_j_per_kgk: Some(4180.0),
},
}
}
#[test]
fn test_evaporator_sweep_produces_cartesian_grid() {
let cfg = base_config();
let report = qualify(&cfg).unwrap();
// 2 temperatures × 2 flows = 4 rows
assert_eq!(report.rows.len(), 4);
// Fixed refrigerant regime ⇒ constant saturation temperature.
for row in &report.rows {
assert!((row.refrigerant_sat_temp_c - report.refrigerant_sat_temp_c).abs() < 1e-9);
assert!(row.q_w > 0.0, "evaporator must absorb heat from warm water");
assert!(row.effectiveness > 0.0 && row.effectiveness < 1.0);
}
}
#[test]
fn test_warmer_water_increases_evaporator_duty() {
let cfg = base_config();
let report = qualify(&cfg).unwrap();
// Rows are ordered temp-major; compare same flow, two temperatures.
let cold = &report.rows[0]; // 10 °C, flow 1.0
let warm = &report.rows[2]; // 15 °C, flow 1.0
assert!(
(cold.secondary_capacity_rate_w_per_k - warm.secondary_capacity_rate_w_per_k).abs()
< 1e-9
);
assert!(
warm.q_w > cold.q_w,
"warmer secondary ⇒ more evaporator duty"
);
}
#[test]
fn test_condenser_sweep_rejects_heat() {
let mut cfg = base_config();
cfg.exchanger = "condenser".to_string();
cfg.refrigerant_pressure_bar = 12.0; // ~46 °C condensing for R134a
let report = qualify(&cfg).unwrap();
assert_eq!(report.rows.len(), 4);
for row in &report.rows {
assert!(row.q_w > 0.0, "condenser must reject heat to cooler water");
assert!(row.approach_k > 0.0);
}
}
#[test]
fn test_missing_secondary_definition_errors() {
let mut cfg = base_config();
cfg.sweep.secondary_mass_flow_kg_s = vec![];
cfg.sweep.secondary_capacity_rate_w_per_k = vec![];
assert!(qualify(&cfg).is_err());
}
}

643
crates/cli/src/rate.rs Normal file
View File

@@ -0,0 +1,643 @@
//! Standardized part-load / seasonal rating harness.
//!
//! Re-solves a full cycle configuration at each standardized part-load point and
//! aggregates the resulting efficiencies into the seasonal metrics used to
//! *qualify* chillers and heat pumps — **IPLV/NPLV** (AHRI 550/590) and **ESEER**
//! (Eurovent). Unlike [`crate::qualify`], which holds the refrigerant regime
//! fixed and sweeps a single heat exchanger, `rate` runs the genuine coupled
//! cycle at every load point: the condensing/evaporating pressures, capacity and
//! power all emerge from the heat-exchanger ↔ secondary balance, so the reported
//! EERs — and hence the integrated value — are real simulation output, not
//! imposed design points.
//!
//! # How a part-load point is defined
//!
//! Each of the four points (100 / 75 / 50 / 25 % load) overrides a handful of
//! operating conditions on the *base* configuration:
//!
//! - `condenser_secondary_inlet_c` — condenser entering water/air temperature
//! (the AHRI/Eurovent condenser schedule; warmer at full load).
//! - `evaporator_secondary_inlet_c` — evaporator entering fluid temperature.
//! - `condenser_secondary_mass_flow_kg_s` / `evaporator_secondary_mass_flow_kg_s`
//! — secondary flows, if they vary with load.
//! - `compressor_speed_hz` — compressor unloading (a variable-speed drive turns
//! the speed down to shed capacity). Combined with a `vsd_reference_speed_hz`
//! map on the compressor this genuinely degrades part-load efficiency.
//!
//! Everything not overridden is inherited from the base config, so the four
//! points share the same machine and only the operating envelope changes.
//!
//! # Example configuration
//!
//! ```json
//! {
//! "base_config": "chiller_r134a_emergent_pressure.json",
//! "metric": "iplv",
//! "points": [
//! { "load_fraction": 1.00, "condenser_secondary_inlet_c": 29.4, "compressor_speed_hz": 50.0 },
//! { "load_fraction": 0.75, "condenser_secondary_inlet_c": 24.4, "compressor_speed_hz": 38.0 },
//! { "load_fraction": 0.50, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 26.0 },
//! { "load_fraction": 0.25, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 14.0 }
//! ]
//! }
//! ```
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use entropyk::rating::PartLoadStandard;
use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
use crate::run::{simulate_from_json, SimulationStatus};
/// Which built-in seasonal weighting standard to integrate the part-load EERs
/// into, when no explicit custom standard is supplied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RateMetric {
/// Integrated Part Load Value — AHRI 550/590 weighting (default).
#[default]
Iplv,
/// European Seasonal Energy Efficiency Ratio — Eurovent weighting.
Eseer,
}
impl RateMetric {
/// The corresponding built-in [`PartLoadStandard`].
fn standard(self) -> PartLoadStandard {
match self {
RateMetric::Iplv => PartLoadStandard::ahri_550_590_iplv(),
RateMetric::Eseer => PartLoadStandard::eurovent_eseer(),
}
}
}
/// A single standardized part-load point: a set of operating overrides applied
/// to the base configuration before re-solving.
#[derive(Debug, Clone, Deserialize)]
pub struct RatePoint {
/// Fraction of full load this point represents (1.0 / 0.75 / 0.50 / 0.25).
/// Used to order the points and to weight the seasonal metric.
pub load_fraction: f64,
/// Condenser secondary (water/air) entering temperature [°C].
#[serde(default)]
pub condenser_secondary_inlet_c: Option<f64>,
/// Evaporator secondary (water/brine) entering temperature [°C].
#[serde(default)]
pub evaporator_secondary_inlet_c: Option<f64>,
/// Condenser secondary mass flow [kg/s].
#[serde(default)]
pub condenser_secondary_mass_flow_kg_s: Option<f64>,
/// Evaporator secondary mass flow [kg/s].
#[serde(default)]
pub evaporator_secondary_mass_flow_kg_s: Option<f64>,
/// Compressor rotational speed [Hz] (unloading via a variable-speed drive).
#[serde(default)]
pub compressor_speed_hz: Option<f64>,
}
/// Rating configuration (JSON).
///
/// The seasonal weighting standard is selected — in order of precedence — by an
/// inline [`standard`](Self::standard) object, a [`standard_file`](Self::standard_file)
/// path (a JSON [`PartLoadStandard`]), a [`standard_name`](Self::standard_name)
/// built-in id (`"iplv"`, `"nplv"`, `"eseer"`), or finally the
/// [`metric`](Self::metric) enum (default IPLV). Supplying a custom standard lets
/// you track a revised norm without recompiling.
#[derive(Debug, Clone, Deserialize)]
pub struct RateConfig {
/// Path to the base cycle configuration (a `run` scenario file). Resolved
/// relative to the rating config file's directory when not absolute.
pub base_config: PathBuf,
/// Built-in seasonal metric to compute: `"iplv"` (default) or `"eseer"`.
/// Ignored when a more specific standard selector below is present.
#[serde(default)]
pub metric: RateMetric,
/// Built-in standard id (e.g. `"iplv"`, `"nplv"`, `"eseer"`). Overrides
/// [`metric`](Self::metric) when set.
#[serde(default)]
pub standard_name: Option<String>,
/// Path to a custom [`PartLoadStandard`] JSON file, resolved relative to this
/// config's directory. Overrides [`standard_name`](Self::standard_name).
#[serde(default)]
pub standard_file: Option<PathBuf>,
/// Inline custom [`PartLoadStandard`]. Highest precedence.
#[serde(default)]
pub standard: Option<PartLoadStandard>,
/// The standardized part-load points (typically four: 100/75/50/25 %).
pub points: Vec<RatePoint>,
}
impl RateConfig {
/// Resolve the effective seasonal weighting standard, honouring the
/// precedence `standard` > `standard_file` > `standard_name` > `metric`.
///
/// `config_dir` is used to resolve a relative `standard_file` path.
pub fn resolve_standard(&self, config_dir: Option<&Path>) -> CliResult<PartLoadStandard> {
let standard = if let Some(inline) = &self.standard {
inline.clone()
} else if let Some(file) = &self.standard_file {
let path = if file.is_absolute() {
file.clone()
} else {
config_dir
.map(|d| d.join(file))
.unwrap_or_else(|| file.clone())
};
let raw = std::fs::read_to_string(&path).map_err(|e| {
CliError::Config(format!(
"Failed to read standard file {}: {}",
path.display(),
e
))
})?;
serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid standard file: {}", e)))?
} else if let Some(name) = &self.standard_name {
PartLoadStandard::builtin(name).ok_or_else(|| {
CliError::Config(format!(
"Unknown built-in standard '{}'. Known ids: {}",
name,
PartLoadStandard::builtin_ids().join(", ")
))
})?
} else {
self.metric.standard()
};
standard
.validate()
.map_err(|e| CliError::Config(format!("Invalid rating standard: {}", e)))?;
Ok(standard)
}
}
/// Result of re-solving one part-load point.
#[derive(Debug, Clone, Serialize)]
pub struct RatePointResult {
/// Fraction of full load.
pub load_fraction: f64,
/// Solver status string ("converged", "non_converged", "error", ...).
pub status: String,
/// Energy efficiency ratio (cooling COP) at this point, if converged.
pub eer: Option<f64>,
/// Cooling capacity [kW], if available.
pub q_cooling_kw: Option<f64>,
/// Power input [kW], if available.
pub power_kw: Option<f64>,
/// Error message when the point failed to solve.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Full rating report.
#[derive(Debug, Clone, Serialize)]
pub struct RateReport {
/// Base configuration path used for every point.
pub base_config: String,
/// Name of the seasonal standard applied (e.g. "AHRI 550/590 IPLV").
pub metric: String,
/// Citation / provenance of the standard's coefficients.
pub standard_reference: String,
/// One result row per part-load point (ordered by descending load fraction).
pub points: Vec<RatePointResult>,
/// Integrated seasonal value, `None` if a required load point failed.
pub integrated_value: Option<f64>,
}
/// Applies a part-load point's overrides to a clone of the base configuration.
///
/// The overrides are written into the flattened `params` map of every matching
/// component so that [`crate::run`] picks them up exactly as if the user had
/// authored them in the JSON. Overriding *all* condensers/evaporators/compressors
/// keeps single-circuit chillers (the common case) simple while remaining
/// well-defined for multi-instance machines.
fn apply_overrides(base: &ScenarioConfig, point: &RatePoint) -> ScenarioConfig {
let mut config = base.clone();
for circuit in &mut config.circuits {
for comp in &mut circuit.components {
match comp.component_type.as_str() {
"BrineSource" | "AirSource" => {
let name_lower = comp.name.to_lowercase();
let is_condenser = name_lower.contains("cond");
let is_evaporator = name_lower.contains("evap");
let temp_key = if comp.component_type == "BrineSource" {
"t_set_c"
} else {
"t_dry_c"
};
if is_condenser {
if let Some(t) = point.condenser_secondary_inlet_c {
comp.params.insert(temp_key.into(), json_num(t));
}
if let Some(m) = point.condenser_secondary_mass_flow_kg_s {
comp.params.insert("m_flow_kg_s".into(), json_num(m));
}
}
if is_evaporator {
if let Some(t) = point.evaporator_secondary_inlet_c {
comp.params.insert(temp_key.into(), json_num(t));
}
if let Some(m) = point.evaporator_secondary_mass_flow_kg_s {
comp.params.insert("m_flow_kg_s".into(), json_num(m));
}
}
}
"IsentropicCompressor" | "Compressor" => {
if let Some(hz) = point.compressor_speed_hz {
comp.params.insert("speed_hz".into(), json_num(hz));
}
}
_ => {}
}
}
}
config
}
/// Builds a JSON number value, falling back to null for non-finite inputs.
fn json_num(x: f64) -> serde_json::Value {
serde_json::Number::from_f64(x)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}
/// Re-solves a single part-load point and extracts its EER.
fn solve_point(base: &ScenarioConfig, point: &RatePoint) -> RatePointResult {
let config = apply_overrides(base, point);
let json = match serde_json::to_string(&config) {
Ok(j) => j,
Err(e) => {
return RatePointResult {
load_fraction: point.load_fraction,
status: "error".to_string(),
eer: None,
q_cooling_kw: None,
power_kw: None,
error: Some(format!("Failed to serialize overridden config: {}", e)),
}
}
};
match simulate_from_json(&json) {
Ok(result) => {
let status = status_label(&result.status);
let perf = result.performance.as_ref();
RatePointResult {
load_fraction: point.load_fraction,
status,
eer: perf.and_then(|p| p.cop),
q_cooling_kw: perf.and_then(|p| p.q_cooling_kw),
power_kw: perf.and_then(|p| p.compressor_power_kw),
error: result.error,
}
}
Err(e) => RatePointResult {
load_fraction: point.load_fraction,
status: "error".to_string(),
eer: None,
q_cooling_kw: None,
power_kw: None,
error: Some(format!("{}", e)),
},
}
}
fn status_label(status: &SimulationStatus) -> String {
match status {
SimulationStatus::Converged => "converged",
SimulationStatus::Timeout => "timeout",
SimulationStatus::NonConverged => "non_converged",
SimulationStatus::Error => "error",
}
.to_string()
}
/// Tolerance for matching a part-load point to a standard's load fraction.
const LOAD_FRACTION_MATCH_TOL: f64 = 0.02;
/// Integrates the part-load EERs into the seasonal value defined by `standard`.
///
/// The standard drives everything: for each of its `load_fractions` (in order)
/// the nearest part-load point (within [`LOAD_FRACTION_MATCH_TOL`]) supplies the
/// EER, and the weighted sum is formed with the standard's `weights`. Returns
/// `None` unless every load point of the standard is matched by a converged point
/// with a finite EER — so a revised standard with a different number of points or
/// different fractions works with no code change, provided the config supplies
/// matching points.
fn integrate(standard: &PartLoadStandard, points: &[RatePointResult]) -> Option<f64> {
let mut efficiencies = Vec::with_capacity(standard.load_fractions.len());
for &target in &standard.load_fractions {
let best = points
.iter()
.filter(|p| p.eer.map(|v| v.is_finite()).unwrap_or(false))
.min_by(|a, b| {
(a.load_fraction - target)
.abs()
.partial_cmp(&(b.load_fraction - target).abs())
.unwrap_or(std::cmp::Ordering::Equal)
})?;
if (best.load_fraction - target).abs() > LOAD_FRACTION_MATCH_TOL {
return None;
}
efficiencies.push(best.eer?);
}
standard.integrate(&efficiencies).ok()
}
/// Runs the full rating: re-solves every part-load point (in parallel) and
/// aggregates the seasonal metric defined by `standard`.
pub fn rate(
config: &RateConfig,
base: &ScenarioConfig,
standard: &PartLoadStandard,
) -> CliResult<RateReport> {
if config.points.is_empty() {
return Err(CliError::Config(
"rate config must define at least one part-load point".to_string(),
));
}
// Each point is an independent solve → evaluate them in parallel.
let mut points: Vec<RatePointResult> = config
.points
.par_iter()
.map(|p| solve_point(base, p))
.collect();
points.sort_by(|a, b| {
b.load_fraction
.partial_cmp(&a.load_fraction)
.unwrap_or(std::cmp::Ordering::Equal)
});
let integrated_value = integrate(standard, &points);
Ok(RateReport {
base_config: config.base_config.display().to_string(),
metric: standard.name.clone(),
standard_reference: standard.reference.clone(),
points,
integrated_value,
})
}
/// Loads a rating config from a JSON file, resolves and loads the referenced base
/// scenario, resolves the seasonal standard, runs the rating, and optionally
/// writes the JSON report.
pub fn run_rate(config_path: &Path, output: Option<&Path>) -> CliResult<RateReport> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
})?;
let config: RateConfig = serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid rate config: {}", e)))?;
let config_dir = config_path.parent();
// Resolve base_config relative to the rating file's directory when needed.
let base_path = if config.base_config.is_absolute() {
config.base_config.clone()
} else {
config_dir
.map(|d| d.join(&config.base_config))
.unwrap_or_else(|| config.base_config.clone())
};
let base = ScenarioConfig::from_file(&base_path)?;
let standard = config.resolve_standard(config_dir)?;
let report = rate(&config, &base, &standard)?;
if let Some(out) = output {
let json = serde_json::to_string_pretty(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
std::fs::write(out, json)
.map_err(|e| CliError::Config(format!("Failed to write {}: {}", out.display(), e)))?;
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn base_config() -> ScenarioConfig {
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [
{ "type": "Condenser", "name": "cond", "ua": 766.0, "secondary_fluid": "Water" },
{ "type": "Evaporator", "name": "evap", "ua": 1468.0, "secondary_fluid": "Water" },
{ "type": "IsentropicCompressor", "name": "comp",
"isentropic_efficiency": 0.7, "t_cond_k": 318.15, "t_evap_k": 278.15,
"superheat_k": 5.0, "speed_hz": 50.0 },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.36 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.48 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": []
}],
"solver": { "strategy": "fallback", "max_iterations": 200, "tolerance": 1e-6 }
}"#;
ScenarioConfig::from_json(json).unwrap()
}
#[test]
fn test_apply_overrides_sets_component_params() {
let base = base_config();
let point = RatePoint {
load_fraction: 0.5,
condenser_secondary_inlet_c: Some(18.3),
evaporator_secondary_inlet_c: Some(10.0),
condenser_secondary_mass_flow_kg_s: None,
evaporator_secondary_mass_flow_kg_s: None,
compressor_speed_hz: Some(25.0),
};
let cfg = apply_overrides(&base, &point);
let comps = &cfg.circuits[0].components;
let cond_src = comps.iter().find(|c| c.name == "cond_water_in").unwrap();
let evap_src = comps.iter().find(|c| c.name == "evap_water_in").unwrap();
let comp = comps
.iter()
.find(|c| c.component_type == "IsentropicCompressor")
.unwrap();
assert_eq!(cond_src.params.get("t_set_c").unwrap().as_f64(), Some(18.3));
assert_eq!(evap_src.params.get("t_set_c").unwrap().as_f64(), Some(10.0));
assert_eq!(comp.params.get("speed_hz").unwrap().as_f64(), Some(25.0));
// Untouched fields keep their base value.
assert_eq!(
cond_src.params.get("m_flow_kg_s").unwrap().as_f64(),
Some(0.36)
);
}
#[test]
fn test_apply_overrides_does_not_mutate_base() {
let base = base_config();
let point = RatePoint {
load_fraction: 1.0,
condenser_secondary_inlet_c: Some(40.0),
evaporator_secondary_inlet_c: None,
condenser_secondary_mass_flow_kg_s: None,
evaporator_secondary_mass_flow_kg_s: None,
compressor_speed_hz: None,
};
let _ = apply_overrides(&base, &point);
let cond_src = base.circuits[0]
.components
.iter()
.find(|c| c.name == "cond_water_in")
.unwrap();
assert_eq!(
cond_src.params.get("t_set_c").unwrap().as_f64(),
Some(30.0),
"base config must be left unmodified"
);
}
#[test]
fn test_integrate_iplv_matches_weighted_formula() {
let points = vec![
mk_point(1.00, 4.0),
mk_point(0.75, 5.0),
mk_point(0.50, 6.0),
mk_point(0.25, 5.5),
];
let std = PartLoadStandard::ahri_550_590_iplv();
let value = integrate(&std, &points).unwrap();
let expected = 0.01 * 4.0 + 0.42 * 5.0 + 0.45 * 6.0 + 0.12 * 5.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_eseer_uses_eurovent_weights() {
let points = vec![
mk_point(1.00, 3.0),
mk_point(0.75, 4.0),
mk_point(0.50, 5.0),
mk_point(0.25, 4.5),
];
let std = PartLoadStandard::eurovent_eseer();
let value = integrate(&std, &points).unwrap();
let expected = 0.03 * 3.0 + 0.33 * 4.0 + 0.41 * 5.0 + 0.23 * 4.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_orders_points_by_load_fraction() {
// Points supplied out of order must still map correctly to 100/75/50/25.
let points = vec![
mk_point(0.25, 5.5),
mk_point(1.00, 4.0),
mk_point(0.50, 6.0),
mk_point(0.75, 5.0),
];
let std = PartLoadStandard::ahri_550_590_iplv();
let value = integrate(&std, &points).unwrap();
let expected = 0.01 * 4.0 + 0.42 * 5.0 + 0.45 * 6.0 + 0.12 * 5.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_with_custom_standard() {
// A custom standard (different fractions/weights) drives the aggregation
// with no code change — matched to the supplied points by nearest fraction.
let std = PartLoadStandard::new(
"Custom SEER",
"illustrative",
vec![1.0, 0.74, 0.47, 0.21],
vec![0.03, 0.27, 0.41, 0.29],
)
.unwrap();
let points = vec![
mk_point(1.00, 3.0),
mk_point(0.74, 4.0),
mk_point(0.47, 5.0),
mk_point(0.21, 4.5),
];
let value = integrate(&std, &points).unwrap();
let expected = 0.03 * 3.0 + 0.27 * 4.0 + 0.41 * 5.0 + 0.29 * 4.5;
assert!((value - expected).abs() < 1e-12);
}
#[test]
fn test_integrate_none_when_point_missing_or_failed() {
let std = PartLoadStandard::ahri_550_590_iplv();
// Only three points → a standard fraction (0.25) has no match within tol.
let three = vec![mk_point(1.0, 4.0), mk_point(0.75, 5.0), mk_point(0.5, 6.0)];
assert!(integrate(&std, &three).is_none());
// Four points but one failed to produce an EER.
let mut four = vec![
mk_point(1.0, 4.0),
mk_point(0.75, 5.0),
mk_point(0.5, 6.0),
mk_point(0.25, 5.5),
];
four[2].eer = None;
assert!(integrate(&std, &four).is_none());
}
#[test]
fn test_resolve_standard_precedence() {
// Inline standard wins over everything.
let inline = PartLoadStandard::new("inline", "", vec![1.0, 0.5], vec![0.5, 0.5]).unwrap();
let cfg = RateConfig {
base_config: PathBuf::from("x.json"),
metric: RateMetric::Eseer,
standard_name: Some("eseer".to_string()),
standard_file: None,
standard: Some(inline.clone()),
points: vec![],
};
assert_eq!(cfg.resolve_standard(None).unwrap().name, "inline");
// standard_name selects a built-in over the metric enum.
let cfg2 = RateConfig {
standard: None,
standard_name: Some("eseer".to_string()),
metric: RateMetric::Iplv,
..cfg.clone()
};
assert_eq!(
cfg2.resolve_standard(None).unwrap().weights,
entropyk::rating::ESEER_WEIGHTS.to_vec()
);
// Falls back to the metric enum (default IPLV) when nothing else is set.
let cfg3 = RateConfig {
standard: None,
standard_name: None,
metric: RateMetric::Iplv,
..cfg.clone()
};
assert_eq!(
cfg3.resolve_standard(None).unwrap().weights,
entropyk::rating::IPLV_WEIGHTS.to_vec()
);
// Unknown built-in id is a clear error.
let cfg4 = RateConfig {
standard: None,
standard_name: Some("nope".to_string()),
..cfg
};
assert!(cfg4.resolve_standard(None).is_err());
}
fn mk_point(load: f64, eer: f64) -> RatePointResult {
RatePointResult {
load_fraction: load,
status: "converged".to_string(),
eer: Some(eer),
q_cooling_kw: Some(eer * 2.0),
power_kw: Some(2.0),
error: None,
}
}
}

File diff suppressed because it is too large Load Diff

690
crates/cli/src/seasonal.rs Normal file
View File

@@ -0,0 +1,690 @@
//! Seasonal bin-method ratings — **SCOP** (heating) and **SEER** (cooling).
//!
//! Implements the EN 14825 temperature-bin method on top of the genuinely coupled
//! cycle solve. For each temperature bin of a climate, the machine is re-solved
//! with its *outdoor* heat-exchanger secondary inlet driven to the bin
//! temperature, yielding a real full-capacity duty and COP at that condition. A
//! linear building load line gives the demand at each bin; part-load cycling
//! degradation and (for heating) an electric backup heater are applied; and the
//! bins are aggregated into the seasonal metric
//!
//! ```text
//! SCOP or SEER = Σ (hoursⱼ · demandⱼ) / Σ (hoursⱼ · demandⱼ / COPⱼ)
//! ```
//!
//! Nothing is imposed: every bin's efficiency emerges from the coupled
//! heat-exchanger ↔ secondary balance. The climate (bin table), the building load
//! line and the degradation/backup coefficients are all data, so a revised norm or
//! a different climate is a config change, not a code change.
//!
//! # Physics summary (per bin `j` at outdoor temperature `Tⱼ`)
//!
//! 1. **Demand** from the linear building load line
//! (heating: `demand = design_load · (T_threshold Tⱼ)/(T_threshold T_design)`;
//! cooling: `demand = design_load · (Tⱼ T_threshold)/(T_design T_threshold)`),
//! clamped to `≥ 0`.
//! 2. **Full-capacity solve** with the outdoor secondary inlet set to `Tⱼ` gives
//! the machine's useful duty `Q_full` and compressor power `W_full`, hence
//! `COP_full = Q_full / W_full`.
//! 3. **Capacity ratio** `CR = demand / Q_full`.
//! - `CR ≤ 1`: the machine modulates/cycles to match demand. Cycling losses are
//! captured by a part-load factor `PLF = 1 Cd·(1 CR)`, giving
//! `COP_bin = COP_full · PLF`.
//! - `CR > 1` (heating only): the machine runs full and an electric backup of
//! efficiency `backup_cop` covers the deficit, so
//! `COP_bin = demand / (W_full + (demand Q_full)/backup_cop)`.
//! For cooling the load is capped at capacity (`CR = 1`, no backup).
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use entropyk::rating::{scop, BinClimateStandard, BinPerformance};
use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
use crate::run::{simulate_from_json, SimulationStatus};
/// Which seasonal bin metric to compute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeasonalMode {
/// Seasonal Coefficient Of Performance (heating). Useful duty = heating.
Scop,
/// Seasonal Energy Efficiency Ratio (cooling). Useful duty = cooling.
Seer,
}
impl SeasonalMode {
fn label(self) -> &'static str {
match self {
SeasonalMode::Scop => "SCOP",
SeasonalMode::Seer => "SEER",
}
}
/// The heat-exchanger side whose secondary inlet is driven by the outdoor bin
/// temperature, by default (heat pump: outdoor = evaporator; chiller: outdoor
/// = condenser).
fn default_outdoor_side(self) -> OutdoorSide {
match self {
SeasonalMode::Scop => OutdoorSide::Evaporator,
SeasonalMode::Seer => OutdoorSide::Condenser,
}
}
}
/// Which heat exchanger the outdoor (ambient) temperature drives.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutdoorSide {
/// The evaporator secondary inlet follows the outdoor temperature.
Evaporator,
/// The condenser secondary inlet follows the outdoor temperature.
Condenser,
}
impl OutdoorSide {
/// Component types matched when applying the outdoor temperature override.
#[allow(dead_code)]
fn matches(self, component_type: &str) -> bool {
match self {
OutdoorSide::Evaporator => {
matches!(component_type, "Evaporator" | "FloodedEvaporator")
}
OutdoorSide::Condenser => component_type == "Condenser",
}
}
}
/// Seasonal rating configuration (JSON).
///
/// The climate (bin table) is selected — in order of precedence — by an inline
/// [`climate`](Self::climate) object, a [`climate_file`](Self::climate_file) path,
/// a [`climate_name`](Self::climate_name) built-in id, or (for SCOP) the default
/// EN 14825 average heating season.
#[derive(Debug, Clone, Deserialize)]
pub struct SeasonalConfig {
/// Path to the base cycle configuration (a `run` scenario file), resolved
/// relative to this config's directory when not absolute.
pub base_config: PathBuf,
/// Inline custom [`BinClimateStandard`]. Highest precedence.
#[serde(default)]
pub climate: Option<BinClimateStandard>,
/// Path to a custom [`BinClimateStandard`] JSON file (resolved relative to
/// this config's directory). Overrides [`climate_name`](Self::climate_name).
#[serde(default)]
pub climate_file: Option<PathBuf>,
/// Built-in climate id (e.g. `"en_14825_average"`).
#[serde(default)]
pub climate_name: Option<String>,
/// Which heat-exchanger side the outdoor temperature drives. Defaults by mode
/// (SCOP → evaporator, SEER → condenser).
#[serde(default)]
pub outdoor_side: Option<OutdoorSide>,
/// Building design load [W] at the design outdoor temperature.
pub design_load_w: f64,
/// Design outdoor temperature [°C] where demand equals `design_load_w`.
pub design_outdoor_c: f64,
/// Outdoor temperature [°C] at which building demand reaches zero
/// (heating/cooling threshold). Default 16 °C.
#[serde(default = "default_threshold_c")]
pub threshold_outdoor_c: f64,
/// Cycling degradation coefficient `Cd` for part-load operation
/// (`PLF = 1 Cd·(1 CR)`). Default 0.25; set per the applicable standard or
/// measurement.
#[serde(default = "default_cd")]
pub cd: f64,
/// Efficiency (COP) of the electric backup heater used when demand exceeds
/// capacity (heating only). Default 1.0 (resistive).
#[serde(default = "default_backup_cop")]
pub backup_cop: f64,
}
fn default_threshold_c() -> f64 {
16.0
}
fn default_cd() -> f64 {
0.25
}
fn default_backup_cop() -> f64 {
1.0
}
/// Per-bin result row.
#[derive(Debug, Clone, Serialize)]
pub struct BinResult {
/// Outdoor bin temperature [°C].
pub temperature_c: f64,
/// Annual hours in this bin.
pub hours: f64,
/// Building demand at this bin [W].
pub demand_w: f64,
/// Machine full-capacity useful duty at this bin [W] (heating or cooling).
pub capacity_w: Option<f64>,
/// Compressor power at full capacity [W].
pub power_w: Option<f64>,
/// Full-capacity COP at this bin.
pub full_cop: Option<f64>,
/// Capacity ratio `demand / capacity` (before clamping).
pub capacity_ratio: Option<f64>,
/// Fraction of demand supplied by the electric backup heater (heating only).
pub backup_fraction: f64,
/// Effective COP after part-load degradation / backup.
pub effective_cop: Option<f64>,
/// Solver status ("converged", "non_converged", "error", "no_demand").
pub status: String,
/// Error message when the bin solve failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Full seasonal rating report.
#[derive(Debug, Clone, Serialize)]
pub struct SeasonalReport {
/// Base configuration path.
pub base_config: String,
/// Metric label ("SCOP" or "SEER").
pub metric: String,
/// Climate name.
pub climate: String,
/// Climate citation / provenance.
pub climate_reference: String,
/// Total annual hours across the climate's bins.
pub total_hours: f64,
/// Total seasonal useful energy delivered [kWh].
pub seasonal_useful_kwh: f64,
/// Total seasonal electrical energy consumed [kWh].
pub seasonal_electric_kwh: f64,
/// Per-bin rows (ascending temperature).
pub bins: Vec<BinResult>,
/// The integrated seasonal metric, `None` if any demanded bin failed.
pub integrated_value: Option<f64>,
}
impl SeasonalConfig {
/// Resolve the effective climate, honouring precedence `climate` >
/// `climate_file` > `climate_name` > default (EN 14825 average).
pub fn resolve_climate(
&self,
mode: SeasonalMode,
config_dir: Option<&Path>,
) -> CliResult<BinClimateStandard> {
let climate = if let Some(inline) = &self.climate {
inline.clone()
} else if let Some(file) = &self.climate_file {
let path = if file.is_absolute() {
file.clone()
} else {
config_dir
.map(|d| d.join(file))
.unwrap_or_else(|| file.clone())
};
let raw = std::fs::read_to_string(&path).map_err(|e| {
CliError::Config(format!(
"Failed to read climate file {}: {}",
path.display(),
e
))
})?;
serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid climate file: {}", e)))?
} else if let Some(name) = &self.climate_name {
BinClimateStandard::builtin(name).ok_or_else(|| {
CliError::Config(format!(
"Unknown built-in climate '{}'. Known ids: {}",
name,
BinClimateStandard::builtin_ids().join(", ")
))
})?
} else {
// SEER has no shipped cooling-season preset; require an explicit climate.
if mode == SeasonalMode::Seer {
return Err(CliError::Config(
"SEER requires an explicit cooling-season climate (set 'climate', \
'climate_file' or 'climate_name')"
.to_string(),
));
}
BinClimateStandard::en_14825_average()
};
climate
.validate()
.map_err(|e| CliError::Config(format!("Invalid climate: {}", e)))?;
Ok(climate)
}
/// Building demand [W] at outdoor temperature `t_c`, from the linear load line
/// (clamped to `≥ 0`).
fn demand_at(&self, mode: SeasonalMode, t_c: f64) -> f64 {
let span = self.design_outdoor_c - self.threshold_outdoor_c;
if span.abs() < f64::EPSILON {
return 0.0;
}
let ratio = match mode {
// Heating: colder ⇒ more demand (design is the cold extreme).
SeasonalMode::Scop => {
(self.threshold_outdoor_c - t_c)
/ (self.threshold_outdoor_c - self.design_outdoor_c)
}
// Cooling: hotter ⇒ more demand (design is the hot extreme).
SeasonalMode::Seer => {
(t_c - self.threshold_outdoor_c)
/ (self.design_outdoor_c - self.threshold_outdoor_c)
}
};
(self.design_load_w * ratio).max(0.0)
}
}
/// Applies the outdoor-temperature override to a clone of the base config.
fn apply_outdoor_temp(base: &ScenarioConfig, side: OutdoorSide, temp_c: f64) -> ScenarioConfig {
let target_keyword = match side {
OutdoorSide::Evaporator => "evap",
OutdoorSide::Condenser => "cond",
};
let mut config = base.clone();
for circuit in &mut config.circuits {
for comp in &mut circuit.components {
if !comp.name.to_lowercase().contains(target_keyword) {
continue;
}
match comp.component_type.as_str() {
"BrineSource" => {
comp.params.insert("t_set_c".into(), json_num(temp_c));
}
"AirSource" => {
comp.params.insert("t_dry_c".into(), json_num(temp_c));
}
_ => {}
}
}
}
config
}
fn json_num(x: f64) -> serde_json::Value {
serde_json::Number::from_f64(x)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}
fn status_label(status: &SimulationStatus) -> &'static str {
match status {
SimulationStatus::Converged => "converged",
SimulationStatus::Timeout => "timeout",
SimulationStatus::NonConverged => "non_converged",
SimulationStatus::Error => "error",
}
}
/// Solves one bin at full capacity and derives its effective COP.
fn solve_bin(
base: &ScenarioConfig,
config: &SeasonalConfig,
mode: SeasonalMode,
side: OutdoorSide,
temperature_c: f64,
hours: f64,
) -> BinResult {
let demand_w = config.demand_at(mode, temperature_c);
// Bins with no building demand contribute no energy — skip the solve.
if demand_w <= 0.0 {
return BinResult {
temperature_c,
hours,
demand_w,
capacity_w: None,
power_w: None,
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status: "no_demand".to_string(),
error: None,
};
}
let scenario = apply_outdoor_temp(base, side, temperature_c);
let json = match serde_json::to_string(&scenario) {
Ok(j) => j,
Err(e) => return bin_error(temperature_c, hours, demand_w, format!("serialize: {e}")),
};
let result = match simulate_from_json(&json) {
Ok(r) => r,
Err(e) => return bin_error(temperature_c, hours, demand_w, format!("{e}")),
};
let status = status_label(&result.status).to_string();
if result.status != SimulationStatus::Converged {
return BinResult {
temperature_c,
hours,
demand_w,
capacity_w: None,
power_w: None,
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status,
error: result.error,
};
}
let perf = result.performance.as_ref();
let useful_kw = match mode {
SeasonalMode::Scop => perf.and_then(|p| p.q_heating_kw),
SeasonalMode::Seer => perf.and_then(|p| p.q_cooling_kw),
};
let power_kw = perf.and_then(|p| p.compressor_power_kw);
let (capacity_w, power_w) = match (useful_kw, power_kw) {
(Some(q), Some(w)) if q > 0.0 && w > 0.0 => (q * 1000.0, w * 1000.0),
_ => {
return BinResult {
temperature_c,
hours,
demand_w,
capacity_w: useful_kw.map(|q| q * 1000.0),
power_w: power_kw.map(|w| w * 1000.0),
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status,
error: Some("non-physical capacity or power at this bin".to_string()),
}
}
};
let full_cop = capacity_w / power_w;
let capacity_ratio = demand_w / capacity_w;
let (effective_cop, backup_fraction) = if capacity_ratio <= 1.0 {
// Part load: cycling degradation, no backup.
let plf = 1.0 - config.cd * (1.0 - capacity_ratio);
(full_cop * plf, 0.0)
} else if mode == SeasonalMode::Scop {
// Demand exceeds capacity: machine runs full, electric backup covers the rest.
let deficit = demand_w - capacity_w;
let elec = power_w + deficit / config.backup_cop.max(f64::EPSILON);
(demand_w / elec, deficit / demand_w)
} else {
// Cooling: cap at capacity (no backup); treat as full load.
(full_cop, 0.0)
};
BinResult {
temperature_c,
hours,
demand_w,
capacity_w: Some(capacity_w),
power_w: Some(power_w),
full_cop: Some(full_cop),
capacity_ratio: Some(capacity_ratio),
backup_fraction,
effective_cop: Some(effective_cop),
status,
error: None,
}
}
fn bin_error(temperature_c: f64, hours: f64, demand_w: f64, msg: String) -> BinResult {
BinResult {
temperature_c,
hours,
demand_w,
capacity_w: None,
power_w: None,
full_cop: None,
capacity_ratio: None,
backup_fraction: 0.0,
effective_cop: None,
status: "error".to_string(),
error: Some(msg),
}
}
/// Runs the seasonal rating: solves every demanded bin (in parallel) and
/// aggregates the SCOP/SEER.
pub fn seasonal(
config: &SeasonalConfig,
base: &ScenarioConfig,
climate: &BinClimateStandard,
mode: SeasonalMode,
) -> CliResult<SeasonalReport> {
let side = config
.outdoor_side
.unwrap_or_else(|| mode.default_outdoor_side());
let mut bins: Vec<BinResult> = climate
.bins
.par_iter()
.map(|b| solve_bin(base, config, mode, side, b.temperature_c, b.hours))
.collect();
bins.sort_by(|a, b| {
a.temperature_c
.partial_cmp(&b.temperature_c)
.unwrap_or(std::cmp::Ordering::Equal)
});
// A demanded bin that failed to converge makes the seasonal value unreliable.
let any_demanded_failed = bins
.iter()
.any(|b| b.demand_w > 0.0 && b.effective_cop.is_none());
let perf: Vec<BinPerformance> = bins
.iter()
.filter_map(|b| {
b.effective_cop.map(|cop| BinPerformance {
bin: entropyk::rating::TemperatureBin {
temperature_c: b.temperature_c,
hours: b.hours,
},
demand_w: b.demand_w,
cop,
})
})
.collect();
let integrated_value = if any_demanded_failed {
None
} else {
scop(&perf)
};
// Seasonal energies (kWh): Σ h·demand and Σ h·demand/cop.
let seasonal_useful_kwh: f64 =
perf.iter().map(|p| p.bin.hours * p.demand_w).sum::<f64>() / 1000.0;
let seasonal_electric_kwh: f64 = perf
.iter()
.map(|p| p.bin.hours * p.demand_w / p.cop)
.sum::<f64>()
/ 1000.0;
Ok(SeasonalReport {
base_config: config.base_config.display().to_string(),
metric: mode.label().to_string(),
climate: climate.name.clone(),
climate_reference: climate.reference.clone(),
total_hours: climate.total_hours(),
seasonal_useful_kwh,
seasonal_electric_kwh,
bins,
integrated_value,
})
}
/// Loads a seasonal config from JSON, resolves the base scenario and climate,
/// runs the rating, and optionally writes the JSON report.
pub fn run_seasonal(
config_path: &Path,
output: Option<&Path>,
mode: SeasonalMode,
) -> CliResult<SeasonalReport> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
})?;
let config: SeasonalConfig = serde_json::from_str(&raw)
.map_err(|e| CliError::Config(format!("Invalid seasonal config: {}", e)))?;
let config_dir = config_path.parent();
let base_path = if config.base_config.is_absolute() {
config.base_config.clone()
} else {
config_dir
.map(|d| d.join(&config.base_config))
.unwrap_or_else(|| config.base_config.clone())
};
let base = ScenarioConfig::from_file(&base_path)?;
let climate = config.resolve_climate(mode, config_dir)?;
let report = seasonal(&config, &base, &climate, mode)?;
if let Some(out) = output {
let json = serde_json::to_string_pretty(&report)
.map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
std::fs::write(out, json)
.map_err(|e| CliError::Config(format!("Failed to write {}: {}", out.display(), e)))?;
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> SeasonalConfig {
SeasonalConfig {
base_config: PathBuf::from("x.json"),
climate: None,
climate_file: None,
climate_name: None,
outdoor_side: None,
design_load_w: 10_000.0,
design_outdoor_c: -10.0,
threshold_outdoor_c: 16.0,
cd: 0.25,
backup_cop: 1.0,
}
}
#[test]
fn demand_line_is_linear_and_clamped_for_heating() {
let c = cfg();
// At design temperature, demand == design load.
assert!((c.demand_at(SeasonalMode::Scop, -10.0) - 10_000.0).abs() < 1e-9);
// At threshold, demand == 0.
assert!(c.demand_at(SeasonalMode::Scop, 16.0).abs() < 1e-9);
// Above threshold, clamped to 0 (no heating needed).
assert_eq!(c.demand_at(SeasonalMode::Scop, 20.0), 0.0);
// Midpoint (3 °C) → half load.
assert!((c.demand_at(SeasonalMode::Scop, 3.0) - 5_000.0).abs() < 1e-6);
}
#[test]
fn demand_line_reverses_for_cooling() {
let mut c = cfg();
c.design_outdoor_c = 35.0;
c.threshold_outdoor_c = 16.0;
// Hot design → full load; threshold → zero; below threshold clamped.
assert!((c.demand_at(SeasonalMode::Seer, 35.0) - 10_000.0).abs() < 1e-9);
assert!(c.demand_at(SeasonalMode::Seer, 16.0).abs() < 1e-9);
assert_eq!(c.demand_at(SeasonalMode::Seer, 10.0), 0.0);
}
#[test]
fn part_load_degrades_cop_via_cd() {
// With CR=0.5 and Cd=0.25, PLF = 1 - 0.25*0.5 = 0.875.
let c = cfg();
let cr = 0.5;
let plf = 1.0 - c.cd * (1.0 - cr);
assert!((plf - 0.875).abs() < 1e-12);
}
#[test]
fn default_outdoor_side_follows_mode() {
assert_eq!(
SeasonalMode::Scop.default_outdoor_side(),
OutdoorSide::Evaporator
);
assert_eq!(
SeasonalMode::Seer.default_outdoor_side(),
OutdoorSide::Condenser
);
}
#[test]
fn seer_requires_explicit_climate() {
let c = cfg();
assert!(c.resolve_climate(SeasonalMode::Seer, None).is_err());
// SCOP falls back to the EN 14825 average season.
let climate = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
assert!((climate.total_hours() - 4910.0).abs() < 1e-6);
}
#[test]
fn resolve_climate_precedence_and_builtin() {
let mut c = cfg();
c.climate_name = Some("average".to_string());
let climate = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
assert_eq!(climate.total_hours(), 4910.0);
// Inline wins over name.
c.climate = Some(BinClimateStandard {
name: "tiny".to_string(),
reference: String::new(),
bins: vec![entropyk::rating::TemperatureBin {
temperature_c: 0.0,
hours: 100.0,
}],
});
let climate2 = c.resolve_climate(SeasonalMode::Scop, None).unwrap();
assert_eq!(climate2.name, "tiny");
}
#[test]
fn outdoor_side_matches_expected_components() {
assert!(OutdoorSide::Evaporator.matches("Evaporator"));
assert!(OutdoorSide::Evaporator.matches("FloodedEvaporator"));
assert!(!OutdoorSide::Evaporator.matches("Condenser"));
assert!(OutdoorSide::Condenser.matches("Condenser"));
assert!(!OutdoorSide::Condenser.matches("Evaporator"));
}
#[test]
fn apply_outdoor_temp_sets_only_matching_side() {
let base = ScenarioConfig::from_json(
r#"{
"fluid": "R134a",
"circuits": [{ "id": 0, "components": [
{ "type": "Condenser", "name": "cond", "ua": 700.0, "secondary_fluid": "Water" },
{ "type": "Evaporator", "name": "evap", "ua": 1400.0, "secondary_fluid": "Water" },
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.4 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
], "edges": [] }],
"solver": { "strategy": "fallback", "max_iterations": 100, "tolerance": 1e-6 }
}"#,
)
.unwrap();
let out = apply_outdoor_temp(&base, OutdoorSide::Evaporator, -7.0);
let comps = &out.circuits[0].components;
let evap_src = comps.iter().find(|c| c.name == "evap_water_in").unwrap();
let cond_src = comps.iter().find(|c| c.name == "cond_water_in").unwrap();
assert_eq!(evap_src.params.get("t_set_c").unwrap().as_f64(), Some(-7.0));
// Condenser (indoor) is untouched.
assert_eq!(cond_src.params.get("t_set_c").unwrap().as_f64(), Some(30.0));
}
}

View File

@@ -88,6 +88,9 @@ fn test_simulation_result_statuses() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -98,6 +101,9 @@ fn test_simulation_result_statuses() {
state: None,
performance: None,
error: Some("Error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
SimulationResult {
@@ -108,6 +114,9 @@ fn test_simulation_result_statuses() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 1000,
},
];
@@ -137,26 +146,38 @@ fn test_batch_aggregator_csv_output() {
input: "scenario1.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
}),
final_residual: 1e-8,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(25),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 150,
},
SimulationResult {
input: "scenario2.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 5e-7,
tolerance: 1e-6,
}),
final_residual: 5e-7,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(30),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 200,
},
SimulationResult {
@@ -167,6 +188,9 @@ fn test_batch_aggregator_csv_output() {
state: None,
performance: None,
error: Some("Solver failed".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
},
];
@@ -195,6 +219,9 @@ fn test_batch_aggregator_json_summary() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
},
SimulationResult {
@@ -205,6 +232,9 @@ fn test_batch_aggregator_json_summary() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 75,
},
SimulationResult {
@@ -215,6 +245,9 @@ fn test_batch_aggregator_json_summary() {
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 5000,
},
];
@@ -268,11 +301,17 @@ fn test_batch_summary_csv_with_convergence() {
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 1e-9,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(42),
state: None,
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 300,
}];

View File

@@ -11,7 +11,67 @@ fn test_parse_minimal_config() {
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.fluid, "R134a");
assert!(config.circuits.is_empty());
assert_eq!(config.solver.strategy, "fallback");
assert_eq!(config.solver.strategy, "newton");
assert!(config.controls.is_empty());
}
#[test]
fn test_parse_saturated_control_loop() {
let json = r#"
{
"fluid": "R134a",
"controls": [
{
"type": "SaturatedController",
"id": "lwt_capacity",
"measure": { "component": "evap_0", "output": "capacity" },
"actuator": {
"component": "screw_0", "factor": "f_m",
"initial": 1.0, "min": 0.5, "max": 1.5
},
"target": 350000.0,
"gain": 0.01,
"band": 1.0,
"smooth_eps": 0.001
}
]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.controls.len(), 1);
let c = &config.controls[0];
assert_eq!(c.control_type, "SaturatedController");
assert_eq!(c.id, "lwt_capacity");
assert_eq!(c.measure.component, "evap_0");
assert_eq!(c.measure.output, "capacity");
assert_eq!(c.actuator.factor, "f_m");
assert_eq!(c.actuator.min, 0.5);
assert_eq!(c.actuator.max, 1.5);
assert_eq!(c.target, 350000.0);
assert_eq!(c.gain, 0.01);
assert_eq!(c.smooth_eps, Some(0.001));
}
#[test]
fn test_control_defaults_apply() {
let json = r#"
{
"fluid": "R134a",
"controls": [
{
"id": "cap",
"measure": { "component": "evap", "output": "capacity" },
"actuator": { "component": "comp", "factor": "f_m", "min": 0.5, "max": 1.5 },
"target": 1000.0
}
]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
let c = &config.controls[0];
assert_eq!(c.control_type, "SaturatedController");
assert_eq!(c.gain, 1.0);
assert_eq!(c.band, 1.0);
assert_eq!(c.actuator.initial, 1.0);
assert_eq!(c.smooth_eps, None);
}
#[test]
@@ -110,7 +170,7 @@ fn test_load_config_file_not_found() {
#[test]
fn test_solver_config_defaults() {
let config = SolverConfig::default();
assert_eq!(config.strategy, "fallback");
assert_eq!(config.strategy, "newton");
assert_eq!(config.max_iterations, 100);
assert_eq!(config.tolerance, 1e-6);
assert_eq!(config.timeout_ms, 0);

View File

@@ -0,0 +1,397 @@
//! Standalone heat exchanger tests — Modelica-style 4-port pattern.
//!
//! Each test wires a SINGLE heat exchanger with Source/Sink boundary components
//! on BOTH sides. No full chiller cycle, no thermal_couplings, no ThermalLoad.
//!
//! Pattern (Modelica `BoundaryNode.Source → HX → Sink`):
//! Hot side: HotSource(P, T, ṁ) → HX:hot_inlet → HX:hot_outlet → HotSink(P_back)
//! Cold side: ColdSource(P, T, ṁ) → HX:cold_inlet → HX:cold_outlet → ColdSink(P_back)
//!
//! Test matrix:
//! 1. Air-Water HX (HeatExchanger, hot=Water, cold=Air)
//! 2. Water-Water HX (HeatExchanger, hot=Water, cold=Water)
//! 3. Air/Ref Evaporator (HeatExchanger, hot=Air, cold=R134a liquid→vapor)
//! 4. Water/Ref Evaporator (HeatExchanger, hot=Water, cold=R134a liquid→vapor)
//! 5. Air/Ref Condenser (HeatExchanger, hot=R134a vapor→liquid, cold=Air)
//! 6. Water/Ref Condenser (HeatExchanger, hot=R134a vapor→liquid, cold=Water)
use entropyk_cli::run::{run_simulation, SimulationStatus};
use tempfile::tempdir;
fn run_config(json: &str) -> SimulationResult {
let dir = tempdir().unwrap();
let path = dir.path().join("hx_test.json");
std::fs::write(&path, json).unwrap();
run_simulation(&path, None, false).unwrap()
}
use entropyk_cli::run::SimulationResult;
fn assert_converged(result: &SimulationResult, label: &str) {
assert!(
matches!(result.status, SimulationStatus::Converged),
"{label} did not converge: {:?} ({:?})",
result.status,
result.error
);
}
// ── 1. Air-Water Heat Exchanger ──────────────────────────────────────────────
// Hot water (60°C) heats cold air (20°C). Water cools down, air warms up.
#[test]
fn test_hx_air_water_4port() {
let json = r#"
{
"fluid": "Water",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "BrineSource", "name": "hot_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 60.0, "m_flow_kg_s": 0.5 },
{ "type": "HeatExchanger", "name": "hx", "ua": 3000.0, "hot_fluid_id": "Water", "cold_fluid_id": "Air", "cold_humidity_ratio": 0.010 },
{ "type": "BrineSink", "name": "hot_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "AirSource", "name": "cold_in", "p_set_bar": 1.01325, "t_dry_c": 20.0, "rh": 50.0, "m_flow_kg_s": 1.0 },
{ "type": "Fan", "name": "supply_fan", "fluid": "Air", "speed_ratio": 1.0, "air_density_kg_per_m3": 1.204, "design_flow_m3_s": 0.83, "curve_p0": 250.0, "curve_p1": 0.0, "curve_p2": -20.0, "eff_e0": 0.65, "eff_e1": 0.0, "eff_e2": 0.0 },
{ "type": "AirSink", "name": "cold_out", "p_back_bar": 1.01325 }
],
"edges": [
{ "from": "hot_in:outlet", "to": "hx:hot_inlet" },
{ "from": "hx:hot_outlet", "to": "hot_out:inlet" },
{ "from": "cold_in:outlet", "to": "supply_fan:inlet" },
{ "from": "supply_fan:outlet", "to": "hx:cold_inlet" },
{ "from": "hx:cold_outlet", "to": "cold_out:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let result = run_config(json);
assert_converged(&result, "Air-Water HX");
let state = result.state.expect("state");
assert!(state.len() == 5, "5 edges expected, got {}", state.len());
// Hot water cools down.
let h_hot_in = state[0].enthalpy_kj_kg;
let h_hot_out = state[1].enthalpy_kj_kg;
assert!(
h_hot_in > h_hot_out,
"hot water must cool down: {h_hot_in} -> {h_hot_out} kJ/kg"
);
// Cold air warms up.
let h_cold_in = state[3].enthalpy_kj_kg;
let h_cold_out = state[4].enthalpy_kj_kg;
assert!(
h_cold_out > h_cold_in,
"cold air must warm up: {h_cold_in} -> {h_cold_out} kJ/kg"
);
// Energy conservation: Q_hot = Q_cold (within 2%).
// m_hot and m_cold are imposed by the sources.
let m_hot = 0.5_f64; // kg/s
let m_cold = 1.0_f64; // kg/s
let q_hot = m_hot * (h_hot_in - h_hot_out); // kW
let q_cold = m_cold * (h_cold_out - h_cold_in); // kW
assert!(
(q_hot - q_cold).abs() < 0.02 * q_hot.abs().max(0.001),
"First Law: Q_hot={q_hot:.4} kW vs Q_cold={q_cold:.4} kW"
);
assert!(q_hot > 0.0, "heat must flow from hot to cold: Q={q_hot} kW");
}
// ── 2. Water-Water Heat Exchanger ────────────────────────────────────────────
// Hot water (80°C) heats cold water (20°C).
#[test]
fn test_hx_water_water_4port() {
let json = r#"
{
"fluid": "Water",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "BrineSource", "name": "hot_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 80.0, "m_flow_kg_s": 0.3 },
{ "type": "HeatExchanger", "name": "hx", "ua": 5000.0, "hot_fluid_id": "Water", "cold_fluid_id": "Water" },
{ "type": "BrineSink", "name": "hot_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "cold_in", "fluid": "Water", "p_set_bar": 1.5, "t_set_c": 20.0, "m_flow_kg_s": 0.5 },
{ "type": "BrineSink", "name": "cold_out", "fluid": "Water", "p_back_bar": 1.5 }
],
"edges": [
{ "from": "hot_in:outlet", "to": "hx:hot_inlet" },
{ "from": "hx:hot_outlet", "to": "hot_out:inlet" },
{ "from": "cold_in:outlet", "to": "hx:cold_inlet" },
{ "from": "hx:cold_outlet", "to": "cold_out:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let result = run_config(json);
assert_converged(&result, "Water-Water HX");
let state = result.state.expect("state");
assert!(state.len() == 4, "4 edges expected");
let h_hot_in = state[0].enthalpy_kj_kg;
let h_hot_out = state[1].enthalpy_kj_kg;
assert!(
h_hot_in > h_hot_out,
"hot water must cool down: {h_hot_in} -> {h_hot_out}"
);
let h_cold_in = state[2].enthalpy_kj_kg;
let h_cold_out = state[3].enthalpy_kj_kg;
assert!(
h_cold_out > h_cold_in,
"cold water must warm up: {h_cold_in} -> {h_cold_out}"
);
let m_hot = 0.3_f64;
let m_cold = 0.5_f64;
let q_hot = m_hot * (h_hot_in - h_hot_out);
let q_cold = m_cold * (h_cold_out - h_cold_in);
assert!(
(q_hot - q_cold).abs() < 0.02 * q_hot.abs().max(0.001),
"First Law: Q_hot={q_hot:.4} vs Q_cold={q_cold:.4}"
);
assert!(q_hot > 0.0, "heat must flow from hot to cold");
}
// ── 3. Air/Refrigerant Evaporator ────────────────────────────────────────────
// Warm air (25°C) heats cold refrigerant R134a (liquid at 5°C → vapor).
// Hot side = Air, Cold side = R134a.
#[test]
fn test_hx_air_ref_evaporator_4port() {
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "AirSource", "name": "hot_in", "p_set_bar": 1.01325, "t_dry_c": 25.0, "rh": 50.0, "m_flow_kg_s": 0.8 },
{ "type": "Evaporator", "name": "hx", "ua": 2000.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 },
{ "type": "AirSink", "name": "hot_out", "p_back_bar": 1.01325 },
{ "type": "RefrigerantSource", "name": "cold_in", "fluid": "R134a", "p_set_bar": 3.5, "quality": 0.0, "m_flow_kg_s": 0.05 },
{ "type": "RefrigerantSink", "name": "cold_out", "fluid": "R134a", "p_back_bar": 3.5 }
],
"edges": [
{ "from": "cold_in:outlet", "to": "hx:inlet" },
{ "from": "hx:outlet", "to": "cold_out:inlet" },
{ "from": "hot_in:outlet", "to": "hx:secondary_inlet" },
{ "from": "hx:secondary_outlet", "to": "hot_out:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let result = run_config(json);
assert_converged(&result, "Air/Ref Evaporator");
let state = result.state.expect("state");
// Edge order: 0=ref_in, 1=ref_out, 2=air_in, 3=air_out
let h_ref_in = state[0].enthalpy_kj_kg;
let h_ref_out = state[1].enthalpy_kj_kg;
assert!(
h_ref_out > h_ref_in,
"refrigerant must absorb heat (evaporate): {h_ref_in} -> {h_ref_out}"
);
let h_air_in = state[2].enthalpy_kj_kg;
let h_air_out = state[3].enthalpy_kj_kg;
assert!(
h_air_in > h_air_out,
"air must cool down: {h_air_in} -> {h_air_out}"
);
let m_air = 0.8_f64;
let m_ref = 0.05_f64;
let q_hot = m_air * (h_air_in - h_air_out);
let q_cold = m_ref * (h_ref_out - h_ref_in);
assert!(
(q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001),
"First Law: Q_air={q_hot:.4} vs Q_ref={q_cold:.4}"
);
assert!(q_hot > 0.0, "heat must flow from air to refrigerant");
}
// ── 4. Water/Refrigerant Evaporator ──────────────────────────────────────────
// Chilled water (12°C) heats cold refrigerant R134a (liquid at 5°C → vapor).
// Hot side = Water (secondary), Cold side = R134a (refrigerant).
#[test]
fn test_hx_water_ref_evaporator_4port() {
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "BrineSource", "name": "hot_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.5 },
{ "type": "Evaporator", "name": "hx", "ua": 2000.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Water" },
{ "type": "BrineSink", "name": "hot_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "RefrigerantSource", "name": "cold_in", "fluid": "R134a", "p_set_bar": 3.5, "quality": 0.0, "m_flow_kg_s": 0.05 },
{ "type": "RefrigerantSink", "name": "cold_out", "fluid": "R134a", "p_back_bar": 3.5 }
],
"edges": [
{ "from": "cold_in:outlet", "to": "hx:inlet" },
{ "from": "hx:outlet", "to": "cold_out:inlet" },
{ "from": "hot_in:outlet", "to": "hx:secondary_inlet" },
{ "from": "hx:secondary_outlet", "to": "hot_out:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let result = run_config(json);
assert_converged(&result, "Water/Ref Evaporator");
let state = result.state.expect("state");
// Edge order: 0=ref_in, 1=ref_out, 2=water_in, 3=water_out
let h_ref_in = state[0].enthalpy_kj_kg;
let h_ref_out = state[1].enthalpy_kj_kg;
assert!(
h_ref_out > h_ref_in,
"refrigerant must absorb heat: {h_ref_in} -> {h_ref_out}"
);
let h_water_in = state[2].enthalpy_kj_kg;
let h_water_out = state[3].enthalpy_kj_kg;
assert!(
h_water_in > h_water_out,
"water must cool down: {h_water_in} -> {h_water_out}"
);
let m_water = 0.5_f64;
let m_ref = 0.05_f64;
let q_hot = m_water * (h_water_in - h_water_out);
let q_cold = m_ref * (h_ref_out - h_ref_in);
assert!(
(q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001),
"First Law: Q_water={q_hot:.4} vs Q_ref={q_cold:.4}"
);
assert!(q_hot > 0.0, "heat must flow from water to refrigerant");
}
// ── 5. Air/Refrigerant Condenser ─────────────────────────────────────────────
// Hot refrigerant R134a (vapor at 50°C) heats cold air (35°C).
// Hot side = R134a (refrigerant), Cold side = Air (secondary).
#[test]
fn test_hx_air_ref_condenser_4port() {
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "RefrigerantSource", "name": "hot_in", "fluid": "R134a", "p_set_bar": 12.0, "quality": 1.0, "m_flow_kg_s": 0.05 },
{ "type": "Condenser", "name": "hx", "ua": 2500.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Air", "secondary_humidity_ratio": 0.010 },
{ "type": "RefrigerantSink", "name": "hot_out", "fluid": "R134a", "p_back_bar": 12.0 },
{ "type": "AirSource", "name": "cold_in", "p_set_bar": 1.01325, "t_dry_c": 35.0, "rh": 40.0, "m_flow_kg_s": 1.0 },
{ "type": "AirSink", "name": "cold_out", "p_back_bar": 1.01325 }
],
"edges": [
{ "from": "hot_in:outlet", "to": "hx:inlet" },
{ "from": "hx:outlet", "to": "hot_out:inlet" },
{ "from": "cold_in:outlet", "to": "hx:secondary_inlet" },
{ "from": "hx:secondary_outlet", "to": "cold_out:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let result = run_config(json);
assert_converged(&result, "Air/Ref Condenser");
let state = result.state.expect("state");
// Edge order: 0=ref_in, 1=ref_out, 2=air_in, 3=air_out
let h_ref_in = state[0].enthalpy_kj_kg;
let h_ref_out = state[1].enthalpy_kj_kg;
assert!(
h_ref_in > h_ref_out,
"refrigerant must reject heat: {h_ref_in} -> {h_ref_out}"
);
let h_air_in = state[2].enthalpy_kj_kg;
let h_air_out = state[3].enthalpy_kj_kg;
assert!(
h_air_out > h_air_in,
"air must warm up: {h_air_in} -> {h_air_out}"
);
let m_ref = 0.05_f64;
let m_air = 1.0_f64;
let q_hot = m_ref * (h_ref_in - h_ref_out);
let q_cold = m_air * (h_air_out - h_air_in);
assert!(
(q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001),
"First Law: Q_ref={q_hot:.4} vs Q_air={q_cold:.4}"
);
assert!(q_hot > 0.0, "heat must flow from refrigerant to air");
}
// ── 6. Water/Refrigerant Condenser ───────────────────────────────────────────
// Hot refrigerant R134a (vapor at 50°C) heats cold water (30°C).
// Hot side = R134a (refrigerant), Cold side = Water (secondary).
#[test]
fn test_hx_water_ref_condenser_4port() {
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [{
"id": 0,
"components": [
{ "type": "RefrigerantSource", "name": "hot_in", "fluid": "R134a", "p_set_bar": 12.0, "quality": 1.0, "m_flow_kg_s": 0.05 },
{ "type": "Condenser", "name": "hx", "ua": 2500.0, "fluid": "R134a", "skip_pressure_eq": true, "secondary_fluid": "Water" },
{ "type": "RefrigerantSink", "name": "hot_out", "fluid": "R134a", "p_back_bar": 12.0 },
{ "type": "BrineSource", "name": "cold_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.4 },
{ "type": "BrineSink", "name": "cold_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": [
{ "from": "hot_in:outlet", "to": "hx:inlet" },
{ "from": "hx:outlet", "to": "hot_out:inlet" },
{ "from": "cold_in:outlet", "to": "hx:secondary_inlet" },
{ "from": "hx:secondary_outlet", "to": "cold_out:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let result = run_config(json);
assert_converged(&result, "Water/Ref Condenser");
let state = result.state.expect("state");
// Edge order: 0=ref_in, 1=ref_out, 2=water_in, 3=water_out
let h_ref_in = state[0].enthalpy_kj_kg;
let h_ref_out = state[1].enthalpy_kj_kg;
assert!(
h_ref_in > h_ref_out,
"refrigerant must reject heat: {h_ref_in} -> {h_ref_out}"
);
let h_water_in = state[2].enthalpy_kj_kg;
let h_water_out = state[3].enthalpy_kj_kg;
assert!(
h_water_out > h_water_in,
"water must warm up: {h_water_in} -> {h_water_out}"
);
let m_ref = 0.05_f64;
let m_water = 0.4_f64;
let q_hot = m_ref * (h_ref_in - h_ref_out);
let q_cold = m_water * (h_water_out - h_water_in);
assert!(
(q_hot - q_cold).abs() < 0.05 * q_hot.abs().max(0.001),
"First Law: Q_ref={q_hot:.4} vs Q_water={q_cold:.4}"
);
assert!(q_hot > 0.0, "heat must flow from refrigerant to water");
}

File diff suppressed because it is too large Load Diff