Wire BPHX channel pressure drop on both sides with selectable correlations.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Replace isobaric 4-port closures with SimplifiedChannel (default) and Martin1996 DP models so z_dp and UI dp_correlation actually affect the Newton solve. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -206,8 +206,14 @@ describe("COMPONENTS catalogue integrity", () => {
|
|||||||
"channel_spacing_mm",
|
"channel_spacing_mm",
|
||||||
"chevron_angle_deg",
|
"chevron_angle_deg",
|
||||||
"correlation",
|
"correlation",
|
||||||
|
"dp_correlation",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
const dp = COMPONENT_BY_TYPE[type].params.find((p) => p.key === "dp_correlation");
|
||||||
|
expect(dp?.options?.map((o) => o.value)).toEqual([
|
||||||
|
"SimplifiedChannel",
|
||||||
|
"Martin1996",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1076,6 +1076,19 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
{ value: "Shah2021", label: "Shah 2021" },
|
{ value: "Shah2021", label: "Shah 2021" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "dp_correlation",
|
||||||
|
label: "Pressure-drop correlation",
|
||||||
|
kind: "string",
|
||||||
|
default: "SimplifiedChannel",
|
||||||
|
section: "Pressure drop",
|
||||||
|
description:
|
||||||
|
"Channel friction on both hot and cold sides (independent of HTC). Default SimplifiedChannel; Martin1996 uses chevron angle.",
|
||||||
|
options: [
|
||||||
|
{ value: "SimplifiedChannel", label: "Simplified channel (default)" },
|
||||||
|
{ value: "Martin1996", label: "Martin 1996 (chevron)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
||||||
{
|
{
|
||||||
key: "calib_sst_c",
|
key: "calib_sst_c",
|
||||||
@@ -1163,6 +1176,19 @@ export const COMPONENTS: ComponentMeta[] = [
|
|||||||
{ value: "Shah2021", label: "Shah 2021" },
|
{ value: "Shah2021", label: "Shah 2021" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "dp_correlation",
|
||||||
|
label: "Pressure-drop correlation",
|
||||||
|
kind: "string",
|
||||||
|
default: "SimplifiedChannel",
|
||||||
|
section: "Pressure drop",
|
||||||
|
description:
|
||||||
|
"Channel friction on both hot and cold sides (independent of HTC). Default SimplifiedChannel; Martin1996 uses chevron angle.",
|
||||||
|
options: [
|
||||||
|
{ value: "SimplifiedChannel", label: "Simplified channel (default)" },
|
||||||
|
{ value: "Martin1996", label: "Martin 1996 (chevron)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
{ key: "ua", label: "UA override", kind: "number", unit: "W/K", section: "Calibration", min: 0.0, advanced: true },
|
||||||
{
|
{
|
||||||
key: "calib_sdt_c",
|
key: "calib_sdt_c",
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ const PARAM_ALIASES: Record<string, string[]> = {
|
|||||||
rh: ["relative_humidity"],
|
rh: ["relative_humidity"],
|
||||||
p_set_bar: ["pressure_bar"],
|
p_set_bar: ["pressure_bar"],
|
||||||
p_back_bar: ["pressure_bar"],
|
p_back_bar: ["pressure_bar"],
|
||||||
|
dp_correlation: ["DpCorrelation", "dpCorrelation"],
|
||||||
};
|
};
|
||||||
|
|
||||||
function getParam(
|
function getParam(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"target_subcooling_k": 5.0,
|
"target_subcooling_k": 5.0,
|
||||||
"emergent_pressure": true,
|
"emergent_pressure": true,
|
||||||
"correlation": "Longo2004",
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
"ua": 2500.0
|
"ua": 2500.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"target_superheat_k": 5.0,
|
"target_superheat_k": 5.0,
|
||||||
"emergent_pressure": true,
|
"emergent_pressure": true,
|
||||||
"correlation": "Longo2004",
|
"correlation": "Longo2004",
|
||||||
|
"dp_correlation": "SimplifiedChannel",
|
||||||
"ua": 2000.0
|
"ua": 2000.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ fn process_single_file(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,7 +425,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -439,7 +439,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -483,7 +483,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -497,7 +497,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -529,7 +529,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -543,7 +543,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -571,7 +571,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -585,7 +585,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 1000,
|
elapsed_ms: 1000,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -643,7 +643,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 100,
|
elapsed_ms: 100,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -667,7 +667,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "ok2.json".to_string(),
|
input: "ok2.json".to_string(),
|
||||||
@@ -681,7 +681,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 60,
|
elapsed_ms: 60,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "fail.json".to_string(),
|
input: "fail.json".to_string(),
|
||||||
@@ -695,7 +695,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ pub struct SimulationResult {
|
|||||||
/// solve via the `ENTROPYK_INIT_STATE_JSON` environment variable).
|
/// solve via the `ENTROPYK_INIT_STATE_JSON` environment variable).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub raw_state_vector: Option<Vec<f64>>,
|
pub raw_state_vector: Option<Vec<f64>>,
|
||||||
|
/// Named solver-computed unknowns (free actuators + calibration factors
|
||||||
|
/// like `z_ua`, `opening`, `z_dp`) read out of `raw_state_vector` and
|
||||||
|
/// labeled with their stable id, owning component, and `[min, max]`
|
||||||
|
/// bounds. Empty for systems with no bounded control variables; skipped
|
||||||
|
/// on serialization when empty so older consumers keep working.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub solved_variables: Vec<entropyk::SolvedVariable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact DoF summary for CLI / web API consumers.
|
/// Compact DoF summary for CLI / web API consumers.
|
||||||
@@ -365,7 +372,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +407,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -710,7 +717,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -730,7 +737,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -775,7 +782,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -795,7 +802,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
None => 1,
|
None => 1,
|
||||||
@@ -816,7 +823,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -836,7 +843,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
None => 0,
|
None => 0,
|
||||||
@@ -860,7 +867,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -880,7 +887,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -950,7 +957,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -976,7 +983,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
let find_component = |name: &str| {
|
let find_component = |name: &str| {
|
||||||
config
|
config
|
||||||
@@ -1054,7 +1061,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
system.add_saturated_controller(controller);
|
system.add_saturated_controller(controller);
|
||||||
@@ -1072,7 +1079,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1246,7 +1253,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
system.add_free_actuator(var_id);
|
system.add_free_actuator(var_id);
|
||||||
@@ -1268,7 +1275,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1289,7 +1296,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1320,7 +1327,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: Some(summary),
|
dof: Some(summary),
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
summary
|
summary
|
||||||
@@ -1517,7 +1524,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: Some(dof_summary),
|
dof: Some(dof_summary),
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1746,6 +1753,7 @@ fn execute_simulation(
|
|||||||
dof: Some(dof_summary.clone()),
|
dof: Some(dof_summary.clone()),
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: Some(converged.state.clone()),
|
raw_state_vector: Some(converged.state.clone()),
|
||||||
|
solved_variables: entropyk::extract_solved_variables(&system, &converged.state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1791,7 +1799,7 @@ fn execute_simulation(
|
|||||||
initialization_diagnostics: Some(initialization_diagnostics),
|
initialization_diagnostics: Some(initialization_diagnostics),
|
||||||
dof: Some(dof_summary),
|
dof: Some(dof_summary),
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2939,6 +2947,32 @@ fn bphx_correlation_from_params(
|
|||||||
Ok(Some(correlation))
|
Ok(Some(correlation))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse BPHX frictional `dp_correlation` / `DpCorrelation` (independent of HTC).
|
||||||
|
///
|
||||||
|
/// Default when absent: `SimplifiedChannel`. Unknown ids are rejected (no silent isobaric).
|
||||||
|
fn bphx_dp_correlation_from_params(
|
||||||
|
params: &std::collections::HashMap<String, serde_json::Value>,
|
||||||
|
component_name: &str,
|
||||||
|
) -> CliResult<entropyk_components::heat_exchanger::BphxDpCorrelation> {
|
||||||
|
use entropyk_components::heat_exchanger::BphxDpCorrelation;
|
||||||
|
|
||||||
|
let Some(value) = params
|
||||||
|
.get("dp_correlation")
|
||||||
|
.or_else(|| params.get("DpCorrelation"))
|
||||||
|
.or_else(|| params.get("dpCorrelation"))
|
||||||
|
else {
|
||||||
|
return Ok(BphxDpCorrelation::default());
|
||||||
|
};
|
||||||
|
let Some(raw) = value.as_str() else {
|
||||||
|
return Err(CliError::Config(format!(
|
||||||
|
"{component_name}: dp_correlation must be a string (got {value})"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
BphxDpCorrelation::parse(raw).map_err(|msg| {
|
||||||
|
CliError::Config(format!("{component_name}: {msg}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract calibration Z-factors for BphxEvaporator/BphxCondenser from JSON params.
|
/// Extract calibration Z-factors for BphxEvaporator/BphxCondenser from JSON params.
|
||||||
///
|
///
|
||||||
/// Errors if `ua_nominal == 0` and an explicit `ua` override is provided (geometry is
|
/// Errors if `ua_nominal == 0` and an explicit `ua` override is provided (geometry is
|
||||||
@@ -5095,7 +5129,11 @@ fn create_component(
|
|||||||
.with_target_superheat(target_superheat)
|
.with_target_superheat(target_superheat)
|
||||||
.with_refrigerant(refrigerant)
|
.with_refrigerant(refrigerant)
|
||||||
.with_secondary_fluid(secondary_fluid)
|
.with_secondary_fluid(secondary_fluid)
|
||||||
.with_fluid_backend(Arc::clone(&backend));
|
.with_fluid_backend(Arc::clone(&backend))
|
||||||
|
.with_dp_correlation(bphx_dp_correlation_from_params(
|
||||||
|
params,
|
||||||
|
"BphxEvaporator",
|
||||||
|
)?);
|
||||||
if let Some(correlation) =
|
if let Some(correlation) =
|
||||||
bphx_correlation_from_params(params, "BphxEvaporator")?
|
bphx_correlation_from_params(params, "BphxEvaporator")?
|
||||||
{
|
{
|
||||||
@@ -5151,7 +5189,11 @@ fn create_component(
|
|||||||
.with_refrigerant(refrigerant)
|
.with_refrigerant(refrigerant)
|
||||||
.with_secondary_fluid(secondary_fluid)
|
.with_secondary_fluid(secondary_fluid)
|
||||||
.with_fluid_backend(Arc::clone(&backend))
|
.with_fluid_backend(Arc::clone(&backend))
|
||||||
.with_target_subcooling(target_subcooling);
|
.with_target_subcooling(target_subcooling)
|
||||||
|
.with_dp_correlation(bphx_dp_correlation_from_params(
|
||||||
|
params,
|
||||||
|
"BphxCondenser",
|
||||||
|
)?);
|
||||||
if let Some(correlation) =
|
if let Some(correlation) =
|
||||||
bphx_correlation_from_params(params, "BphxCondenser")?
|
bphx_correlation_from_params(params, "BphxCondenser")?
|
||||||
{
|
{
|
||||||
@@ -5630,7 +5672,7 @@ mod tests {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&result).unwrap();
|
let json = serde_json::to_string_pretty(&result).unwrap();
|
||||||
@@ -5787,6 +5829,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_dp_correlation_defaults_and_aliases() {
|
||||||
|
use entropyk_components::heat_exchanger::BphxDpCorrelation;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
bphx_dp_correlation_from_params(&std::collections::HashMap::new(), "BphxEvaporator")
|
||||||
|
.unwrap(),
|
||||||
|
BphxDpCorrelation::SimplifiedChannel
|
||||||
|
);
|
||||||
|
let params = std::collections::HashMap::from([(
|
||||||
|
"DpCorrelation".to_string(),
|
||||||
|
json!("Martin1996"),
|
||||||
|
)]);
|
||||||
|
assert_eq!(
|
||||||
|
bphx_dp_correlation_from_params(¶ms, "BphxCondenser").unwrap(),
|
||||||
|
BphxDpCorrelation::Martin1996
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_dp_correlation_rejects_unknown() {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let params = std::collections::HashMap::from([(
|
||||||
|
"dp_correlation".to_string(),
|
||||||
|
json!("Amalfi2016"),
|
||||||
|
)]);
|
||||||
|
let error = bphx_dp_correlation_from_params(¶ms, "BphxEvaporator").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("unsupported dp_correlation"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_dp_correlation_rejects_non_string() {
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
let params = std::collections::HashMap::from([(
|
||||||
|
"dp_correlation".to_string(),
|
||||||
|
json!(42),
|
||||||
|
)]);
|
||||||
|
let error = bphx_dp_correlation_from_params(¶ms, "BphxEvaporator").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("must be a string"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn comp_from_json(v: serde_json::Value) -> crate::config::ComponentConfig {
|
fn comp_from_json(v: serde_json::Value) -> crate::config::ComponentConfig {
|
||||||
serde_json::from_value(v).expect("valid component JSON")
|
serde_json::from_value(v).expect("valid component JSON")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ fn test_simulation_result_statuses() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "fail.json".to_string(),
|
input: "fail.json".to_string(),
|
||||||
@@ -106,7 +106,7 @@ fn test_simulation_result_statuses() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "timeout.json".to_string(),
|
input: "timeout.json".to_string(),
|
||||||
@@ -120,7 +120,7 @@ fn test_simulation_result_statuses() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 1000,
|
elapsed_ms: 1000,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ fn test_batch_aggregator_csv_output() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 150,
|
elapsed_ms: 150,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "scenario2.json".to_string(),
|
input: "scenario2.json".to_string(),
|
||||||
@@ -183,7 +183,7 @@ fn test_batch_aggregator_csv_output() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 200,
|
elapsed_ms: 200,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "scenario3.json".to_string(),
|
input: "scenario3.json".to_string(),
|
||||||
@@ -197,7 +197,7 @@ fn test_batch_aggregator_csv_output() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ fn test_batch_aggregator_json_summary() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test2.json".to_string(),
|
input: "test2.json".to_string(),
|
||||||
@@ -243,7 +243,7 @@ fn test_batch_aggregator_json_summary() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 75,
|
elapsed_ms: 75,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
input: "test3.json".to_string(),
|
input: "test3.json".to_string(),
|
||||||
@@ -257,7 +257,7 @@ fn test_batch_aggregator_json_summary() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 5000,
|
elapsed_ms: 5000,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ fn test_batch_summary_csv_with_convergence() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 300,
|
elapsed_ms: 300,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let summary = BatchSummary {
|
let summary = BatchSummary {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ fn test_simulation_result_serialization() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 50,
|
elapsed_ms: 50,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&result).unwrap();
|
let json = serde_json::to_string_pretty(&result).unwrap();
|
||||||
@@ -70,7 +70,7 @@ fn test_error_result_serialization() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
@@ -96,7 +96,7 @@ fn test_error_result_serializes_failure_diagnostics() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
@@ -2303,7 +2303,7 @@ fn test_structural_failure_serializes_without_diagnostics() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 0,
|
elapsed_ms: 0,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
@@ -2348,7 +2348,7 @@ fn test_success_result_does_not_include_failure_diagnostics() {
|
|||||||
initialization_diagnostics: None,
|
initialization_diagnostics: None,
|
||||||
dof: None,
|
dof: None,
|
||||||
elapsed_ms: 120,
|
elapsed_ms: 120,
|
||||||
raw_state_vector: None,
|
raw_state_vector: None, solved_variables: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
||||||
|
use super::bphx_dp::BphxDpCorrelation;
|
||||||
use super::bphx_exchanger::BphxExchanger;
|
use super::bphx_exchanger::BphxExchanger;
|
||||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||||
use super::correlation_registry::CorrelationSelectionError;
|
use super::correlation_registry::CorrelationSelectionError;
|
||||||
@@ -150,6 +151,17 @@ impl BphxCondenser {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the channel frictional pressure-drop correlation (independent of HTC).
|
||||||
|
pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
|
||||||
|
self.inner = self.inner.with_dp_correlation(correlation);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured pressure-drop correlation.
|
||||||
|
pub fn dp_correlation(&self) -> BphxDpCorrelation {
|
||||||
|
self.inner.dp_correlation()
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the target subcooling in Kelvin.
|
/// Sets the target subcooling in Kelvin.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
|
|||||||
@@ -344,7 +344,11 @@ impl BphxCorrelation {
|
|||||||
CorrelationId::Cooper1984
|
CorrelationId::Cooper1984
|
||||||
| CorrelationId::Mostinski1963
|
| CorrelationId::Mostinski1963
|
||||||
| CorrelationId::Friedel1979
|
| CorrelationId::Friedel1979
|
||||||
| CorrelationId::MullerSteinhagenHeck1986 => None,
|
| CorrelationId::MullerSteinhagenHeck1986
|
||||||
|
| CorrelationId::SimplifiedChannel
|
||||||
|
| CorrelationId::Martin1996
|
||||||
|
| CorrelationId::AmalfiThome2016
|
||||||
|
| CorrelationId::TaoInfanteFerreiraSurvey => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
422
crates/components/src/heat_exchanger/bphx_dp.rs
Normal file
422
crates/components/src/heat_exchanger/bphx_dp.rs
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
//! BPHX channel pressure-drop correlations (friction), separate from HTC.
|
||||||
|
//!
|
||||||
|
//! Pressure-drop form (Fanning-like `f`, matching existing SimplifiedChannel):
|
||||||
|
//! `ΔP = z_dp · 2 · f · L · G² / (ρ · d_h)`.
|
||||||
|
//!
|
||||||
|
//! Martin (1996/1999) returns a Fanning friction factor from the reciprocal-
|
||||||
|
//! square-root chevron model (same intermediate `f` as `fluids` /
|
||||||
|
//! `ht.friction_plate_Martin_1999` before the ×4 Darcy conversion).
|
||||||
|
|
||||||
|
use super::bphx_geometry::BphxGeometry;
|
||||||
|
use crate::ComponentError;
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
/// Reference dynamic viscosity [Pa·s] used for Re in channel DP models.
|
||||||
|
/// Matches the historical SimplifiedChannel implementation (Newton-friendly constant).
|
||||||
|
pub const BPHX_DP_MU_REF_PA_S: f64 = 0.0002;
|
||||||
|
|
||||||
|
/// Selectable BPHX frictional pressure-drop correlation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum BphxDpCorrelation {
|
||||||
|
/// Existing Blasius/laminar C¹ blend (default).
|
||||||
|
#[default]
|
||||||
|
SimplifiedChannel,
|
||||||
|
/// Holger Martin chevron-plate single-phase friction (1996 / 1999 form).
|
||||||
|
Martin1996,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BphxDpCorrelation {
|
||||||
|
/// Stable serialization name.
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::SimplifiedChannel => "SimplifiedChannel",
|
||||||
|
Self::Martin1996 => "Martin1996",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse CLI/UI identifiers. Rejects unknown strings (no silent fallback).
|
||||||
|
pub fn parse(name: &str) -> Result<Self, String> {
|
||||||
|
let key = name.trim().to_ascii_lowercase().replace(['-', '_', ' '], "");
|
||||||
|
match key.as_str() {
|
||||||
|
"simplifiedchannel" | "simplified" | "channel" | "default" => {
|
||||||
|
Ok(Self::SimplifiedChannel)
|
||||||
|
}
|
||||||
|
"martin1996" | "martin1999" | "martin" => Ok(Self::Martin1996),
|
||||||
|
_ => Err(format!(
|
||||||
|
"unsupported dp_correlation '{name}'. Use 'SimplifiedChannel' or 'Martin1996'."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluated channel pressure drop and ∂ΔP/∂G at fixed ρ and z_dp.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BphxDpEval {
|
||||||
|
/// Pressure drop [Pa] (always ≥ 0 for the channel friction model).
|
||||||
|
pub delta_p_pa: f64,
|
||||||
|
/// ∂ΔP/∂G [Pa / (kg/(m²·s))].
|
||||||
|
pub d_delta_p_d_g: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fanning friction factor and ∂f/∂Re for the selected correlation.
|
||||||
|
fn fanning_friction(
|
||||||
|
corr: BphxDpCorrelation,
|
||||||
|
re: f64,
|
||||||
|
chevron_angle_deg: f64,
|
||||||
|
) -> Result<(f64, f64), ComponentError> {
|
||||||
|
match corr {
|
||||||
|
BphxDpCorrelation::SimplifiedChannel => Ok(simplified_channel_fanning(re)),
|
||||||
|
BphxDpCorrelation::Martin1996 => {
|
||||||
|
validate_martin_chevron(chevron_angle_deg)?;
|
||||||
|
Ok(martin1996_fanning(re, chevron_angle_deg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_martin_chevron(angle_deg: f64) -> Result<(), ComponentError> {
|
||||||
|
if !angle_deg.is_finite() || !(0.0..90.0).contains(&angle_deg) {
|
||||||
|
return Err(ComponentError::InvalidState(format!(
|
||||||
|
"Martin1996 requires chevron_angle in (0, 90) degrees (got {angle_deg})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SimplifiedChannel: laminar 64/Re, Blasius 0.079 Re^−0.25, C¹ blend on [2300, 4000].
|
||||||
|
///
|
||||||
|
/// Note: laminar uses the Darcy 64/Re coefficient inside the Fanning ΔP form —
|
||||||
|
/// historical Entropyk convention; kept identical for continuity.
|
||||||
|
fn simplified_channel_fanning(re: f64) -> (f64, f64) {
|
||||||
|
const RE_LAMINAR: f64 = 2300.0;
|
||||||
|
const RE_TURBULENT: f64 = 4000.0;
|
||||||
|
let re_safe = re.max(1e-9);
|
||||||
|
let f_lam = 64.0 / re_safe;
|
||||||
|
let df_lam = -64.0 / (re_safe * re_safe);
|
||||||
|
|
||||||
|
if re <= RE_LAMINAR {
|
||||||
|
return (f_lam, df_lam);
|
||||||
|
}
|
||||||
|
|
||||||
|
let f_turb = 0.079 * re_safe.powf(-0.25);
|
||||||
|
let df_turb = 0.079 * (-0.25) * re_safe.powf(-1.25);
|
||||||
|
|
||||||
|
if re >= RE_TURBULENT {
|
||||||
|
return (f_turb, df_turb);
|
||||||
|
}
|
||||||
|
|
||||||
|
let f = entropyk_core::smoothing::cubic_blend(
|
||||||
|
f_lam,
|
||||||
|
f_turb,
|
||||||
|
re,
|
||||||
|
RE_LAMINAR,
|
||||||
|
RE_TURBULENT,
|
||||||
|
);
|
||||||
|
// d/dRe of blend(f_lam(Re), f_turb(Re), Re): product rule on cubic Hermite.
|
||||||
|
let df_blend_dx = entropyk_core::smoothing::cubic_blend_derivative(
|
||||||
|
f_lam,
|
||||||
|
f_turb,
|
||||||
|
re,
|
||||||
|
RE_LAMINAR,
|
||||||
|
RE_TURBULENT,
|
||||||
|
);
|
||||||
|
// cubic_blend treats endpoints as constants w.r.t x; add chain-rule terms for
|
||||||
|
// f_lam(Re) and f_turb(Re) via the complementary weights.
|
||||||
|
let t = ((re - RE_LAMINAR) / (RE_TURBULENT - RE_LAMINAR)).clamp(0.0, 1.0);
|
||||||
|
let w_b = t * t * (3.0 - 2.0 * t); // weight on b (= f_turb)
|
||||||
|
let w_a = 1.0 - w_b;
|
||||||
|
let df = df_blend_dx + w_a * df_lam + w_b * df_turb;
|
||||||
|
(f, df)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Martin 1996/1999 Fanning friction factor vs Re and chevron angle φ [deg].
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// 1/√f = cosφ / √(0.045 tanφ + 0.09 sinφ + f0/cosφ) + (1−cosφ) / √(3.8 f1)
|
||||||
|
/// ```
|
||||||
|
/// with piecewise `f0`, `f1` (Shah/Martin 1999 appendix form).
|
||||||
|
/// Laminar/turbulent `f0`/`f1` branches are C¹-blended on Re ∈ [1800, 2200]
|
||||||
|
/// (same Newton rationale as SimplifiedChannel’s [2300, 4000] blend).
|
||||||
|
fn martin1996_fanning(re: f64, chevron_angle_deg: f64) -> (f64, f64) {
|
||||||
|
const RE_LO: f64 = 1800.0;
|
||||||
|
const RE_HI: f64 = 2200.0;
|
||||||
|
let re_safe = re.max(1e-9);
|
||||||
|
|
||||||
|
let lam = |re_v: f64| -> (f64, f64, f64, f64) {
|
||||||
|
let f0 = 16.0 / re_v;
|
||||||
|
let df0 = -16.0 / (re_v * re_v);
|
||||||
|
let f1 = 149.0 / re_v + 0.9625;
|
||||||
|
let df1 = -149.0 / (re_v * re_v);
|
||||||
|
(f0, df0, f1, df1)
|
||||||
|
};
|
||||||
|
let turb = |re_v: f64| -> (f64, f64, f64, f64) {
|
||||||
|
let u = 1.56 * re_v.ln() - 3.0;
|
||||||
|
let u_safe = if u.abs() < 1e-12 {
|
||||||
|
if u >= 0.0 {
|
||||||
|
1e-12
|
||||||
|
} else {
|
||||||
|
-1e-12
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
u
|
||||||
|
};
|
||||||
|
let f0 = u_safe.powi(-2);
|
||||||
|
let df0 = -2.0 * u_safe.powi(-3) * (1.56 / re_v);
|
||||||
|
let f1 = 9.75 * re_v.powf(-0.289);
|
||||||
|
let df1 = 9.75 * (-0.289) * re_v.powf(-1.289);
|
||||||
|
(f0, df0, f1, df1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (f0, df0, f1, df1) = if re_safe <= RE_LO {
|
||||||
|
lam(re_safe)
|
||||||
|
} else if re_safe >= RE_HI {
|
||||||
|
turb(re_safe)
|
||||||
|
} else {
|
||||||
|
let (f0_l, df0_l, f1_l, df1_l) = lam(re_safe);
|
||||||
|
let (f0_t, df0_t, f1_t, df1_t) = turb(re_safe);
|
||||||
|
let f0 = entropyk_core::smoothing::cubic_blend(f0_l, f0_t, re_safe, RE_LO, RE_HI);
|
||||||
|
let f1 = entropyk_core::smoothing::cubic_blend(f1_l, f1_t, re_safe, RE_LO, RE_HI);
|
||||||
|
let t = ((re_safe - RE_LO) / (RE_HI - RE_LO)).clamp(0.0, 1.0);
|
||||||
|
let w_b = t * t * (3.0 - 2.0 * t);
|
||||||
|
let w_a = 1.0 - w_b;
|
||||||
|
let df0_blend = entropyk_core::smoothing::cubic_blend_derivative(
|
||||||
|
f0_l, f0_t, re_safe, RE_LO, RE_HI,
|
||||||
|
);
|
||||||
|
let df1_blend = entropyk_core::smoothing::cubic_blend_derivative(
|
||||||
|
f1_l, f1_t, re_safe, RE_LO, RE_HI,
|
||||||
|
);
|
||||||
|
let df0 = df0_blend + w_a * df0_l + w_b * df0_t;
|
||||||
|
let df1 = df1_blend + w_a * df1_l + w_b * df1_t;
|
||||||
|
(f0, df0, f1, df1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let phi = chevron_angle_deg * PI / 180.0;
|
||||||
|
let cos_p = phi.cos();
|
||||||
|
let sin_p = phi.sin();
|
||||||
|
let tan_p = phi.tan();
|
||||||
|
// Guard grazing angles so /cosφ stays finite inside (0, 90).
|
||||||
|
let cos_safe = cos_p.signum() * cos_p.abs().max(1e-12);
|
||||||
|
|
||||||
|
let a = 0.045 * tan_p + 0.09 * sin_p + f0 / cos_safe;
|
||||||
|
let b = 3.8 * f1.max(1e-30);
|
||||||
|
let a_safe = a.max(1e-30);
|
||||||
|
|
||||||
|
let term0 = cos_p / a_safe.sqrt();
|
||||||
|
let term1 = (1.0 - cos_p) / b.sqrt();
|
||||||
|
let rhs = term0 + term1;
|
||||||
|
let rhs_safe = rhs.max(1e-30);
|
||||||
|
let f = rhs_safe.powi(-2);
|
||||||
|
|
||||||
|
let da_dre = df0 / cos_safe;
|
||||||
|
let db_dre = 3.8 * df1;
|
||||||
|
let dterm0 = cos_p * (-0.5) * a_safe.powf(-1.5) * da_dre;
|
||||||
|
let dterm1 = (1.0 - cos_p) * (-0.5) * b.powf(-1.5) * db_dre;
|
||||||
|
let drhs = dterm0 + dterm1;
|
||||||
|
let df = -2.0 * rhs_safe.powi(-3) * drhs;
|
||||||
|
(f, df)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel frictional ΔP and ∂ΔP/∂G for the given correlation.
|
||||||
|
///
|
||||||
|
/// `mass_flux` may be signed; Re uses `|G|`, ΔP uses `G²` (existing convention).
|
||||||
|
pub fn evaluate_channel_pressure_drop(
|
||||||
|
corr: BphxDpCorrelation,
|
||||||
|
geometry: &BphxGeometry,
|
||||||
|
mass_flux: f64,
|
||||||
|
rho: f64,
|
||||||
|
z_dp: f64,
|
||||||
|
) -> Result<BphxDpEval, ComponentError> {
|
||||||
|
if !mass_flux.is_finite() {
|
||||||
|
return Ok(BphxDpEval {
|
||||||
|
delta_p_pa: 0.0,
|
||||||
|
d_delta_p_d_g: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !rho.is_finite() || rho < 1e-10 || geometry.dh < 1e-10 {
|
||||||
|
return Ok(BphxDpEval {
|
||||||
|
delta_p_pa: 0.0,
|
||||||
|
d_delta_p_d_g: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !geometry.plate_length.is_finite() || geometry.plate_length <= 0.0 {
|
||||||
|
return Ok(BphxDpEval {
|
||||||
|
delta_p_pa: 0.0,
|
||||||
|
d_delta_p_d_g: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !z_dp.is_finite() || z_dp < 0.0 {
|
||||||
|
return Err(ComponentError::InvalidState(format!(
|
||||||
|
"BPHX z_dp must be finite and >= 0 (got {z_dp})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let g_abs = mass_flux.abs();
|
||||||
|
let re = g_abs * geometry.dh / BPHX_DP_MU_REF_PA_S;
|
||||||
|
let (f, df_dre) = fanning_friction(corr, re, geometry.chevron_angle)?;
|
||||||
|
|
||||||
|
// ΔP = z_dp · 2 f L G² / (ρ dh)
|
||||||
|
let k = z_dp * 2.0 * geometry.plate_length / (rho * geometry.dh);
|
||||||
|
let g2 = mass_flux * mass_flux;
|
||||||
|
let delta_p_pa = k * f * g2;
|
||||||
|
|
||||||
|
// dRe/dG = sign(G) · dh/μ (0 at stagnation)
|
||||||
|
let d_re_d_g = if g_abs < 1e-30 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
mass_flux.signum() * geometry.dh / BPHX_DP_MU_REF_PA_S
|
||||||
|
};
|
||||||
|
// d(G²)/dG = 2 G
|
||||||
|
let d_delta_p_d_g = k * (df_dre * d_re_d_g * g2 + f * 2.0 * mass_flux);
|
||||||
|
|
||||||
|
Ok(BphxDpEval {
|
||||||
|
delta_p_pa,
|
||||||
|
d_delta_p_d_g,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::heat_exchanger::BphxGeometry;
|
||||||
|
|
||||||
|
fn geo() -> BphxGeometry {
|
||||||
|
BphxGeometry::from_dh_area(0.003, 0.5, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_aliases_rejects_unknown() {
|
||||||
|
assert_eq!(
|
||||||
|
BphxDpCorrelation::parse("SimplifiedChannel").unwrap(),
|
||||||
|
BphxDpCorrelation::SimplifiedChannel
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
BphxDpCorrelation::parse("martin-1996").unwrap(),
|
||||||
|
BphxDpCorrelation::Martin1996
|
||||||
|
);
|
||||||
|
assert!(BphxDpCorrelation::parse("Amalfi2016").is_err());
|
||||||
|
assert!(BphxDpCorrelation::parse("friedel").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simplified_matches_historical_scale() {
|
||||||
|
let g = 30.0;
|
||||||
|
let rho = 1100.0;
|
||||||
|
let eval = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::SimplifiedChannel,
|
||||||
|
&geo(),
|
||||||
|
g,
|
||||||
|
rho,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(eval.delta_p_pa > 0.0);
|
||||||
|
assert!(eval.delta_p_pa.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn martin_differs_from_simplified_same_state() {
|
||||||
|
let g = 80.0;
|
||||||
|
let rho = 1000.0;
|
||||||
|
let simp = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::SimplifiedChannel,
|
||||||
|
&geo(),
|
||||||
|
g,
|
||||||
|
rho,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let martin = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::Martin1996,
|
||||||
|
&geo(),
|
||||||
|
g,
|
||||||
|
rho,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
(simp.delta_p_pa - martin.delta_p_pa).abs() > 1.0,
|
||||||
|
"Martin and Simplified should differ: {} vs {}",
|
||||||
|
simp.delta_p_pa,
|
||||||
|
martin.delta_p_pa
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn z_dp_scales_linearly() {
|
||||||
|
let g = 40.0;
|
||||||
|
let rho = 1100.0;
|
||||||
|
let a = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::SimplifiedChannel,
|
||||||
|
&geo(),
|
||||||
|
g,
|
||||||
|
rho,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let b = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::SimplifiedChannel,
|
||||||
|
&geo(),
|
||||||
|
g,
|
||||||
|
rho,
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!((b.delta_p_pa - 0.5 * a.delta_p_pa).abs() < 1e-9 * a.delta_p_pa.max(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_flow_smooth() {
|
||||||
|
let eval = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::SimplifiedChannel,
|
||||||
|
&geo(),
|
||||||
|
0.0,
|
||||||
|
1100.0,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(eval.delta_p_pa, 0.0);
|
||||||
|
assert!(eval.d_delta_p_d_g.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn martin_rejects_invalid_chevron() {
|
||||||
|
let mut g = geo();
|
||||||
|
g.chevron_angle = f64::NAN;
|
||||||
|
let err = evaluate_channel_pressure_drop(
|
||||||
|
BphxDpCorrelation::Martin1996,
|
||||||
|
&g,
|
||||||
|
30.0,
|
||||||
|
1000.0,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("chevron"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn analytic_d_dg_matches_central_difference() {
|
||||||
|
let geo = geo();
|
||||||
|
let g0 = 55.0;
|
||||||
|
let rho = 1050.0;
|
||||||
|
for corr in [
|
||||||
|
BphxDpCorrelation::SimplifiedChannel,
|
||||||
|
BphxDpCorrelation::Martin1996,
|
||||||
|
] {
|
||||||
|
let eval = evaluate_channel_pressure_drop(corr, &geo, g0, rho, 1.0).unwrap();
|
||||||
|
let h = 1e-4;
|
||||||
|
let plus =
|
||||||
|
evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
|
||||||
|
let minus =
|
||||||
|
evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
|
||||||
|
let fd = (plus.delta_p_pa - minus.delta_p_pa) / (2.0 * h);
|
||||||
|
let rel = (eval.d_delta_p_d_g - fd).abs() / fd.abs().max(1.0);
|
||||||
|
assert!(
|
||||||
|
rel < 1e-4,
|
||||||
|
"{:?}: analytic {} vs fd {} (rel {})",
|
||||||
|
corr,
|
||||||
|
eval.d_delta_p_d_g,
|
||||||
|
fd,
|
||||||
|
rel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
||||||
|
use super::bphx_dp::BphxDpCorrelation;
|
||||||
use super::bphx_exchanger::BphxExchanger;
|
use super::bphx_exchanger::BphxExchanger;
|
||||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||||
use super::correlation_registry::CorrelationSelectionError;
|
use super::correlation_registry::CorrelationSelectionError;
|
||||||
@@ -154,6 +155,17 @@ impl BphxEvaporator {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the channel frictional pressure-drop correlation (independent of HTC).
|
||||||
|
pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
|
||||||
|
self.inner = self.inner.with_dp_correlation(correlation);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured pressure-drop correlation.
|
||||||
|
pub fn dp_correlation(&self) -> BphxDpCorrelation {
|
||||||
|
self.inner.dp_correlation()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the component name.
|
/// Returns the component name.
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
self.inner.name()
|
self.inner.name()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use super::bphx_correlation::{
|
|||||||
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
||||||
CorrelationSelector, ValidityStatus,
|
CorrelationSelector, ValidityStatus,
|
||||||
};
|
};
|
||||||
|
use super::bphx_dp::{evaluate_channel_pressure_drop, BphxDpCorrelation, BphxDpEval};
|
||||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||||
use super::correlation_registry::{
|
use super::correlation_registry::{
|
||||||
CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
|
CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
|
||||||
@@ -55,6 +56,7 @@ pub struct BphxExchanger {
|
|||||||
inner: HeatExchanger<EpsNtuModel>,
|
inner: HeatExchanger<EpsNtuModel>,
|
||||||
geometry: BphxGeometry,
|
geometry: BphxGeometry,
|
||||||
correlation_selector: CorrelationSelector,
|
correlation_selector: CorrelationSelector,
|
||||||
|
dp_correlation: BphxDpCorrelation,
|
||||||
refrigerant_id: String,
|
refrigerant_id: String,
|
||||||
secondary_fluid_id: String,
|
secondary_fluid_id: String,
|
||||||
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
|
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
|
||||||
@@ -70,6 +72,7 @@ impl std::fmt::Debug for BphxExchanger {
|
|||||||
.field("ua", &self.ua())
|
.field("ua", &self.ua())
|
||||||
.field("geometry", &self.geometry)
|
.field("geometry", &self.geometry)
|
||||||
.field("correlation", &self.correlation_selector.correlation)
|
.field("correlation", &self.correlation_selector.correlation)
|
||||||
|
.field("dp_correlation", &self.dp_correlation)
|
||||||
.field("refrigerant_id", &self.refrigerant_id)
|
.field("refrigerant_id", &self.refrigerant_id)
|
||||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||||
@@ -108,6 +111,7 @@ impl BphxExchanger {
|
|||||||
inner: HeatExchanger::new(model, "BphxExchanger"),
|
inner: HeatExchanger::new(model, "BphxExchanger"),
|
||||||
geometry,
|
geometry,
|
||||||
correlation_selector: CorrelationSelector::default(),
|
correlation_selector: CorrelationSelector::default(),
|
||||||
|
dp_correlation: BphxDpCorrelation::default(),
|
||||||
refrigerant_id: String::new(),
|
refrigerant_id: String::new(),
|
||||||
secondary_fluid_id: String::new(),
|
secondary_fluid_id: String::new(),
|
||||||
fluid_backend: None,
|
fluid_backend: None,
|
||||||
@@ -125,6 +129,7 @@ impl BphxExchanger {
|
|||||||
inner: HeatExchanger::new(model, "BphxExchanger"),
|
inner: HeatExchanger::new(model, "BphxExchanger"),
|
||||||
geometry,
|
geometry,
|
||||||
correlation_selector: CorrelationSelector::default(),
|
correlation_selector: CorrelationSelector::default(),
|
||||||
|
dp_correlation: BphxDpCorrelation::default(),
|
||||||
refrigerant_id: String::new(),
|
refrigerant_id: String::new(),
|
||||||
secondary_fluid_id: String::new(),
|
secondary_fluid_id: String::new(),
|
||||||
fluid_backend: None,
|
fluid_backend: None,
|
||||||
@@ -151,6 +156,17 @@ impl BphxExchanger {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets the channel frictional pressure-drop correlation (independent of HTC).
|
||||||
|
pub fn with_dp_correlation(mut self, correlation: BphxDpCorrelation) -> Self {
|
||||||
|
self.dp_correlation = correlation;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured pressure-drop correlation.
|
||||||
|
pub fn dp_correlation(&self) -> BphxDpCorrelation {
|
||||||
|
self.dp_correlation
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the refrigerant fluid identifier.
|
/// Sets the refrigerant fluid identifier.
|
||||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||||
self.set_refrigerant_id(fluid);
|
self.set_refrigerant_id(fluid);
|
||||||
@@ -338,12 +354,10 @@ impl BphxExchanger {
|
|||||||
self.last_validity_warning.set(false);
|
self.last_validity_warning.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the pressure drop using a simplified correlation.
|
/// Computes the channel frictional pressure drop for the configured correlation.
|
||||||
///
|
///
|
||||||
/// ΔP = f_dp × (2 × f × L × G²) / (ρ × d_h)
|
/// ΔP = z_dp × (2 × f × L × G²) / (ρ × d_h) with Fanning-like `f`
|
||||||
///
|
/// (SimplifiedChannel default; Martin1996 when selected).
|
||||||
/// where f is the friction factor, L is the plate length, G is mass flux.
|
|
||||||
/// The result is scaled by `calib().z_dp`.
|
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
@@ -352,46 +366,158 @@ impl BphxExchanger {
|
|||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Pressure drop in Pa, scaled by f_dp calibration factor.
|
/// Pressure drop in Pa, scaled by `calib().z_dp`. Returns 0 on invalid inputs
|
||||||
|
/// or (for Martin) invalid chevron — callers that need errors should use
|
||||||
|
/// [`evaluate_channel_pressure_drop`].
|
||||||
pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
|
pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
|
||||||
if rho < 1e-10 || self.geometry.dh < 1e-10 {
|
evaluate_channel_pressure_drop(
|
||||||
return 0.0;
|
self.dp_correlation,
|
||||||
|
&self.geometry,
|
||||||
|
mass_flux,
|
||||||
|
rho,
|
||||||
|
self.calib().z_dp,
|
||||||
|
)
|
||||||
|
.map(|e| e.delta_p_pa)
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live `z_dp`: free actuator from state when indexed, else calib value.
|
||||||
|
fn live_z_dp(&self, state: &StateSlice) -> f64 {
|
||||||
|
self.inner
|
||||||
|
.calib_indices_ref()
|
||||||
|
.z_dp
|
||||||
|
.and_then(|idx| state.get(idx).copied())
|
||||||
|
.unwrap_or_else(|| self.calib().z_dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn side_channel_dp(
|
||||||
|
&self,
|
||||||
|
mass_flow: f64,
|
||||||
|
rho: f64,
|
||||||
|
z_dp: f64,
|
||||||
|
) -> Result<BphxDpEval, ComponentError> {
|
||||||
|
let g = self.geometry.mass_flux(mass_flow);
|
||||||
|
evaluate_channel_pressure_drop(self.dp_correlation, &self.geometry, g, rho, z_dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites the two 4-port pressure rows with `P_out − P_in + ΔP = 0`.
|
||||||
|
fn overwrite_pressure_closures_with_dp(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
residuals: &mut ResidualVector,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
let Some(edges) = self.inner.four_port_edges() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let row0 = self.inner.model_n_equations();
|
||||||
|
if residuals.len() < row0 + 2 {
|
||||||
|
return Err(ComponentError::InvalidResidualDimensions {
|
||||||
|
expected: row0 + 2,
|
||||||
|
actual: residuals.len(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let re = mass_flux.abs() * self.geometry.dh / 0.0002;
|
let z_dp = self.live_z_dp(state);
|
||||||
// C¹ laminar→turbulent blend over [2300, 4000] (same rationale as the
|
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||||
// pipe friction factor and the Gnielinski correlation): a hard switch at
|
let (_, p_h_out, _) = edges.hot_out;
|
||||||
// Re = 2300 put a kink in the pressure-drop residual that the analytic
|
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||||
// Newton Jacobian could not see, hurting convergence near the transition.
|
let (_, p_c_out, _) = edges.cold_out;
|
||||||
// Reynolds uses |mass_flux| so transient reverse flow during iteration
|
|
||||||
// yields a physical friction factor instead of the flat `64` the old
|
let max_idx = [m_h, p_h_in, h_h_in, p_h_out, m_c, p_c_in, h_c_in, p_c_out]
|
||||||
// signed/`max(1.0)` form produced. The lower clamp only guards the exact
|
.into_iter()
|
||||||
// div-by-zero at stagnation; at 1e-9 it is far below any reachable Re, so
|
.max()
|
||||||
// it introduces no visible kink (the previous `max(1.0)` kinked at Re = 1).
|
.unwrap_or(0);
|
||||||
const RE_LAMINAR: f64 = 2300.0;
|
if max_idx >= state.len() {
|
||||||
const RE_TURBULENT: f64 = 4000.0;
|
return Err(ComponentError::InvalidStateDimensions {
|
||||||
let f_laminar = 64.0 / re.max(1e-9);
|
expected: max_idx + 1,
|
||||||
let f = if re <= RE_LAMINAR {
|
actual: state.len(),
|
||||||
f_laminar
|
});
|
||||||
} else {
|
}
|
||||||
let f_turbulent = 0.079 * re.powf(-0.25);
|
|
||||||
if re >= RE_TURBULENT {
|
let rho_hot = self.inner.side_density(
|
||||||
f_turbulent
|
"hot",
|
||||||
} else {
|
self.inner.hot_fluid_id_str(),
|
||||||
entropyk_core::smoothing::cubic_blend(
|
state[p_h_in],
|
||||||
f_laminar,
|
state[h_h_in],
|
||||||
f_turbulent,
|
)?;
|
||||||
re,
|
let rho_cold = self.inner.side_density(
|
||||||
RE_LAMINAR,
|
"cold",
|
||||||
RE_TURBULENT,
|
self.inner.cold_fluid_id_str(),
|
||||||
)
|
state[p_c_in],
|
||||||
}
|
state[h_c_in],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
|
||||||
|
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
|
||||||
|
|
||||||
|
residuals[row0] = state[p_h_out] - state[p_h_in] + dp_hot.delta_p_pa;
|
||||||
|
residuals[row0 + 1] = state[p_c_out] - state[p_c_in] + dp_cold.delta_p_pa;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds analytic ∂r/∂ṁ and ∂r/∂z_dp for BPHX pressure rows (P entries already set).
|
||||||
|
fn append_pressure_dp_jacobian(
|
||||||
|
&self,
|
||||||
|
state: &StateSlice,
|
||||||
|
jacobian: &mut JacobianBuilder,
|
||||||
|
) -> Result<(), ComponentError> {
|
||||||
|
let Some(edges) = self.inner.four_port_edges() else {
|
||||||
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
let row0 = self.inner.model_n_equations();
|
||||||
|
let z_dp = self.live_z_dp(state);
|
||||||
|
let (m_h, p_h_in, h_h_in) = edges.hot_in;
|
||||||
|
let (_, p_h_out, _) = edges.hot_out;
|
||||||
|
let (m_c, p_c_in, h_c_in) = edges.cold_in;
|
||||||
|
let (_, p_c_out, _) = edges.cold_out;
|
||||||
|
|
||||||
let dp_base =
|
let max_idx = [m_h, p_h_in, h_h_in, p_h_out, m_c, p_c_in, h_c_in, p_c_out]
|
||||||
2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh);
|
.into_iter()
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
if max_idx >= state.len() {
|
||||||
|
return Err(ComponentError::InvalidStateDimensions {
|
||||||
|
expected: max_idx + 1,
|
||||||
|
actual: state.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dp_base * self.calib().z_dp
|
let rho_hot = self.inner.side_density(
|
||||||
|
"hot",
|
||||||
|
self.inner.hot_fluid_id_str(),
|
||||||
|
state[p_h_in],
|
||||||
|
state[h_h_in],
|
||||||
|
)?;
|
||||||
|
let rho_cold = self.inner.side_density(
|
||||||
|
"cold",
|
||||||
|
self.inner.cold_fluid_id_str(),
|
||||||
|
state[p_c_in],
|
||||||
|
state[h_c_in],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
|
||||||
|
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
|
||||||
|
|
||||||
|
let a_flow = self.geometry.channel_flow_area() * self.geometry.n_channels() as f64;
|
||||||
|
if a_flow <= 1e-30 {
|
||||||
|
return Err(ComponentError::InvalidState(
|
||||||
|
"BPHX channel flow area too small for pressure-drop Jacobian".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let d_g_d_m = 1.0 / a_flow;
|
||||||
|
|
||||||
|
// r = P_out − P_in + ΔP(G(ṁ), z_dp) ⇒ ∂r/∂ṁ = ∂ΔP/∂G · ∂G/∂ṁ
|
||||||
|
jacobian.add_entry(row0, m_h, dp_hot.d_delta_p_d_g * d_g_d_m);
|
||||||
|
jacobian.add_entry(row0 + 1, m_c, dp_cold.d_delta_p_d_g * d_g_d_m);
|
||||||
|
|
||||||
|
if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
|
||||||
|
// ΔP = z_dp · ΔP_corr ⇒ ∂r/∂z_dp = ΔP_corr = ΔP / z_dp
|
||||||
|
// Keep the column even near z_dp→0 so a free actuator stays visible to Newton.
|
||||||
|
let z_safe = z_dp.max(1e-30);
|
||||||
|
jacobian.add_entry(row0, z_idx, dp_hot.delta_p_pa / z_safe);
|
||||||
|
jacobian.add_entry(row0 + 1, z_idx, dp_cold.delta_p_pa / z_safe);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates UA based on computed HTC.
|
/// Updates UA based on computed HTC.
|
||||||
@@ -413,7 +539,9 @@ impl Component for BphxExchanger {
|
|||||||
state: &StateSlice,
|
state: &StateSlice,
|
||||||
residuals: &mut ResidualVector,
|
residuals: &mut ResidualVector,
|
||||||
) -> Result<(), ComponentError> {
|
) -> Result<(), ComponentError> {
|
||||||
self.inner.compute_residuals(state, residuals)
|
self.inner.compute_residuals(state, residuals)?;
|
||||||
|
// Replace isobaric P_out − P_in with channel ΔP on both sides.
|
||||||
|
self.overwrite_pressure_closures_with_dp(state, residuals)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jacobian_entries(
|
fn jacobian_entries(
|
||||||
@@ -421,7 +549,9 @@ impl Component for BphxExchanger {
|
|||||||
state: &StateSlice,
|
state: &StateSlice,
|
||||||
jacobian: &mut JacobianBuilder,
|
jacobian: &mut JacobianBuilder,
|
||||||
) -> Result<(), ComponentError> {
|
) -> Result<(), ComponentError> {
|
||||||
self.inner.jacobian_entries(state, jacobian)
|
self.inner.jacobian_entries(state, jacobian)?;
|
||||||
|
// Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=−1; add ṁ and z_dp terms.
|
||||||
|
self.append_pressure_dp_jacobian(state, jacobian)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_ports(&self) -> &[ConnectedPort] {
|
fn get_ports(&self) -> &[ConnectedPort] {
|
||||||
@@ -844,4 +974,131 @@ mod tests {
|
|||||||
"ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
|
"ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_both_sides_pressure_residuals_include_dp() {
|
||||||
|
use entropyk_fluids::TestBackend;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// State layout per port triple (ṁ, P, h):
|
||||||
|
// hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
|
||||||
|
let mut hx = BphxExchanger::new(test_geometry())
|
||||||
|
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
|
hx.set_hot_fluid("Water");
|
||||||
|
hx.set_cold_fluid("Water");
|
||||||
|
hx.set_port_context(&[
|
||||||
|
Some((0, 1, 2)),
|
||||||
|
Some((3, 4, 5)),
|
||||||
|
Some((6, 7, 8)),
|
||||||
|
Some((9, 10, 11)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut state = vec![0.0; 12];
|
||||||
|
// Liquid water-ish P-h (TestBackend): ~2 bar, ~80 kJ/kg
|
||||||
|
state[0] = 0.25; // m_hot
|
||||||
|
state[1] = 200_000.0;
|
||||||
|
state[2] = 80_000.0;
|
||||||
|
state[3] = 0.25;
|
||||||
|
state[4] = 200_000.0; // isobaric guess — residual should show +ΔP
|
||||||
|
state[5] = 70_000.0;
|
||||||
|
state[6] = 0.20; // m_cold
|
||||||
|
state[7] = 200_000.0;
|
||||||
|
state[8] = 50_000.0;
|
||||||
|
state[9] = 0.20;
|
||||||
|
state[10] = 200_000.0;
|
||||||
|
state[11] = 60_000.0;
|
||||||
|
|
||||||
|
let n = hx.n_equations();
|
||||||
|
assert!(n >= 4, "4-port BPHX should include two pressure rows");
|
||||||
|
let mut residuals = vec![0.0; n];
|
||||||
|
hx.compute_residuals(&state, &mut residuals).unwrap();
|
||||||
|
|
||||||
|
let row_p = hx.inner.model_n_equations();
|
||||||
|
assert!(
|
||||||
|
residuals[row_p].abs() > 1.0,
|
||||||
|
"hot pressure residual should include ΔP, got {}",
|
||||||
|
residuals[row_p]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
residuals[row_p + 1].abs() > 1.0,
|
||||||
|
"cold pressure residual should include ΔP, got {}",
|
||||||
|
residuals[row_p + 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
// z_dp scale: half calib → half residual contribution at same P_out=P_in
|
||||||
|
let mut hx_half = BphxExchanger::new(test_geometry())
|
||||||
|
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
|
hx_half.set_hot_fluid("Water");
|
||||||
|
hx_half.set_cold_fluid("Water");
|
||||||
|
hx_half.set_port_context(&[
|
||||||
|
Some((0, 1, 2)),
|
||||||
|
Some((3, 4, 5)),
|
||||||
|
Some((6, 7, 8)),
|
||||||
|
Some((9, 10, 11)),
|
||||||
|
]);
|
||||||
|
let mut calib = Calib::default();
|
||||||
|
calib.z_dp = 0.5;
|
||||||
|
hx_half.set_calib(calib);
|
||||||
|
let mut r_half = vec![0.0; n];
|
||||||
|
hx_half.compute_residuals(&state, &mut r_half).unwrap();
|
||||||
|
assert!(
|
||||||
|
(r_half[row_p] - 0.5 * residuals[row_p]).abs() < 1e-6 * residuals[row_p].abs().max(1.0),
|
||||||
|
"z_dp should scale hot ΔP residual"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(r_half[row_p + 1] - 0.5 * residuals[row_p + 1]).abs()
|
||||||
|
< 1e-6 * residuals[row_p + 1].abs().max(1.0),
|
||||||
|
"z_dp should scale cold ΔP residual"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_martin_dp_in_residuals_differs() {
|
||||||
|
use entropyk_fluids::TestBackend;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
let ports = [
|
||||||
|
Some((0, 1, 2)),
|
||||||
|
Some((3, 4, 5)),
|
||||||
|
Some((6, 7, 8)),
|
||||||
|
Some((9, 10, 11)),
|
||||||
|
];
|
||||||
|
let mut state = vec![0.0; 12];
|
||||||
|
state[0] = 0.30;
|
||||||
|
state[1] = 200_000.0;
|
||||||
|
state[2] = 80_000.0;
|
||||||
|
state[3] = 0.30;
|
||||||
|
state[4] = 200_000.0;
|
||||||
|
state[5] = 70_000.0;
|
||||||
|
state[6] = 0.30;
|
||||||
|
state[7] = 200_000.0;
|
||||||
|
state[8] = 50_000.0;
|
||||||
|
state[9] = 0.30;
|
||||||
|
state[10] = 200_000.0;
|
||||||
|
state[11] = 60_000.0;
|
||||||
|
|
||||||
|
let mut simp = BphxExchanger::new(test_geometry())
|
||||||
|
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
|
simp.set_hot_fluid("Water");
|
||||||
|
simp.set_cold_fluid("Water");
|
||||||
|
simp.set_port_context(&ports);
|
||||||
|
|
||||||
|
let mut martin = BphxExchanger::new(test_geometry())
|
||||||
|
.with_dp_correlation(BphxDpCorrelation::Martin1996)
|
||||||
|
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||||
|
martin.set_hot_fluid("Water");
|
||||||
|
martin.set_cold_fluid("Water");
|
||||||
|
martin.set_port_context(&ports);
|
||||||
|
|
||||||
|
let n = simp.n_equations();
|
||||||
|
let mut r_s = vec![0.0; n];
|
||||||
|
let mut r_m = vec![0.0; n];
|
||||||
|
simp.compute_residuals(&state, &mut r_s).unwrap();
|
||||||
|
martin.compute_residuals(&state, &mut r_m).unwrap();
|
||||||
|
let row = simp.inner.model_n_equations();
|
||||||
|
assert!(
|
||||||
|
(r_s[row] - r_m[row]).abs() > 1.0,
|
||||||
|
"Martin residual should differ from Simplified"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ pub enum CorrelationId {
|
|||||||
Friedel1979,
|
Friedel1979,
|
||||||
/// Müller-Steinhagen-Heck (1986) two-phase pressure-drop correlation.
|
/// Müller-Steinhagen-Heck (1986) two-phase pressure-drop correlation.
|
||||||
MullerSteinhagenHeck1986,
|
MullerSteinhagenHeck1986,
|
||||||
|
/// BPHX SimplifiedChannel single-phase channel friction (default ΔP).
|
||||||
|
SimplifiedChannel,
|
||||||
|
/// Martin (1996/1999) chevron-plate single-phase friction.
|
||||||
|
Martin1996,
|
||||||
|
/// Amalfi–Thome (2016) boiling plate ΔP — metadata only (not implemented).
|
||||||
|
AmalfiThome2016,
|
||||||
|
/// Tao & Infante Ferreira condensation-in-BPHE DP survey — metadata only.
|
||||||
|
TaoInfanteFerreiraSurvey,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CorrelationId {
|
impl CorrelationId {
|
||||||
@@ -58,6 +66,10 @@ impl CorrelationId {
|
|||||||
Self::Mostinski1963 => "mostinski-1963",
|
Self::Mostinski1963 => "mostinski-1963",
|
||||||
Self::Friedel1979 => "friedel-1979",
|
Self::Friedel1979 => "friedel-1979",
|
||||||
Self::MullerSteinhagenHeck1986 => "muller-steinhagen-heck-1986",
|
Self::MullerSteinhagenHeck1986 => "muller-steinhagen-heck-1986",
|
||||||
|
Self::SimplifiedChannel => "simplified-channel",
|
||||||
|
Self::Martin1996 => "martin-1996",
|
||||||
|
Self::AmalfiThome2016 => "amalfi-thome-2016",
|
||||||
|
Self::TaoInfanteFerreiraSurvey => "tao-infante-ferreira-survey",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +349,28 @@ const POOL_BOILING_GEOMETRIES: &[ExchangerGeometryType] = &[
|
|||||||
ExchangerGeometryType::FinnedTube,
|
ExchangerGeometryType::FinnedTube,
|
||||||
];
|
];
|
||||||
const DP_TUBE_GEOMETRIES: &[ExchangerGeometryType] = &[ExchangerGeometryType::SmoothTube];
|
const DP_TUBE_GEOMETRIES: &[ExchangerGeometryType] = &[ExchangerGeometryType::SmoothTube];
|
||||||
|
const BPHX_DP_BOUNDS: &[BoundedQuantity] = &[
|
||||||
|
bound(
|
||||||
|
BoundedQuantityKind::Reynolds,
|
||||||
|
1.0,
|
||||||
|
100_000.0,
|
||||||
|
SINGLE_PHASE_REGIMES,
|
||||||
|
),
|
||||||
|
bound(
|
||||||
|
BoundedQuantityKind::MassFlux,
|
||||||
|
1.0,
|
||||||
|
2000.0,
|
||||||
|
SINGLE_PHASE_REGIMES,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const AMALFI_BOUNDS: &[BoundedQuantity] = &[
|
||||||
|
bound(BoundedQuantityKind::MassFlux, 5.0, 600.0, EVAPORATION),
|
||||||
|
bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION),
|
||||||
|
];
|
||||||
|
const TAO_BOUNDS: &[BoundedQuantity] = &[
|
||||||
|
bound(BoundedQuantityKind::MassFlux, 5.0, 600.0, CONDENSATION),
|
||||||
|
bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
|
||||||
|
];
|
||||||
|
|
||||||
const fn bound(
|
const fn bound(
|
||||||
kind: BoundedQuantityKind,
|
kind: BoundedQuantityKind,
|
||||||
@@ -481,6 +515,42 @@ pub fn correlation_metadata(id: CorrelationId) -> CorrelationMetadata {
|
|||||||
RefrigerantApplicability::EvidenceIncomplete,
|
RefrigerantApplicability::EvidenceIncomplete,
|
||||||
"Muller-Steinhagen, H., Heck, K. (1986). Chem. Eng. Process. 20, 297-308",
|
"Muller-Steinhagen, H., Heck, K. (1986). Chem. Eng. Process. 20, 297-308",
|
||||||
),
|
),
|
||||||
|
CorrelationId::SimplifiedChannel => (
|
||||||
|
"BPHX SimplifiedChannel (default)",
|
||||||
|
CorrelationPurpose::PressureDrop,
|
||||||
|
PLATES,
|
||||||
|
SINGLE_PHASE_REGIMES,
|
||||||
|
BPHX_DP_BOUNDS,
|
||||||
|
RefrigerantApplicability::FluidAgnostic,
|
||||||
|
"Entropyk channel Blasius/laminar C¹ blend; ΔP = z_dp·2 f L G²/(ρ dh)",
|
||||||
|
),
|
||||||
|
CorrelationId::Martin1996 => (
|
||||||
|
"Martin (1996/1999) chevron plate friction",
|
||||||
|
CorrelationPurpose::PressureDrop,
|
||||||
|
PLATES,
|
||||||
|
SINGLE_PHASE_REGIMES,
|
||||||
|
BPHX_DP_BOUNDS,
|
||||||
|
RefrigerantApplicability::FluidAgnostic,
|
||||||
|
"Martin, H. (1996). Chem. Eng. Process. 35:301–310; Martin 1999 / Shah appendix form",
|
||||||
|
),
|
||||||
|
CorrelationId::AmalfiThome2016 => (
|
||||||
|
"Amalfi–Thome (2016) boiling plate ΔP [not implemented]",
|
||||||
|
CorrelationPurpose::PressureDrop,
|
||||||
|
PLATES,
|
||||||
|
EVAPORATION,
|
||||||
|
AMALFI_BOUNDS,
|
||||||
|
RefrigerantApplicability::EvidenceIncomplete,
|
||||||
|
"Amalfi, Vakili-Farahani, Thome (2016). Int. J. Refrigeration — Parts 1–2; deferred",
|
||||||
|
),
|
||||||
|
CorrelationId::TaoInfanteFerreiraSurvey => (
|
||||||
|
"Tao & Infante Ferreira condensation BPHE DP survey [not implemented]",
|
||||||
|
CorrelationPurpose::PressureDrop,
|
||||||
|
PLATES,
|
||||||
|
CONDENSATION,
|
||||||
|
TAO_BOUNDS,
|
||||||
|
RefrigerantApplicability::EvidenceIncomplete,
|
||||||
|
"Tao & Infante Ferreira — condensation-in-BPHE pressure-drop survey; deferred",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
CorrelationMetadata {
|
CorrelationMetadata {
|
||||||
id,
|
id,
|
||||||
@@ -516,6 +586,10 @@ pub fn registered_correlations() -> Vec<CorrelationMetadata> {
|
|||||||
CorrelationId::Mostinski1963,
|
CorrelationId::Mostinski1963,
|
||||||
CorrelationId::Friedel1979,
|
CorrelationId::Friedel1979,
|
||||||
CorrelationId::MullerSteinhagenHeck1986,
|
CorrelationId::MullerSteinhagenHeck1986,
|
||||||
|
CorrelationId::SimplifiedChannel,
|
||||||
|
CorrelationId::Martin1996,
|
||||||
|
CorrelationId::AmalfiThome2016,
|
||||||
|
CorrelationId::TaoInfanteFerreiraSurvey,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(correlation_metadata)
|
.map(correlation_metadata)
|
||||||
@@ -1369,16 +1443,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_does_not_claim_unverified_fluid_agnostic_coverage() {
|
fn registry_does_not_claim_unverified_fluid_agnostic_coverage() {
|
||||||
assert!(registered_correlations().iter().all(|metadata| matches!(
|
// Geometry-based single-phase BPHX channel DP may declare FluidAgnostic;
|
||||||
metadata.domain.refrigerants,
|
// HTC / two-phase entries must stay EvidenceIncomplete until verified.
|
||||||
RefrigerantApplicability::EvidenceIncomplete
|
assert!(registered_correlations().iter().all(|metadata| {
|
||||||
)));
|
matches!(
|
||||||
|
metadata.domain.refrigerants,
|
||||||
|
RefrigerantApplicability::EvidenceIncomplete
|
||||||
|
) || matches!(
|
||||||
|
metadata.id,
|
||||||
|
CorrelationId::SimplifiedChannel | CorrelationId::Martin1996
|
||||||
|
)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn central_registry_contains_heat_transfer_and_pressure_drop_entries() {
|
fn central_registry_contains_heat_transfer_and_pressure_drop_entries() {
|
||||||
let registered = registered_correlations();
|
let registered = registered_correlations();
|
||||||
assert_eq!(registered.len(), 14);
|
assert_eq!(registered.len(), 18);
|
||||||
assert!(registered
|
assert!(registered
|
||||||
.iter()
|
.iter()
|
||||||
.any(|entry| entry.id == CorrelationId::Friedel1979
|
.any(|entry| entry.id == CorrelationId::Friedel1979
|
||||||
@@ -1387,6 +1468,18 @@ mod tests {
|
|||||||
entry.id == CorrelationId::MullerSteinhagenHeck1986
|
entry.id == CorrelationId::MullerSteinhagenHeck1986
|
||||||
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||||
}));
|
}));
|
||||||
|
assert!(registered.iter().any(|entry| {
|
||||||
|
entry.id == CorrelationId::SimplifiedChannel
|
||||||
|
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||||
|
}));
|
||||||
|
assert!(registered.iter().any(|entry| {
|
||||||
|
entry.id == CorrelationId::Martin1996
|
||||||
|
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||||
|
}));
|
||||||
|
assert!(registered.iter().any(|entry| {
|
||||||
|
entry.id == CorrelationId::AmalfiThome2016
|
||||||
|
&& entry.domain.purpose == CorrelationPurpose::PressureDrop
|
||||||
|
}));
|
||||||
assert!(registered
|
assert!(registered
|
||||||
.iter()
|
.iter()
|
||||||
.any(|entry| entry.id == CorrelationId::Cooper1984));
|
.any(|entry| entry.id == CorrelationId::Cooper1984));
|
||||||
|
|||||||
@@ -163,6 +163,15 @@ impl HxSideConditions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live four-port edge indices: each triple is `(ṁ_idx, P_idx, h_idx)`.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct FourPortEdgeIndices {
|
||||||
|
pub hot_in: (usize, usize, usize),
|
||||||
|
pub hot_out: (usize, usize, usize),
|
||||||
|
pub cold_in: (usize, usize, usize),
|
||||||
|
pub cold_out: (usize, usize, usize),
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic heat exchanger component with 4 ports.
|
/// Generic heat exchanger component with 4 ports.
|
||||||
///
|
///
|
||||||
/// Uses the Strategy Pattern for heat transfer calculations via the
|
/// Uses the Strategy Pattern for heat transfer calculations via the
|
||||||
@@ -516,7 +525,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
|
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
|
||||||
fn edges_ready(&self) -> bool {
|
pub(crate) fn edges_ready(&self) -> bool {
|
||||||
self.hot_in_idx.is_some()
|
self.hot_in_idx.is_some()
|
||||||
&& self.hot_out_idx.is_some()
|
&& self.hot_out_idx.is_some()
|
||||||
&& self.cold_in_idx.is_some()
|
&& self.cold_in_idx.is_some()
|
||||||
@@ -525,6 +534,60 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
|||||||
&& !self.cold_fluid_id_str.is_empty()
|
&& !self.cold_fluid_id_str.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live four-port edge index triples `(ṁ, P, h)` when wired.
|
||||||
|
pub(crate) fn four_port_edges(&self) -> Option<FourPortEdgeIndices> {
|
||||||
|
if !self.edges_ready() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(FourPortEdgeIndices {
|
||||||
|
hot_in: self.hot_in_idx?,
|
||||||
|
hot_out: self.hot_out_idx?,
|
||||||
|
cold_in: self.cold_in_idx?,
|
||||||
|
cold_out: self.cold_out_idx?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thermal-model equation count (excludes the two pressure-closure rows).
|
||||||
|
pub(crate) fn model_n_equations(&self) -> usize {
|
||||||
|
self.model.n_equations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calibration state indices (e.g. free `z_dp` actuator).
|
||||||
|
pub(crate) fn calib_indices_ref(&self) -> &entropyk_core::CalibIndices {
|
||||||
|
&self.calib_indices
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hot-side fluid id used for live property queries.
|
||||||
|
pub(crate) fn hot_fluid_id_str(&self) -> &str {
|
||||||
|
&self.hot_fluid_id_str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cold-side fluid id used for live property queries.
|
||||||
|
pub(crate) fn cold_fluid_id_str(&self) -> &str {
|
||||||
|
&self.cold_fluid_id_str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inlet density [kg/m³] from the attached fluid backend at `(P, h)`.
|
||||||
|
pub(crate) fn side_density(
|
||||||
|
&self,
|
||||||
|
side: &str,
|
||||||
|
fluid_id: &str,
|
||||||
|
p_pa: f64,
|
||||||
|
h_jkg: f64,
|
||||||
|
) -> Result<f64, ComponentError> {
|
||||||
|
self.query_live_property(side, fluid_id, Property::Density, p_pa, h_jkg)
|
||||||
|
.and_then(|rho| {
|
||||||
|
if rho.is_finite() && rho > 1e-10 {
|
||||||
|
Ok(rho)
|
||||||
|
} else {
|
||||||
|
Err(ComponentError::DomainViolation(DomainViolation {
|
||||||
|
component: Some(self.name.clone()),
|
||||||
|
detail: format!("{} {}-side density is invalid: {}", self.name, side, rho),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn live_state_required_error(&self) -> ComponentError {
|
fn live_state_required_error(&self) -> ComponentError {
|
||||||
ComponentError::InvalidState(format!(
|
ComponentError::InvalidState(format!(
|
||||||
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
|
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
pub mod air_cooled_condenser;
|
pub mod air_cooled_condenser;
|
||||||
pub mod bphx_condenser;
|
pub mod bphx_condenser;
|
||||||
pub mod bphx_correlation;
|
pub mod bphx_correlation;
|
||||||
|
pub mod bphx_dp;
|
||||||
pub mod bphx_evaporator;
|
pub mod bphx_evaporator;
|
||||||
pub mod bphx_exchanger;
|
pub mod bphx_exchanger;
|
||||||
pub mod bphx_geometry;
|
pub mod bphx_geometry;
|
||||||
@@ -83,6 +84,7 @@ pub use bphx_correlation::{
|
|||||||
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
||||||
CorrelationSelector, ValidityStatus,
|
CorrelationSelector, ValidityStatus,
|
||||||
};
|
};
|
||||||
|
pub use bphx_dp::{evaluate_channel_pressure_drop, BphxDpCorrelation, BphxDpEval};
|
||||||
pub use bphx_evaporator::BphxEvaporator;
|
pub use bphx_evaporator::BphxEvaporator;
|
||||||
pub use bphx_exchanger::BphxExchanger;
|
pub use bphx_exchanger::BphxExchanger;
|
||||||
pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType};
|
pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType};
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ pub use free_cooling_exchanger::{
|
|||||||
};
|
};
|
||||||
pub use heat_exchanger::model::FluidState;
|
pub use heat_exchanger::model::FluidState;
|
||||||
pub use heat_exchanger::{
|
pub use heat_exchanger::{
|
||||||
AirCooledCondenser, CoilGeometry, Condenser, CondenserCoil, CondenserRating, Economizer,
|
AirCooledCondenser, BphxDpCorrelation, CoilGeometry, Condenser, CondenserCoil, CondenserRating,
|
||||||
EpsNtuModel, Evaporator, EvaporatorCoil, EvaporatorRating, ExchangerType, FanCoilUnit,
|
Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, EvaporatorRating, ExchangerType,
|
||||||
FinCoilCondenser, FinType, FloodedCondenser, FloodedEvaporator, FloodedPoolBoilingConfig,
|
FanCoilUnit, FinCoilCondenser, FinType, FloodedCondenser, FloodedEvaporator,
|
||||||
FlowConfiguration, GasCooler, HeatExchanger, HeatExchangerBuilder, HeatTransferModel,
|
FloodedPoolBoilingConfig, FlowConfiguration, GasCooler, HeatExchanger, HeatExchangerBuilder,
|
||||||
HxSideConditions, LmtdModel, MchxCondenserCoil, ShellAndTubeHx, UaMode,
|
HeatTransferModel, HxSideConditions, LmtdModel, MchxCondenserCoil, ShellAndTubeHx, UaMode,
|
||||||
};
|
};
|
||||||
pub use heat_source::HeatSource;
|
pub use heat_source::HeatSource;
|
||||||
pub use isenthalpic_expansion_valve::IsenthalpicExpansionValve;
|
pub use isenthalpic_expansion_valve::IsenthalpicExpansionValve;
|
||||||
|
|||||||
@@ -174,8 +174,9 @@ pub use builder::{SystemBuilder, SystemBuilderError};
|
|||||||
|
|
||||||
mod result;
|
mod result;
|
||||||
pub use result::{
|
pub use result::{
|
||||||
extract_simulation_result, ComponentResult, ConvergenceSummary, EdgeResult, EnergyResult,
|
extract_simulation_result, extract_solved_variables, ComponentResult, ConvergenceSummary,
|
||||||
PortState, SimulationOutcome, SimulationResult, SystemSummary,
|
EdgeResult, EnergyResult, PortState, SimulationOutcome, SimulationResult, SolvedVariable,
|
||||||
|
SystemSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -173,6 +173,36 @@ pub struct SystemSummary {
|
|||||||
pub cop_heating: Option<f64>,
|
pub cop_heating: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Solved unknown (free actuator / calibration factor)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A named solver-computed unknown with its converged value and physical bounds.
|
||||||
|
///
|
||||||
|
/// These correspond to bounded control variables that the Newton solve treats
|
||||||
|
/// as unknowns — free actuators (e.g. expansion-valve `opening`, condenser
|
||||||
|
/// `fan_speed`), hard inverse-control links, and saturated-controller
|
||||||
|
/// actuators (calibration factors like `z_ua`, `z_dp`, `z_flow`). They ride
|
||||||
|
/// inside the flat `raw_state_vector` returned by the solver; this struct
|
||||||
|
/// attaches a stable id, owning component, short label, and `[min, max]`
|
||||||
|
/// bounds so the UI can surface them without parsing the raw vector.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SolvedVariable {
|
||||||
|
/// Stable id from [`BoundedVariableId`] (e.g. `"exv__opening"`).
|
||||||
|
pub id: String,
|
||||||
|
/// Owning component name, when known (`None` for globals).
|
||||||
|
pub component: Option<String>,
|
||||||
|
/// Short human label, e.g. `"opening"`, `"z_ua"`, `"z_dp"`.
|
||||||
|
pub variable: String,
|
||||||
|
/// Converged value of the unknown.
|
||||||
|
pub value: f64,
|
||||||
|
/// Lower bound (inclusive).
|
||||||
|
pub min: f64,
|
||||||
|
/// Upper bound (inclusive).
|
||||||
|
pub max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Top-level SimulationResult
|
// Top-level SimulationResult
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -195,6 +225,12 @@ pub struct SimulationResult {
|
|||||||
pub edges: Vec<EdgeResult>,
|
pub edges: Vec<EdgeResult>,
|
||||||
/// Aggregated system performance summary.
|
/// Aggregated system performance summary.
|
||||||
pub summary: SystemSummary,
|
pub summary: SystemSummary,
|
||||||
|
/// Named solver-computed unknowns (free actuators + calibration factors).
|
||||||
|
///
|
||||||
|
/// Empty for systems with no bounded control variables. Skipped on
|
||||||
|
/// serialization when empty so older consumers keep working.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub solved_variables: Vec<SolvedVariable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SimulationResult {
|
impl SimulationResult {
|
||||||
@@ -422,6 +458,9 @@ pub fn extract_simulation_result(system: &System, converged: &ConvergedState) ->
|
|||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Solved unknowns (free actuators + calibration factors) ---
|
||||||
|
let solved_variables = extract_solved_variables(system, state);
|
||||||
|
|
||||||
SimulationResult {
|
SimulationResult {
|
||||||
status,
|
status,
|
||||||
convergence,
|
convergence,
|
||||||
@@ -429,6 +468,93 @@ pub fn extract_simulation_result(system: &System, converged: &ConvergedState) ->
|
|||||||
components,
|
components,
|
||||||
edges,
|
edges,
|
||||||
summary,
|
summary,
|
||||||
|
solved_variables,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts named solver unknowns (bounded variables / free actuators) from a
|
||||||
|
/// solved state vector.
|
||||||
|
///
|
||||||
|
/// Every bounded variable registered on `system` whose value lives in the
|
||||||
|
/// Newton state vector — as a physical free actuator, a hard inverse-control
|
||||||
|
/// link, or a saturated-controller actuator — is materialized into a
|
||||||
|
/// [`SolvedVariable`] carrying its converged value and `[min, max]` bounds.
|
||||||
|
///
|
||||||
|
/// Bounded variables with no resolvable state slot (purely registered, never
|
||||||
|
/// wired into the solve) are silently skipped; a single `tracing::debug!`
|
||||||
|
/// line summarizes how many were skipped so coverage gaps are diagnosable
|
||||||
|
/// without panic risk. Out-of-range indices (state shorter than expected,
|
||||||
|
/// e.g. a truncated warm-start vector) are likewise skipped.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `system` - The solved system (must be finalized).
|
||||||
|
/// * `state` - Converged Newton state vector slice (length =
|
||||||
|
/// `system.full_state_vector_len()` when complete).
|
||||||
|
pub fn extract_solved_variables(system: &System, state: &[f64]) -> Vec<SolvedVariable> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
|
||||||
|
for bv in system.bounded_variables() {
|
||||||
|
let id = bv.id();
|
||||||
|
// Free actuators are laid out in their own block at the tail of the
|
||||||
|
// state vector; everything else (hard-control links and saturated-
|
||||||
|
// controller actuators) is resolvable via control_variable_state_index.
|
||||||
|
let idx = system
|
||||||
|
.free_actuators()
|
||||||
|
.position(|fid| fid == id)
|
||||||
|
.map(|i| system.free_actuator_index(i))
|
||||||
|
.or_else(|| system.control_variable_state_index(id));
|
||||||
|
|
||||||
|
match idx {
|
||||||
|
Some(i) if i < state.len() => {
|
||||||
|
let id_str = id.as_str();
|
||||||
|
let component = bv.component_id().map(|s| s.to_string());
|
||||||
|
let variable = derive_solved_variable_label(id_str, bv.component_id());
|
||||||
|
out.push(SolvedVariable {
|
||||||
|
id: id_str.to_string(),
|
||||||
|
component,
|
||||||
|
variable,
|
||||||
|
value: state[i],
|
||||||
|
min: bv.min(),
|
||||||
|
max: bv.max(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if skipped > 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
skipped,
|
||||||
|
total = system.bounded_variable_count(),
|
||||||
|
"Bounded variables skipped when extracting solved variables (no resolvable state slot)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the short user-facing variable label from a bounded-variable id.
|
||||||
|
///
|
||||||
|
/// Bounded-variable ids follow the convention `"{component}__{factor}"`, e.g.
|
||||||
|
/// `"exv__opening"`, `"cond__z_ua"`. When the component is known we strip the
|
||||||
|
/// `"{component}__"` prefix; otherwise we fall back to the segment after the
|
||||||
|
/// last `__`. The solver-internal `"actuator"` suffix (used for saturated
|
||||||
|
/// controllers driving an `opening`/`injection` factor) is remapped to the
|
||||||
|
/// user-facing `"opening"` label.
|
||||||
|
fn derive_solved_variable_label(id: &str, component_id: Option<&str>) -> String {
|
||||||
|
let stripped = match component_id {
|
||||||
|
Some(c) => id
|
||||||
|
.strip_prefix(&format!("{}__", c))
|
||||||
|
.unwrap_or(id),
|
||||||
|
None => id.rfind("__").map(|i| &id[i + 2..]).unwrap_or(id),
|
||||||
|
};
|
||||||
|
match stripped {
|
||||||
|
"actuator" => "opening".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,6 +698,7 @@ mod tests {
|
|||||||
components: vec![],
|
components: vec![],
|
||||||
edges: vec![],
|
edges: vec![],
|
||||||
summary: SystemSummary::default(),
|
summary: SystemSummary::default(),
|
||||||
|
solved_variables: vec![],
|
||||||
};
|
};
|
||||||
let json = result.to_json().unwrap();
|
let json = result.to_json().unwrap();
|
||||||
assert!(json.contains("\"status\": \"converged\""));
|
assert!(json.contains("\"status\": \"converged\""));
|
||||||
@@ -595,4 +722,65 @@ mod tests {
|
|||||||
assert_eq!(result.edges, de.edges);
|
assert_eq!(result.edges, de.edges);
|
||||||
assert_eq!(result.summary, de.summary);
|
assert_eq!(result.summary, de.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_solved_variable_serialization() {
|
||||||
|
let sv = SolvedVariable {
|
||||||
|
id: "exv__opening".to_string(),
|
||||||
|
component: Some("exv".to_string()),
|
||||||
|
variable: "opening".to_string(),
|
||||||
|
value: 0.62,
|
||||||
|
min: 0.02,
|
||||||
|
max: 1.0,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&sv).unwrap();
|
||||||
|
let de: SolvedVariable = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(sv, de);
|
||||||
|
assert!(json.contains("\"component\":\"exv\""));
|
||||||
|
assert!(json.contains("\"variable\":\"opening\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_solved_variables_skipped_when_empty() {
|
||||||
|
// Empty solved_variables should be omitted from serialized JSON
|
||||||
|
// (skip_serializing_if = "Vec::is_empty") for backward compatibility.
|
||||||
|
let result = SimulationResult {
|
||||||
|
status: SimulationOutcome::Converged,
|
||||||
|
convergence: ConvergenceSummary {
|
||||||
|
iterations: 1,
|
||||||
|
final_residual: 0.0,
|
||||||
|
converged: true,
|
||||||
|
status: SimulationOutcome::Converged,
|
||||||
|
},
|
||||||
|
metadata: entropyk_solver::SimulationMetadata::new("h".to_string()),
|
||||||
|
components: vec![],
|
||||||
|
edges: vec![],
|
||||||
|
summary: SystemSummary::default(),
|
||||||
|
solved_variables: vec![],
|
||||||
|
};
|
||||||
|
let json = result.to_json().unwrap();
|
||||||
|
assert!(!json.contains("solvedVariables"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derive_solved_variable_label() {
|
||||||
|
// Component known → strip "{component}__" prefix.
|
||||||
|
assert_eq!(
|
||||||
|
derive_solved_variable_label("exv__opening", Some("exv")),
|
||||||
|
"opening"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
derive_solved_variable_label("cond__z_ua", Some("cond")),
|
||||||
|
"z_ua"
|
||||||
|
);
|
||||||
|
// Solver-internal "actuator" suffix remapped to user-facing "opening".
|
||||||
|
assert_eq!(
|
||||||
|
derive_solved_variable_label("exv__actuator", Some("exv")),
|
||||||
|
"opening"
|
||||||
|
);
|
||||||
|
// Component unknown → fall back to last "__"-separated segment.
|
||||||
|
assert_eq!(derive_solved_variable_label("glob__z_dp", None), "z_dp");
|
||||||
|
// No separator and no component → id itself.
|
||||||
|
assert_eq!(derive_solved_variable_label("global_var", None), "global_var");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user