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:
@@ -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"
|
||||
|
||||
19
crates/cli/examples/_ui_repro_flooded_port.json
Normal file
19
crates/cli/examples/_ui_repro_flooded_port.json
Normal 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}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
30
crates/cli/examples/capillary_tube_r134a.json
Normal file
30
crates/cli/examples/capillary_tube_r134a.json
Normal 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
|
||||
}
|
||||
}
|
||||
107
crates/cli/examples/chiller_aircooled_r134a.json
Normal file
107
crates/cli/examples/chiller_aircooled_r134a.json
Normal 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
|
||||
}
|
||||
}
|
||||
107
crates/cli/examples/chiller_flooded_4port_watercooled.json
Normal file
107
crates/cli/examples/chiller_flooded_4port_watercooled.json
Normal 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
|
||||
}
|
||||
}
|
||||
105
crates/cli/examples/chiller_flooded_delta_t_rating.json
Normal file
105
crates/cli/examples/chiller_flooded_delta_t_rating.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
57
crates/cli/examples/chiller_r134a_dual_circuit_staging.json
Normal file
57
crates/cli/examples/chiller_r134a_dual_circuit_staging.json
Normal 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 }
|
||||
}
|
||||
34
crates/cli/examples/chiller_r134a_emergent_pressure.json
Normal file
34
crates/cli/examples/chiller_r134a_emergent_pressure.json
Normal 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 }
|
||||
}
|
||||
28
crates/cli/examples/chiller_r134a_exv_orifice.json
Normal file
28
crates/cli/examples/chiller_r134a_exv_orifice.json
Normal 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 }
|
||||
}
|
||||
28
crates/cli/examples/chiller_r134a_flooded_headpressure.json
Normal file
28
crates/cli/examples/chiller_r134a_flooded_headpressure.json
Normal 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 }
|
||||
}
|
||||
36
crates/cli/examples/chiller_r134a_liquid_injection.json
Normal file
36
crates/cli/examples/chiller_r134a_liquid_injection.json
Normal 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 }
|
||||
}
|
||||
28
crates/cli/examples/chiller_r134a_slide_valve.json
Normal file
28
crates/cli/examples/chiller_r134a_slide_valve.json
Normal 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 }
|
||||
}
|
||||
36
crates/cli/examples/chiller_r134a_superheat_control.json
Normal file
36
crates/cli/examples/chiller_r134a_superheat_control.json
Normal 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 }
|
||||
}
|
||||
32
crates/cli/examples/chiller_r410a_bolt_nodes.json
Normal file
32
crates/cli/examples/chiller_r410a_bolt_nodes.json
Normal 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 }
|
||||
}
|
||||
41
crates/cli/examples/chiller_r410a_coupled_water_loop.json
Normal file
41
crates/cli/examples/chiller_r410a_coupled_water_loop.json
Normal 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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
93
crates/cli/examples/chiller_watercooled_r410a.json
Normal file
93
crates/cli/examples/chiller_watercooled_r410a.json
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
93
crates/cli/examples/heatpump_airsource_r410a.json
Normal file
93
crates/cli/examples/heatpump_airsource_r410a.json
Normal 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
|
||||
}
|
||||
}
|
||||
28
crates/cli/examples/heatpump_r134a_fan_headpressure.json
Normal file
28
crates/cli/examples/heatpump_r134a_fan_headpressure.json
Normal 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 }
|
||||
}
|
||||
30
crates/cli/examples/heatpump_r410a_reversing_valve.json
Normal file
30
crates/cli/examples/heatpump_r410a_reversing_valve.json
Normal 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 }
|
||||
}
|
||||
88
crates/cli/examples/hx_air_water_4port.json
Normal file
88
crates/cli/examples/hx_air_water_4port.json
Normal 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
|
||||
}
|
||||
}
|
||||
10
crates/cli/examples/rate_chiller_iplv_ahri.json
Normal file
10
crates/cli/examples/rate_chiller_iplv_ahri.json
Normal 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 }
|
||||
]
|
||||
}
|
||||
8
crates/cli/examples/scop_heatpump_r134a.json
Normal file
8
crates/cli/examples/scop_heatpump_r134a.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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·(setpoint−measure)`.
|
||||
#[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, ¶ms).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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
333
crates/cli/src/qualify.rs
Normal 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
643
crates/cli/src/rate.rs
Normal 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
690
crates/cli/src/seasonal.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
397
crates/cli/tests/hx_standalone.rs
Normal file
397
crates/cli/tests/hx_standalone.rs
Normal 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
Reference in New Issue
Block a user