//! 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 { 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}" ); } } }