feat(python): implement python bindings for all components and solvers

This commit is contained in:
Sepehr
2026-02-21 20:34:56 +01:00
parent 8ef8cd2eba
commit 4440132b0a
310 changed files with 11577 additions and 397 deletions

View File

@@ -353,8 +353,12 @@ impl System {
let mut current_offset = 2 * self.graph.edge_count();
// Gather (node_idx, offset, incident_edge_indices) before mutating nodes.
let mut node_context: Vec<(petgraph::graph::NodeIndex, usize, Vec<(usize, usize)>)> =
Vec::new();
#[allow(clippy::type_complexity)]
let mut node_context: Vec<(
petgraph::graph::NodeIndex,
usize,
Vec<(usize, usize)>,
)> = Vec::new();
for node_idx in self.graph.node_indices() {
let component = self.graph.node_weight(node_idx).unwrap();
let mut incident: Vec<(usize, usize)> = Vec::new();
@@ -380,15 +384,46 @@ impl System {
current_offset += component.internal_state_len();
}
self.total_state_len = current_offset;
// Notify components about their calibration control variables (Story 5.5)
let mut comp_calib_indices: HashMap<String, entropyk_core::CalibIndices> = HashMap::new();
for (index, id) in self.inverse_control.linked_controls().enumerate() {
if let Some(bounded_var) = self.bounded_variables.get(id) {
if let Some(comp_id) = bounded_var.component_id() {
let indices = comp_calib_indices.entry(comp_id.to_string()).or_default();
let state_idx = self.total_state_len + index;
let id_str = id.as_str();
if id_str.ends_with("f_m") || id_str == "f_m" {
indices.f_m = Some(state_idx);
} else if id_str.ends_with("f_dp") || id_str == "f_dp" {
indices.f_dp = Some(state_idx);
} else if id_str.ends_with("f_ua") || id_str == "f_ua" {
indices.f_ua = Some(state_idx);
} else if id_str.ends_with("f_power") || id_str == "f_power" {
indices.f_power = Some(state_idx);
} else if id_str.ends_with("f_etav") || id_str == "f_etav" {
indices.f_etav = Some(state_idx);
}
}
}
}
// Now mutate each node weight (component) with the gathered context.
for (node_idx, offset, incident) in node_context {
if let Some(component) = self.graph.node_weight_mut(node_idx) {
component.set_system_context(offset, &incident);
// If we registered a name for this node, check if we have calib indices for it
if let Some((name, _)) = self.component_names.iter().find(|(_, &n)| n == node_idx) {
if let Some(&indices) = comp_calib_indices.get(name) {
component.set_calib_indices(indices);
}
}
}
}
self.total_state_len = current_offset;
if !self.constraints.is_empty() {
match self.validate_inverse_control_dof() {
Ok(()) => {
@@ -484,18 +519,17 @@ impl System {
"[P_edge0, h_edge0, P_edge1, h_edge1, ...] — 2 per edge (pressure Pa, enthalpy J/kg)"
}
/// Returns the length of the state vector: `2 * edge_count`.
/// Returns the length of the state vector: `2 * edge_count + internal_components_length`.
///
/// Note: This returns only the edge state length. For the full state vector
/// including internal component states and control variables, use
/// [`full_state_vector_len`](Self::full_state_vector_len).
/// Note: This returns the physical state vector length. For the full solver state vector
/// including control variables, use [`full_state_vector_len`](Self::full_state_vector_len).
///
/// # Panics
///
/// Panics if `finalize()` has not been called.
pub fn state_vector_len(&self) -> usize {
assert!(self.finalized, "call finalize() before state_vector_len()");
2 * self.graph.edge_count()
self.total_state_len
}
/// Returns the state indices (P, h) for the given edge.
@@ -814,13 +848,13 @@ impl System {
///
/// ```rust,ignore
/// let mut residuals = ResidualVector::new();
/// let measured = system.extract_constraint_values(&state);
/// let measured = system.extract_constraint_values_with_controls(&state, &control);
/// let count = system.compute_constraint_residuals(&state, &mut residuals, &measured);
/// ```
pub fn compute_constraint_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
residuals: &mut [f64],
measured_values: &HashMap<ConstraintId, f64>,
) -> usize {
if self.constraints.is_empty() {
@@ -840,42 +874,147 @@ impl System {
constraint.target_value()
});
let residual = constraint.compute_residual(measured);
residuals.push(residual);
if count < residuals.len() {
residuals[count] = residual;
}
count += 1;
}
count
}
/// Extracts constraint output values from component state.
/// Extracts measured values for all constraints, incorporating control variable effects.
///
/// This method attempts to extract measurable output values for all constraints
/// from the current system state. For complex outputs (superheat, subcooling),
/// additional thermodynamic calculations may be needed.
/// This method computes the measured output value for each constraint, taking into
/// account the current state and control variable values. For MIMO (Multi-Input
/// Multi-Output) systems, ALL control variables can affect ALL constraint outputs
/// due to system coupling.
///
/// # Arguments
///
/// * `_state` - Current system state (edge pressures and enthalpies)
/// * `state` - Current system state (edge pressures and enthalpies)
/// * `control_values` - Current values of control variables
///
/// # Returns
///
/// A map from constraint IDs to their measured values. Constraints whose
/// outputs cannot be extracted will not appear in the map.
/// A map from constraint ID to measured output value.
///
/// # Note
/// # Cross-Coupling for MIMO Systems
///
/// Full implementation requires integration with ThermoState (Story 2.8) and
/// component-specific output extraction. This MVP version returns an empty map
/// and should be enhanced with actual component state extraction.
pub fn extract_constraint_values(&self, _state: &StateSlice) -> HashMap<ConstraintId, f64> {
/// In a real thermodynamic system, control variables are coupled:
/// - Compressor speed affects both capacity AND superheat
/// - Valve opening affects both superheat AND capacity
///
/// The mock implementation simulates this coupling for Jacobian cross-derivative
/// computation. Each control variable has a primary effect (on its linked constraint)
/// and a secondary effect (on other constraints) to simulate thermal coupling.
pub fn extract_constraint_values_with_controls(
&self,
state: &StateSlice,
control_values: &[f64],
) -> HashMap<ConstraintId, f64> {
let mut measured = HashMap::new();
if self.constraints.is_empty() {
return HashMap::new();
return measured;
}
tracing::debug!(
constraint_count = self.constraints.len(),
"Constraint value extraction called - MVP returns empty map"
);
HashMap::new()
// Build a map of control variable index -> component_id it controls
// This uses the proper component_id() field from BoundedVariable
let mut control_to_component: HashMap<usize, &str> = HashMap::new();
for (j, bounded_var_id) in self.inverse_control.linked_controls().enumerate() {
if let Some(bounded_var) = self.bounded_variables.get(bounded_var_id) {
if let Some(comp_id) = bounded_var.component_id() {
control_to_component.insert(j, comp_id);
}
}
}
for constraint in self.constraints.values() {
let comp_id = constraint.output().component_id();
if let Some(&node_idx) = self.component_names.get(comp_id) {
// Find first associated edge (incoming or outgoing)
let mut edge_opt = self
.graph
.edges_directed(node_idx, petgraph::Direction::Incoming)
.next();
if edge_opt.is_none() {
edge_opt = self
.graph
.edges_directed(node_idx, petgraph::Direction::Outgoing)
.next();
}
if let Some(edge) = edge_opt {
if let Some(&(p_idx, h_idx)) = self.edge_to_state.get(&edge.id()) {
let mut value = match constraint.output() {
crate::inverse::ComponentOutput::Pressure { .. } => state[p_idx],
crate::inverse::ComponentOutput::Temperature { .. } => 300.0, // Mock for MVP without fluid backend
crate::inverse::ComponentOutput::Superheat { .. } => {
// Mock numerical value sensitive to BOTH P and h for Jacobian calculation
state[h_idx] / 1000.0 - (state[p_idx] / 1e5)
}
crate::inverse::ComponentOutput::Subcooling { .. } => {
(state[p_idx] / 1e5) - state[h_idx] / 1000.0
}
crate::inverse::ComponentOutput::Capacity { .. } => {
// Mock capacity: h * mass_flow. Let's just use h for Jacobian sensitivity
state[h_idx] * 10.0
}
_ => 0.0,
};
// MIMO Cross-Coupling: ALL control variables can affect ALL constraints
// In a real system, changing compressor speed affects both capacity and superheat,
// and changing valve opening also affects both. We simulate this coupling here.
//
// ⚠️ MOCK COEFFICIENTS: These values (10.0, 2.0) are placeholders for testing.
// They create a well-conditioned Jacobian with off-diagonal entries that allow
// Newton-Raphson to converge. Real implementations should replace these with
// actual component physics derived from:
// - Component characteristic curves (compressor map, valve Cv curve)
// - Thermodynamic property calculations via fluid backend
// - Energy and mass balance equations
//
// The 5:1 ratio between primary and secondary effects is arbitrary but creates
// a diagonally-dominant Jacobian that converges reliably. See Story 5.4
// Review Follow-ups for tracking real thermodynamics integration.
//
// For each control variable:
// - Primary effect (10.0): if control is linked to this constraint's component
// - Secondary effect (2.0): cross-coupling to other constraints
const MIMO_PRIMARY_COEFF: f64 = 10.0;
const MIMO_SECONDARY_COEFF: f64 = 2.0;
for (j, _bounded_var_id) in
self.inverse_control.linked_controls().enumerate()
{
if j >= control_values.len() {
continue;
}
let ctrl_val = control_values[j];
// Check if this control variable is primarily associated with this component
let is_primary = control_to_component
.get(&j)
.map_or(false, |&c| c == comp_id);
if is_primary {
// Primary effect: strong influence on the controlled output
// e.g., valve opening strongly affects superheat
value += ctrl_val * MIMO_PRIMARY_COEFF;
} else {
// Secondary (cross-coupling) effect: weaker influence
// e.g., compressor speed also affects superheat (through mass flow)
// This creates the off-diagonal entries in the MIMO Jacobian
value += ctrl_val * MIMO_SECONDARY_COEFF;
}
}
measured.insert(constraint.id().clone(), value);
}
}
}
}
measured
}
/// Computes the Jacobian entries for inverse control constraints.
@@ -886,9 +1025,9 @@ impl System {
///
/// # Arguments
///
/// * `_state` - Current system state
/// * `state` - Current system state
/// * `row_offset` - Starting row index for constraint equations in the Jacobian
/// * `_control_values` - Current values of control variables (for finite difference)
/// * `control_values` - Current values of control variables (for finite difference)
///
/// # Returns
///
@@ -898,11 +1037,16 @@ impl System {
///
/// MVP uses finite difference approximation. Future versions may use analytical
/// derivatives from components for better accuracy and performance.
///
/// # Finite Difference Epsilon
///
/// Uses the epsilon configured in `InverseControlConfig` (default 1e-6) for central
/// finite differences. Configure via `set_inverse_control_epsilon()`.
pub fn compute_inverse_control_jacobian(
&self,
_state: &StateSlice,
state: &StateSlice,
row_offset: usize,
_control_values: &[f64],
control_values: &[f64],
) -> Vec<(usize, usize, f64)> {
let mut entries = Vec::new();
@@ -910,18 +1054,118 @@ impl System {
return entries;
}
for (i, (_constraint_id, bounded_var_id)) in self.inverse_control.mappings().enumerate() {
let col = self.control_variable_state_index(bounded_var_id);
if let Some(col_idx) = col {
// Use configurable epsilon from InverseControlConfig
let eps = self.inverse_control.finite_diff_epsilon();
let mut state_mut = state.to_vec();
let mut control_mut = control_values.to_vec();
// 1. Compute ∂r_i / ∂x_j (Partial derivatives with respect to PHYSICAL states P, h)
// We do this per constraint to keep perturbations localized where possible
for (i, (constraint_id, _)) in self.inverse_control.mappings().enumerate() {
let row = row_offset + i;
if let Some(constraint) = self.constraints.get(constraint_id) {
let comp_id = constraint.output().component_id();
if let Some(&node_idx) = self.component_names.get(comp_id) {
let mut state_indices = Vec::new();
// Gather all edge state indices for this component
for edge in self
.graph
.edges_directed(node_idx, petgraph::Direction::Incoming)
{
if let Some(&(p_idx, h_idx)) = self.edge_to_state.get(&edge.id()) {
if !state_indices.contains(&p_idx) {
state_indices.push(p_idx);
}
if !state_indices.contains(&h_idx) {
state_indices.push(h_idx);
}
}
}
for edge in self
.graph
.edges_directed(node_idx, petgraph::Direction::Outgoing)
{
if let Some(&(p_idx, h_idx)) = self.edge_to_state.get(&edge.id()) {
if !state_indices.contains(&p_idx) {
state_indices.push(p_idx);
}
if !state_indices.contains(&h_idx) {
state_indices.push(h_idx);
}
}
}
// Central finite difference for Jacobian entries w.r.t physical state
for &col in &state_indices {
let orig = state_mut[col];
state_mut[col] = orig + eps;
let plus = self
.extract_constraint_values_with_controls(&state_mut, control_values);
let val_plus = plus.get(constraint_id).copied().unwrap_or(0.0);
state_mut[col] = orig - eps;
let minus = self
.extract_constraint_values_with_controls(&state_mut, control_values);
let val_minus = minus.get(constraint_id).copied().unwrap_or(0.0);
state_mut[col] = orig; // Restore
let derivative = (val_plus - val_minus) / (2.0 * eps);
if derivative.abs() > 1e-10 {
entries.push((row, col, derivative));
tracing::trace!(
constraint = constraint_id.as_str(),
row,
col,
derivative,
"Inverse control Jacobian actual ∂r/∂state entry"
);
}
}
}
}
}
// 2. Compute ∂r_i / ∂u_j (Cross-derivatives with respect to CONTROL variables)
// Here we must form the full dense block because control variable 'j' could affect constraint 'i'
// even if they are not explicitly linked, due to system coupling.
let control_offset = self.state_vector_len();
for (j, (_, bounded_var_id)) in self.inverse_control.mappings().enumerate() {
let col = control_offset + j;
let orig = control_mut[j];
// Perturb control variable +eps
control_mut[j] = orig + eps;
let plus = self.extract_constraint_values_with_controls(state, &control_mut);
// Perturb control variable -eps
control_mut[j] = orig - eps;
let minus = self.extract_constraint_values_with_controls(state, &control_mut);
control_mut[j] = orig; // Restore
// For this perturbed control variable j, compute the effect on ALL constraints i
for (i, (constraint_id, _)) in self.inverse_control.mappings().enumerate() {
let row = row_offset + i;
entries.push((row, col_idx, 1.0));
tracing::trace!(
constraint = _constraint_id.as_str(),
control = bounded_var_id.as_str(),
row,
col = col_idx,
"Inverse control Jacobian entry (placeholder derivative = 1.0)"
);
let val_plus = plus.get(constraint_id).copied().unwrap_or(0.0);
let val_minus = minus.get(constraint_id).copied().unwrap_or(0.0);
let derivative = (val_plus - val_minus) / (2.0 * eps);
// We add it even if it's 0 to maintain block structure (optional but safe)
// However, for performance we only add non-zeros
if derivative.abs() > 1e-10 {
entries.push((row, col, derivative));
tracing::trace!(
constraint = ?constraint_id,
control = ?bounded_var_id,
row, col, derivative,
"Inverse control Jacobian cross-derivative ∂r/∂u entry"
);
}
}
}
@@ -1131,6 +1375,20 @@ impl System {
self.inverse_control.mapping_count()
}
/// Sets the finite difference epsilon for inverse control Jacobian computation.
///
/// # Panics
///
/// Panics if epsilon is non-positive.
pub fn set_inverse_control_epsilon(&mut self, epsilon: f64) {
self.inverse_control.set_finite_diff_epsilon(epsilon);
}
/// Returns the current finite difference epsilon for inverse control.
pub fn inverse_control_epsilon(&self) -> f64 {
self.inverse_control.finite_diff_epsilon()
}
/// Returns an iterator over linked control variable IDs.
pub fn linked_controls(&self) -> impl Iterator<Item = &BoundedVariableId> {
self.inverse_control.linked_controls()
@@ -1224,16 +1482,36 @@ impl System {
}
let base = self.total_state_len;
let mut index = 0;
for linked_id in self.inverse_control.linked_controls() {
for (index, linked_id) in self.inverse_control.linked_controls().enumerate() {
if linked_id == id {
return Some(base + index);
}
index += 1;
}
None
}
/// Returns the bounded variable for a given state index.
pub fn get_bounded_variable_by_state_index(
&self,
state_index: usize,
) -> Option<&BoundedVariable> {
let base = self.total_state_len;
if state_index < base {
return None;
}
let control_idx = state_index - base;
self.inverse_control
.linked_controls()
.nth(control_idx)
.and_then(|id| self.bounded_variables.get(id))
}
/// Returns the bounds (min, max) for a given state index if it corresponds to a bounded control variable.
pub fn get_bounds_for_state_index(&self, state_index: usize) -> Option<(f64, f64)> {
self.get_bounded_variable_by_state_index(state_index)
.map(|var| (var.min(), var.max()))
}
/// Returns the total state vector length including control variables.
///
/// ```text
@@ -1399,7 +1677,7 @@ impl System {
.map(|(_, c, _)| c.n_equations())
.sum();
total_eqs += self.constraints.len() + self.coupling_residual_count();
if residuals.len() < total_eqs {
return Err(ComponentError::InvalidResidualDimensions {
expected: total_eqs,
@@ -1419,13 +1697,15 @@ impl System {
}
// Add constraints
let measured = self.extract_constraint_values(state);
let mut constraint_res = vec![];
let n_constraints = self.compute_constraint_residuals(state, &mut constraint_res, &measured);
if n_constraints > 0 {
residuals[eq_offset..eq_offset + n_constraints].copy_from_slice(&constraint_res[0..n_constraints]);
eq_offset += n_constraints;
}
let control_values: Vec<f64> = self
.control_variable_indices()
.into_iter()
.map(|(_, idx)| state[idx])
.collect();
let measured = self.extract_constraint_values_with_controls(state, &control_values);
let n_constraints =
self.compute_constraint_residuals(state, &mut residuals[eq_offset..], &measured);
eq_offset += n_constraints;
// Add couplings
let n_couplings = self.coupling_residual_count();
@@ -1464,11 +1744,13 @@ impl System {
}
// Add constraints jacobian
let control_values: Vec<f64> = self.control_variable_indices()
let control_values: Vec<f64> = self
.control_variable_indices()
.into_iter()
.map(|(_, idx)| state[idx])
.collect();
let constraint_jac = self.compute_inverse_control_jacobian(state, row_offset, &control_values);
let constraint_jac =
self.compute_inverse_control_jacobian(state, row_offset, &control_values);
for (r, c, v) in constraint_jac {
jacobian.add_entry(r, c, v);
}