Files
Entropyk/crates/solver/src/inverse/override_network.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

243 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Steady-state **override / selector control** network.
//!
//! Real supervisory controllers drive a *single* actuator from *several*
//! competing objectives: a primary setpoint (e.g. capacity, superheat) plus a
//! set of operating-envelope protections (SST low, SDT high, DGT high,
//! min/max frequency, …). Only one objective is "in authority" at a time; the
//! others act as overrides that take over when a limit is about to be crossed.
//!
//! This mirrors the `BOLT.Control.SteadyState.SetpointControl` library used in
//! the reference Modelica chillers (61WH / 61AQ / NG-Screw), where the pattern
//! is `ErrorCalculation` blocks feeding a tree of `Min` / `Max` selectors into a
//! single `SetpointController`. See also the ALES/UTC report *Supervisory
//! Control Formulation: Centrifugal System* (Mancuso & Morari, 2016).
//!
//! # Formulation
//!
//! Each objective `i` computes a **normalized** error
//!
//! ```text
//! e_i = gain_i · (setpoint_i measurement_i)
//! ```
//!
//! The `gain_i` normalizes every objective to a comparable scale (e.g.
//! `1/(freq_max freq_min)`, `1/(T_dgt_max T_dgt_min)`), so that the
//! selector compares apples to apples — this is the "same-gain" principle from
//! the reference: after normalization a *single* unit controller integrates the
//! selected error.
//!
//! Errors are folded left-to-right into a single selected error `E`:
//!
//! ```text
//! acc_0 = e_0
//! acc_i = combine_i(acc_{i-1}, e_i) with combine_i ∈ {Min, Max}
//! E = acc_{n-1}
//! ```
//!
//! The fold order encodes **priority**: place higher-priority protections later
//! in the chain (this reproduces the linear `min/max/min/…` selector chains of
//! `CompressorControl` / `EXVControl`).
//!
//! # Smoothing (convergence)
//!
//! `Min` / `Max` are replaced by the C^∞ `softMin` / `softMax`
//! (`entropyk_core::smoothing`) with sharpness `alpha`. Using a smooth selector
//! with an **exact analytic Jacobian** (rather than a non-smooth `min`/`max`
//! with a semismooth Newton step) is the "Jacobian-smoothing" approach that the
//! nonlinear-complementarity literature reports as markedly more robust and
//! faster to converge (fewer Newton iterations, no chattering at the selector
//! kinks). `alpha` can be annealed toward zero by an outer continuation loop for
//! a sharp final solution.
use entropyk_core::smoothing::{smooth_max, smooth_min};
use super::constraint::ComponentOutput;
/// How an objective combines with the running selected error.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Combine {
/// Take the (smooth) minimum of the accumulator and this objective's error.
Min,
/// Take the (smooth) maximum of the accumulator and this objective's error.
Max,
}
/// A single control objective feeding an override network.
///
/// The normalized error is `gain · (setpoint measurement)`, where
/// `measurement` is the current value of [`Objective::output`].
#[derive(Debug, Clone)]
pub struct Objective {
/// The measured plant output for this objective.
pub output: ComponentOutput,
/// Target value for the measured output (SI units).
pub setpoint: f64,
/// Normalization/sign gain for this objective's error.
pub gain: f64,
/// Selector applied between the running accumulator and this objective.
/// Ignored for the first objective (which seeds the accumulator).
pub combine: Combine,
}
impl Objective {
/// Builds an objective with the given output, setpoint, gain and combinator.
pub fn new(output: ComponentOutput, setpoint: f64, gain: f64, combine: Combine) -> Self {
Self {
output,
setpoint,
gain,
combine,
}
}
/// Normalized error `e = gain · (setpoint measurement)`.
#[inline]
pub fn error(&self, measurement: f64) -> f64 {
self.gain * (self.setpoint - measurement)
}
}
/// `softMin` value and partials `(value, ∂/∂a, ∂/∂b)`.
#[inline]
fn soft_min_partials(a: f64, b: f64, k: f64) -> (f64, f64, f64) {
let d = ((a - b) * (a - b) + k * k).sqrt();
let s = if d > 0.0 { (a - b) / d } else { 0.0 };
(smooth_min(a, b, k), 0.5 * (1.0 - s), 0.5 * (1.0 + s))
}
/// `softMax` value and partials `(value, ∂/∂a, ∂/∂b)`.
#[inline]
fn soft_max_partials(a: f64, b: f64, k: f64) -> (f64, f64, f64) {
let d = ((a - b) * (a - b) + k * k).sqrt();
let s = if d > 0.0 { (a - b) / d } else { 0.0 };
(smooth_max(a, b, k), 0.5 * (1.0 + s), 0.5 * (1.0 - s))
}
/// Evaluates the selected error `E` for the given objectives and their measured
/// values (`measured[i]` corresponds to `objectives[i]`).
///
/// Panics in debug builds if the slice lengths differ. Returns `0.0` for an
/// empty objective list.
pub fn eval_error_signal(objectives: &[Objective], measured: &[f64], alpha: f64) -> f64 {
debug_assert_eq!(objectives.len(), measured.len());
if objectives.is_empty() {
return 0.0;
}
let mut acc = objectives[0].error(measured[0]);
for i in 1..objectives.len() {
let e = objectives[i].error(measured[i]);
acc = match objectives[i].combine {
Combine::Min => smooth_min(acc, e, alpha),
Combine::Max => smooth_max(acc, e, alpha),
};
}
acc
}
/// Computes the selector weights `w_i = ∂E/∂e_i` for each objective via a
/// forward/backward sweep over the fold. These let the caller assemble the
/// exact plant-coupling Jacobian: `∂E/∂measurement_i = w_i · (gain_i)`.
pub fn eval_error_weights(objectives: &[Objective], measured: &[f64], alpha: f64) -> Vec<f64> {
debug_assert_eq!(objectives.len(), measured.len());
let n = objectives.len();
let mut weights = vec![0.0; n];
if n == 0 {
return weights;
}
if n == 1 {
weights[0] = 1.0;
return weights;
}
// Forward: accumulate value and store per-step partials.
let mut pa = vec![0.0; n]; // ∂acc_i/∂acc_{i-1}
let mut pb = vec![0.0; n]; // ∂acc_i/∂e_i
let mut acc = objectives[0].error(measured[0]);
for i in 1..n {
let e = objectives[i].error(measured[i]);
let (val, da, db) = match objectives[i].combine {
Combine::Min => soft_min_partials(acc, e, alpha),
Combine::Max => soft_max_partials(acc, e, alpha),
};
pa[i] = da;
pb[i] = db;
acc = val;
}
// Backward: propagate ∂E/∂acc back to each e_i.
let mut g = 1.0;
for i in (1..n).rev() {
weights[i] = g * pb[i];
g *= pa[i];
}
weights[0] = g;
weights
}
#[cfg(test)]
mod tests {
use super::*;
fn obj(setpoint: f64, gain: f64, combine: Combine) -> Objective {
Objective::new(
ComponentOutput::Temperature {
component_id: "c".to_string(),
},
setpoint,
gain,
combine,
)
}
#[test]
fn single_objective_is_plain_error() {
let objs = vec![obj(5.0, -0.5, Combine::Min)];
let e = eval_error_signal(&objs, &[7.0], 1e-3);
assert!((e - (-0.5 * (5.0 - 7.0))).abs() < 1e-12);
let w = eval_error_weights(&objs, &[7.0], 1e-3);
assert_eq!(w, vec![1.0]);
}
#[test]
fn min_selects_smaller_error_and_routes_weight() {
// Two objectives; e_0 large, e_1 small → Min picks ~e_1, so weight ~1 on
// objective 1 and ~0 on objective 0.
let objs = vec![obj(10.0, 1.0, Combine::Min), obj(0.0, 1.0, Combine::Min)];
// measured: obj0 at 5 → e0 = 5; obj1 at 5 → e1 = -5. min → ~-5.
let e = eval_error_signal(&objs, &[5.0, 5.0], 1e-4);
assert!((e - (-5.0)).abs() < 1e-2, "E={e}");
let w = eval_error_weights(&objs, &[5.0, 5.0], 1e-4);
assert!(w[1] > 0.98 && w[0] < 0.02, "weights={w:?}");
// Weights of a smooth selector sum to 1 (convex combination).
assert!((w[0] + w[1] - 1.0).abs() < 1e-9);
}
#[test]
fn weights_match_finite_difference() {
let objs = vec![
obj(8.0, 0.7, Combine::Min),
obj(2.0, -1.3, Combine::Max),
obj(-1.0, 0.9, Combine::Min),
];
let measured = [6.0, 3.0, 0.5];
let alpha = 0.05;
let w = eval_error_weights(&objs, &measured, alpha);
let h = 1e-6;
for i in 0..objs.len() {
// dE/de_i via FD on the measurement, then convert: dE/dm_i = -gain_i·w_i.
let mut mp = measured;
let mut mm = measured;
mp[i] += h;
mm[i] -= h;
let de_dm = (eval_error_signal(&objs, &mp, alpha)
- eval_error_signal(&objs, &mm, alpha))
/ (2.0 * h);
let expected = -objs[i].gain * w[i];
assert!(
(de_dm - expected).abs() < 1e-4,
"objective {i}: FD {de_dm} vs analytic {expected}"
);
}
}
}