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 = ®ulation[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}