Skip to main content

gridoxide_core/outerloop/
taps.rs

1//! Transformer tap control: the on-load tap changer as a control rather than a
2//! constant.
3//!
4//! Every steady-state result gridoxide produced before this module existed was
5//! computed with taps that never move, which is a modelling assumption four of
6//! the five tools in `docs/src/reference/feature_comparison.md` do not make. A
7//! real tap changer holds a voltage or a flow, and its position is an *output*
8//! of the solve.
9//!
10//! # The strategy, and why this one
11//!
12//! powsybl-open-loadflow offers three for voltage control. Two put the
13//! continuous ratio inside the Newton system and round afterwards; the third —
14//! `IncrementalTransformerVoltageControlOuterLoop` — steps the discrete
15//! position directly, using the sensitivity of the controlled voltage to the
16//! ratio:
17//!
18//! \\[ \Delta\rho = \frac{V^{\text{target}} - V_c}{\partial V_c/\partial\rho} \\]
19//!
20//! The incremental one is implemented here, for a reason specific to this
21//! crate: that derivative is already [`crate::ac_sensitivity::AcSensitivity`]
22//! with [`Variable::TransformerRatio`] against
23//! [`Function::VoltageMagnitude`](crate::ac_sensitivity::Function::VoltageMagnitude),
24//! validated against a central-difference re-solve of the full nonlinear power
25//! flow. The continuous variants would need the ratio inside
26//! [`crate::jacobian::JacobianPattern`] and a different `n_unknowns`.
27//!
28//! # Three guards, all load-bearing
29//!
30//! - **Deadband.** Every enabled control in the CGMES conformity fixtures
31//!   carries one. A loop that ignores it hunts, and a loop that hunts does not
32//!   terminate.
33//! - **Insensitivity filter** ([`MIN_SENSITIVITY`]). Below it the controller
34//!   cannot reach its bus, and \\(\Delta\rho\\) is a division by nearly zero
35//!   that slams the tap to a limit.
36//! - **Direction-change budget** ([`MAX_DIRECTION_CHANGES`]). A tap that has
37//!   reversed this many times is oscillating between two positions that
38//!   straddle the target with none inside the deadband. That is a property of
39//!   the network and the deadband, not a solver failure, and the honest answer
40//!   is to stop and report [`ControllerOutcome::Hunting`].
41
42use crate::ac_sensitivity::{AcSensitivity, Variable};
43use crate::branch_flow::Terminal;
44
45use super::{Invalidates, OuterLoop, OuterLoopContext, OuterLoopStatus};
46
47/// Below this, a controller is treated as unable to reach its controlled
48/// quantity. powsybl's own `MIN_SENSI_FILTER`.
49pub const MIN_SENSITIVITY: f64 = 0.05;
50
51/// How many times a controller may reverse direction before it is declared to
52/// be hunting. powsybl's own `MAX_DIRECTION_CHANGE`.
53pub const MAX_DIRECTION_CHANGES: usize = 3;
54
55/// What a tap changer's regulating control holds.
56///
57/// Both variants' targets are in **per-unit**, converted by the importer
58/// against the controlled bus's own nominal voltage (or the system power base),
59/// so this module is unit-free.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub enum RegulationMode {
62    /// Hold the voltage magnitude at `controlled_bus`. CGMES
63    /// `RegulatingControlModeKind.voltage`.
64    Voltage,
65    /// Hold the active power flowing into `branch` at `terminal`. CGMES
66    /// `RegulatingControlModeKind.activePower`. The branch index is flat —
67    /// lines first, then transformers — matching `branch_flow::branch_params`.
68    ActivePower { branch: usize, terminal: Terminal },
69}
70
71/// One regulating control, resolved against gridoxide's own indices.
72#[derive(Clone, Debug)]
73pub struct TapRegulation {
74    /// Index into the `transformers` slice — *not* a flat branch index.
75    pub transformer: usize,
76    /// The bus whose voltage is held. Meaningful for
77    /// [`RegulationMode::Voltage`]; carried for both so a report can name it.
78    pub controlled_bus: usize,
79    pub mode: RegulationMode,
80    /// Set-point, per-unit.
81    pub target: f64,
82    /// Full width, per-unit. The controller is satisfied within `±deadband/2`.
83    /// Zero means "hit the target exactly", which in practice means hunt until
84    /// the direction budget stops it.
85    pub deadband: f64,
86    /// `TapChanger.controlEnabled` AND `RegulatingControl.enabled`. A control
87    /// that exists but is off is data, not an error.
88    pub enabled: bool,
89    /// The element's own identifier, for reports.
90    pub id: String,
91}
92
93/// How one controller finished.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum ControllerOutcome {
96    /// The controlled quantity is inside the deadband. The success case.
97    InDeadband,
98    /// The controller asked for a ratio or angle outside what this changer can
99    /// produce at all, and is sitting at whichever end is nearest.
100    AtLimit,
101    /// The best position the table offers, with the target still outside the
102    /// deadband — the discrete grid has nothing closer.
103    ///
104    /// Common rather than exceptional: a tap moves in finite steps, so a
105    /// deadband narrower than one step's worth of effect can be unreachable
106    /// from every position. ENTSO-E's own `PST_PhaseTapChangerLinear_Type2`
107    /// asks for zero flow within 5e-4 pu on a shifter whose closest position
108    /// leaves 0.11 pu, and calling that `InDeadband` — as an earlier draft
109    /// did — reports success at a flow 200 times the tolerance.
110    Closest,
111    /// `|∂target/∂tap|` is below [`MIN_SENSITIVITY`]; this controller cannot
112    /// move its own controlled quantity.
113    Insensitive,
114    /// Reversed direction [`MAX_DIRECTION_CHANGES`] times without landing
115    /// inside the deadband — two positions straddle the target and neither is
116    /// acceptable.
117    Hunting,
118    /// The run ended (budget, or another loop failing) before this controller
119    /// settled.
120    Unfinished,
121}
122
123/// One controller's history through a solve.
124#[derive(Clone, Debug)]
125pub struct ControllerReport {
126    pub id: String,
127    pub transformer: usize,
128    pub controlled_bus: usize,
129    pub initial_position: i32,
130    pub final_position: i32,
131    /// Positions moved, summed over every step, ignoring direction.
132    pub steps_moved: usize,
133    pub direction_changes: usize,
134    pub outcome: ControllerOutcome,
135}
136
137/// What a tap-control loop did.
138#[derive(Clone, Debug, Default)]
139pub struct TapControlReport {
140    pub controllers: Vec<ControllerReport>,
141}
142
143impl TapControlReport {
144    /// The report for the controller on `transformer`, if that transformer had
145    /// one.
146    pub fn controller(&self, transformer: usize) -> Option<&ControllerReport> {
147        self.controllers
148            .iter()
149            .find(|c| c.transformer == transformer)
150    }
151}
152
153/// Per-controller state the loop carries between passes.
154#[derive(Clone, Debug)]
155struct ControllerState {
156    regulation_index: usize,
157    initial_position: i32,
158    steps_moved: usize,
159    direction_changes: usize,
160    /// `Some(true)` last moved up, `Some(false)` last moved down.
161    last_direction: Option<bool>,
162    outcome: ControllerOutcome,
163    /// Stops being consulted once it is hunting, insensitive or at a limit
164    /// with nowhere to go.
165    frozen: bool,
166}
167
168/// Shared machinery for the two tap loops. They differ only in which quantity
169/// they read, which tap coordinate they move, and which sensitivity relates
170/// them — the deadband, filter, budget and reporting are identical.
171struct TapLoop {
172    max_tap_shift: i32,
173    states: Vec<ControllerState>,
174    initialized: bool,
175}
176
177impl TapLoop {
178    fn new(max_tap_shift: i32) -> Self {
179        Self {
180            max_tap_shift: max_tap_shift.max(1),
181            states: Vec::new(),
182            initialized: false,
183        }
184    }
185
186    /// Records the starting positions of every controller this loop owns.
187    fn initialize(&mut self, ctx: &mut OuterLoopContext<'_, '_>, mine: fn(&TapRegulation) -> bool) {
188        if self.initialized {
189            return;
190        }
191        self.initialized = true;
192        for (i, reg) in ctx.net.regulation.iter().enumerate() {
193            if !reg.enabled || !mine(reg) {
194                continue;
195            }
196            let Some(Some(tc)) = ctx.net.tap_changers.get(reg.transformer) else {
197                continue;
198            };
199            self.states.push(ControllerState {
200                regulation_index: i,
201                initial_position: tc.position,
202                steps_moved: 0,
203                direction_changes: 0,
204                last_direction: None,
205                outcome: ControllerOutcome::Unfinished,
206                frozen: false,
207            });
208        }
209    }
210
211    fn report(
212        &self,
213        regulation: &[TapRegulation],
214        tap_changers: &[Option<crate::types::TapChanger>],
215    ) -> TapControlReport {
216        TapControlReport {
217            controllers: self
218                .states
219                .iter()
220                .map(|s| {
221                    let reg = &regulation[s.regulation_index];
222                    ControllerReport {
223                        id: reg.id.clone(),
224                        transformer: reg.transformer,
225                        controlled_bus: reg.controlled_bus,
226                        initial_position: s.initial_position,
227                        final_position: tap_changers[reg.transformer]
228                            .as_ref()
229                            .map(|t| t.position)
230                            .unwrap_or(s.initial_position),
231                        steps_moved: s.steps_moved,
232                        direction_changes: s.direction_changes,
233                        outcome: s.outcome,
234                    }
235                })
236                .collect(),
237        }
238    }
239
240    /// Applies one decided position move, maintaining the direction budget.
241    ///
242    /// `saturated` says the controller asked for a ratio or angle *outside*
243    /// what this changer can produce at all. It has to be passed in rather than
244    /// inferred: `nearest_to_ratio`/`nearest_to_angle` round into the table by
245    /// construction, so by the time a position arrives here the two very
246    /// different situations — "the table has nothing that far" and "the grid is
247    /// too coarse to do better" — look identical.
248    ///
249    /// Returns true if the tap actually moved.
250    fn move_to(
251        &mut self,
252        state_idx: usize,
253        wanted: i32,
254        saturated: bool,
255        ctx: &mut OuterLoopContext<'_, '_>,
256    ) -> bool {
257        let reg_idx = self.states[state_idx].regulation_index;
258        let transformer = ctx.net.regulation[reg_idx].transformer;
259        let Some(Some(tc)) = ctx.net.tap_changers.get(transformer) else {
260            return false;
261        };
262        let current = tc.position;
263        let low = tc.low;
264        let high = tc.high();
265
266        // Cap the step, then clamp into range. Clamping after capping is what
267        // makes a controller at a limit report `AtLimit` rather than looking
268        // like it moved.
269        let capped = wanted.clamp(current - self.max_tap_shift, current + self.max_tap_shift);
270        let target = capped.clamp(low, high);
271        if target == current {
272            self.states[state_idx].outcome = if saturated {
273                ControllerOutcome::AtLimit
274            } else {
275                ControllerOutcome::Closest
276            };
277            self.states[state_idx].frozen = true;
278            return false;
279        }
280
281        let up = target > current;
282        if let Some(previous) = self.states[state_idx].last_direction {
283            if previous != up {
284                self.states[state_idx].direction_changes += 1;
285                if self.states[state_idx].direction_changes >= MAX_DIRECTION_CHANGES {
286                    self.states[state_idx].outcome = ControllerOutcome::Hunting;
287                    self.states[state_idx].frozen = true;
288                    return false;
289                }
290            }
291        }
292
293        let tc = ctx.net.tap_changers[transformer]
294            .as_mut()
295            .expect("checked above");
296        if !tc.set_position(&mut ctx.net.transformers[transformer], target) {
297            return false;
298        }
299        self.states[state_idx].last_direction = Some(up);
300        self.states[state_idx].steps_moved += (target - current).unsigned_abs() as usize;
301        true
302    }
303}
304
305// ---------------------------------------------------------------------------
306// Voltage control
307// ---------------------------------------------------------------------------
308
309/// A ratio tap changer holding the voltage magnitude at its controlled bus.
310///
311/// Handles several controllers regulating one bus: the required change is
312/// shared among them in sensitivity order, each one's achieved effect being
313/// subtracted before the next is sized. That is powsybl's
314/// `adjustWithSeveralControllers`, and it is deliberately *not* the "last
315/// writer wins" behaviour the comparison table records for gridoxide's
316/// generator-side shared voltage control.
317pub struct TransformerVoltageControl {
318    inner: TapLoop,
319}
320
321impl Default for TransformerVoltageControl {
322    fn default() -> Self {
323        Self {
324            inner: TapLoop::new(3),
325        }
326    }
327}
328
329impl TransformerVoltageControl {
330    pub fn new() -> Self {
331        Self::default()
332    }
333
334    /// Positions a controller may move in one outer pass. The sensitivity is
335    /// exact only at the converged state, so a large step overshoots; capping
336    /// trades passes for stability.
337    pub fn max_tap_shift(mut self, steps: i32) -> Self {
338        self.inner.max_tap_shift = steps.max(1);
339        self
340    }
341
342    pub fn report(
343        &self,
344        ctx_regulation: &[TapRegulation],
345        tap_changers: &[Option<crate::types::TapChanger>],
346    ) -> TapControlReport {
347        self.inner.report(ctx_regulation, tap_changers)
348    }
349}
350
351fn is_voltage(reg: &TapRegulation) -> bool {
352    matches!(reg.mode, RegulationMode::Voltage)
353}
354
355impl OuterLoop for TransformerVoltageControl {
356    fn name(&self) -> &'static str {
357        "TransformerVoltageControl"
358    }
359
360    fn invalidates(&self) -> Invalidates {
361        Invalidates::Admittances
362    }
363
364    fn initialize(&mut self, ctx: &mut OuterLoopContext<'_, '_>) {
365        self.inner.initialize(ctx, is_voltage);
366    }
367
368    fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus {
369        if self.inner.states.is_empty() {
370            return OuterLoopStatus::Stable;
371        }
372
373        // Which controllers act on which bus. Several tap changers regulating
374        // one bus is normal on a substation with parallel transformers.
375        let mut by_bus: Vec<(usize, Vec<usize>)> = Vec::new();
376        for (s, state) in self.inner.states.iter().enumerate() {
377            if state.frozen {
378                continue;
379            }
380            let bus = ctx.net.regulation[state.regulation_index].controlled_bus;
381            match by_bus.iter_mut().find(|(b, _)| *b == bus) {
382                Some((_, v)) => v.push(s),
383                None => by_bus.push((bus, vec![s])),
384            }
385        }
386        if by_bus.is_empty() {
387            return OuterLoopStatus::Stable;
388        }
389
390        let n_lines = ctx.net.lines.len();
391        let Some(sensi) = AcSensitivity::new(
392            ctx.net.buses,
393            ctx.net.ybus,
394            ctx.net.lines,
395            ctx.net.transformers,
396        ) else {
397            return OuterLoopStatus::Failed(
398                "tap voltage control: the Jacobian is singular at the operating point".into(),
399            );
400        };
401
402        let mut moved = false;
403        for (bus, controllers) in by_bus {
404            let v = ctx.net.buses[bus].voltage_mag;
405            // Every controller of one bus should name the same target; if they
406            // disagree the tightest deadband and the first target win, and the
407            // disagreement is visible in the report rather than silent.
408            let reg0 = &ctx.net.regulation[self.inner.states[controllers[0]].regulation_index];
409            let target = reg0.target;
410            let half_deadband = reg0.deadband.abs() / 2.0;
411
412            let mut remaining = target - v;
413            if remaining.abs() <= half_deadband {
414                for &s in &controllers {
415                    self.inner.states[s].outcome = ControllerOutcome::InDeadband;
416                }
417                continue;
418            }
419
420            // Sensitivity first, so the controller with the most authority is
421            // asked to do the most.
422            let mut ranked: Vec<(usize, f64)> = Vec::new();
423            for &s in &controllers {
424                let t = ctx.net.regulation[self.inner.states[s].regulation_index].transformer;
425                let sens = sensi
426                    .state_response(Variable::TransformerRatio(n_lines + t))
427                    .map(|r| r.d_vmag[bus])
428                    .unwrap_or(0.0);
429                if sens.abs() < MIN_SENSITIVITY {
430                    self.inner.states[s].outcome = ControllerOutcome::Insensitive;
431                    self.inner.states[s].frozen = true;
432                    continue;
433                }
434                ranked.push((s, sens));
435            }
436            ranked.sort_by(|a, b| b.1.abs().total_cmp(&a.1.abs()));
437
438            for (s, sens) in ranked {
439                if remaining.abs() <= half_deadband {
440                    self.inner.states[s].outcome = ControllerOutcome::InDeadband;
441                    continue;
442                }
443                let t = ctx.net.regulation[self.inner.states[s].regulation_index].transformer;
444                let Some(Some(tc)) = ctx.net.tap_changers.get(t) else {
445                    continue;
446                };
447                let Some(current_ratio) = tc.ratio(tc.position) else {
448                    continue;
449                };
450                let wanted_ratio = current_ratio + remaining / sens;
451                let Some(wanted) = tc.nearest_to_ratio(wanted_ratio) else {
452                    continue;
453                };
454                let (lo, hi) = tc
455                    .steps
456                    .iter()
457                    .map(|t| t.norm())
458                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), r| {
459                        (a.min(r), b.max(r))
460                    });
461                let saturated = wanted_ratio < lo || wanted_ratio > hi;
462
463                let before = tc.position;
464                if self.inner.move_to(s, wanted, saturated, ctx) {
465                    moved = true;
466                    let tc = ctx.net.tap_changers[t].as_ref().expect("moved it");
467                    let achieved =
468                        (tc.ratio(tc.position).unwrap_or(current_ratio) - current_ratio) * sens;
469                    remaining -= achieved;
470                    let _ = before;
471                }
472            }
473        }
474
475        if moved {
476            OuterLoopStatus::Unstable
477        } else {
478            // No blanket relabelling here. `InDeadband` is set where the
479            // deviation was actually measured to be inside, and the three
480            // give-up outcomes where they were actually reached; anything
481            // still `Unfinished` genuinely was not finished, and saying so is
482            // the point of having the field.
483            OuterLoopStatus::Stable
484        }
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Phase control
490// ---------------------------------------------------------------------------
491
492/// A phase-shifting transformer holding the active power on a monitored
493/// branch.
494///
495/// The sensitivity is [`Variable::PhaseShift`] against the branch's own active
496/// flow. That pairing matters: a shifter usually regulates *its own* flow, and
497/// `AcSensitivity` carries a direct \\(\partial f/\partial p\\) term for
498/// exactly that case which, if dropped, flips the sign of the answer on the
499/// regulated branch while leaving every other branch correct.
500pub struct PhaseControl {
501    inner: TapLoop,
502}
503
504impl Default for PhaseControl {
505    fn default() -> Self {
506        Self {
507            inner: TapLoop::new(3),
508        }
509    }
510}
511
512impl PhaseControl {
513    pub fn new() -> Self {
514        Self::default()
515    }
516
517    pub fn max_tap_shift(mut self, steps: i32) -> Self {
518        self.inner.max_tap_shift = steps.max(1);
519        self
520    }
521
522    pub fn report(
523        &self,
524        regulation: &[TapRegulation],
525        tap_changers: &[Option<crate::types::TapChanger>],
526    ) -> TapControlReport {
527        self.inner.report(regulation, tap_changers)
528    }
529}
530
531fn is_active_power(reg: &TapRegulation) -> bool {
532    matches!(reg.mode, RegulationMode::ActivePower { .. })
533}
534
535impl OuterLoop for PhaseControl {
536    fn name(&self) -> &'static str {
537        "PhaseControl"
538    }
539
540    fn invalidates(&self) -> Invalidates {
541        Invalidates::Admittances
542    }
543
544    fn initialize(&mut self, ctx: &mut OuterLoopContext<'_, '_>) {
545        self.inner.initialize(ctx, is_active_power);
546    }
547
548    fn check(&mut self, ctx: &mut OuterLoopContext<'_, '_>) -> OuterLoopStatus {
549        if self.inner.states.is_empty() {
550            return OuterLoopStatus::Stable;
551        }
552
553        // Grouped by the flow they regulate, for the same reason the voltage
554        // loop groups by controlled bus: two shifters holding one branch's
555        // power must be sized against each other, not each against the whole
556        // deviation. FullGrid has exactly that — two phase shifters on one
557        // -65 MW target — and sizing them independently doubles the correction
558        // and sets both oscillating.
559        let mut groups: Vec<((usize, Terminal), Vec<usize>)> = Vec::new();
560        for s in 0..self.inner.states.len() {
561            if self.inner.states[s].frozen {
562                continue;
563            }
564            let reg = &ctx.net.regulation[self.inner.states[s].regulation_index];
565            let RegulationMode::ActivePower { branch, terminal } = reg.mode else {
566                continue;
567            };
568            match groups.iter_mut().find(|(k, _)| *k == (branch, terminal)) {
569                Some((_, v)) => v.push(s),
570                None => groups.push(((branch, terminal), vec![s])),
571            }
572        }
573        if groups.is_empty() {
574            return OuterLoopStatus::Stable;
575        }
576
577        let n_lines = ctx.net.lines.len();
578        let Some(sensi) = AcSensitivity::new(
579            ctx.net.buses,
580            ctx.net.ybus,
581            ctx.net.lines,
582            ctx.net.transformers,
583        ) else {
584            return OuterLoopStatus::Failed(
585                "tap phase control: the Jacobian is singular at the operating point".into(),
586            );
587        };
588        let params = crate::branch_flow::branch_params(ctx.net.lines, ctx.net.transformers);
589        let v = crate::branch_flow::bus_voltages(ctx.net.buses);
590
591        let mut moved = false;
592        for ((branch, terminal), controllers) in groups {
593            let Some(bp) = params.get(branch) else {
594                continue;
595            };
596            let (p, _) = crate::branch_flow::terminal_flow(bp, terminal, &v);
597
598            let reg0 = &ctx.net.regulation[self.inner.states[controllers[0]].regulation_index];
599            let target = reg0.target;
600            let half_deadband = reg0.deadband.abs() / 2.0;
601
602            let mut remaining = target - p;
603            if remaining.abs() <= half_deadband {
604                for &s in &controllers {
605                    self.inner.states[s].outcome = ControllerOutcome::InDeadband;
606                }
607                continue;
608            }
609
610            let mut ranked: Vec<(usize, f64)> = Vec::new();
611            for &s in &controllers {
612                let t = ctx.net.regulation[self.inner.states[s].regulation_index].transformer;
613                let sens = sensi
614                    .branch_response(Variable::PhaseShift(n_lines + t), terminal)
615                    .and_then(|r| r.get(branch).map(|(dp, _)| *dp))
616                    .unwrap_or(0.0);
617                if sens.abs() < MIN_SENSITIVITY {
618                    self.inner.states[s].outcome = ControllerOutcome::Insensitive;
619                    self.inner.states[s].frozen = true;
620                    continue;
621                }
622                ranked.push((s, sens));
623            }
624            // Most authority first, so the shifter that can actually reach the
625            // target does the work and the others are left where they are.
626            ranked.sort_by(|a, b| b.1.abs().total_cmp(&a.1.abs()));
627
628            for (s, sens) in ranked {
629                if remaining.abs() <= half_deadband {
630                    self.inner.states[s].outcome = ControllerOutcome::InDeadband;
631                    continue;
632                }
633                let t = ctx.net.regulation[self.inner.states[s].regulation_index].transformer;
634                let Some(Some(tc)) = ctx.net.tap_changers.get(t) else {
635                    continue;
636                };
637                let Some(current_angle) = tc.angle_deg(tc.position) else {
638                    continue;
639                };
640                // The sensitivity is per radian; the tap table is in degrees.
641                let wanted_angle = current_angle + (remaining / sens).to_degrees();
642                let Some(wanted) = tc.nearest_to_angle(wanted_angle) else {
643                    continue;
644                };
645                let (lo, hi) = tc
646                    .steps
647                    .iter()
648                    .map(|t| t.arg().to_degrees())
649                    .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), r| {
650                        (a.min(r), b.max(r))
651                    });
652                let saturated = wanted_angle < lo || wanted_angle > hi;
653
654                if self.inner.move_to(s, wanted, saturated, ctx) {
655                    moved = true;
656                    let tc = ctx.net.tap_changers[t].as_ref().expect("moved it");
657                    let achieved = (tc.angle_deg(tc.position).unwrap_or(current_angle)
658                        - current_angle)
659                        .to_radians()
660                        * sens;
661                    remaining -= achieved;
662                }
663            }
664        }
665
666        if moved {
667            OuterLoopStatus::Unstable
668        } else {
669            // No blanket relabelling here. `InDeadband` is set where the
670            // deviation was actually measured to be inside, and the three
671            // give-up outcomes where they were actually reached; anything
672            // still `Unfinished` genuinely was not finished, and saying so is
673            // the point of having the field.
674            OuterLoopStatus::Stable
675        }
676    }
677}