signalk-pypilot-newui 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.0 — 2026-10-18 — The sailor always wins, and the pilot gets unstuck
4
+
5
+ Rolls up Rev506 → Rev508 on top of 3.3.0, from the strong-wind sea
6
+ trial of 2026-10-18 (26 kn TWS, 48° heel). Minor bump: new opt-in
7
+ recovery and alarms. No breaking changes; everything new is off until
8
+ enabled.
9
+
10
+ ### Added
11
+ - **Stuck pilot recovery** (Smart Pilot card, opt-in). With no rudder
12
+ sensor, pypilot treats an overcurrent as the end of rudder travel and
13
+ stops driving that way. If the AP is engaged, the heading error is
14
+ over 25° and growing and the servo is not pushing for 4 s, the plugin
15
+ turns the AP off and on (0.7 s, same mode and target), which clears
16
+ that fault in pypilot. At most 3 tries per minute. An order from the
17
+ sailor during the cycle wins, and a running virtual tack is kept.
18
+ - **New alarms** (opt-in, Setup > Alarms): `steer-stuck`,
19
+ `steer-gave-up` ("steer by hand") and `steer-dropped` (AP disengaged
20
+ from the remote or the TinyPilot keys while underway).
21
+ - **+-1 / +-10 during a virtual tack** move the tack's final heading
22
+ instead of being rejected.
23
+ - `servo.flags` is watched and saved in every session sample.
24
+
25
+ ### Fixed
26
+ - **Taking the AP off now cancels a virtual tack**, whatever the source
27
+ (visor, KIP, remote, TinyPilot). Before, the tack kept running and
28
+ rejected the sailor's own headings until it finished. After a cancel
29
+ the boat holds its current wind angle.
30
+ - Repeated taps on the tack button within 1.5 s no longer chain
31
+ start / cancel / start (wind modes).
32
+ - `unable-to-steer` can fire again: it required servo duty, which the
33
+ TinyPilot never reports. It now also fires on a large error while the
34
+ servo is stopped by an overcurrent fault.
35
+ - Settings tab: unlocking a group no longer asks about the profile; the
36
+ question appears only for PROFILE settings. Open or unlocked groups
37
+ are no longer folded, relocked and reset to old values by the 10 s
38
+ background refresh.
39
+
3
40
  ## 3.3.0 — 2026-10-19 — Light on the Pi, AIS on the ring, settings shared by every device
4
41
 
5
42
  Rolls up Rev483 → Rev505 on top of 3.2.0. Minor bump: new shared
package/README.md CHANGED
@@ -147,7 +147,12 @@ up automatically.
147
147
  for 20 s, temporarily switch to the Heavy profile for 30 s, or
148
148
  just warn.
149
149
  17. **Auto-disengage on lost authority** — RMS heading error > 30°
150
- with servo duty > 90% sustained 10 s.
150
+ with servo duty > 90% sustained 10 s. **Stuck pilot recovery**
151
+ (opt-in): with no rudder sensor, pypilot treats an overcurrent as
152
+ the end of rudder travel and stops driving that way; when the error
153
+ keeps growing and the servo is not pushing, the plugin turns the AP
154
+ off and on (same mode and target) to clear it, at most 3 times per
155
+ minute.
151
156
  18. **Pre-departure Autopilot Check** — a single READY / CAVEATS /
152
157
  DO-NOT-ENGAGE verdict aggregating every sensor, servo, voltage
153
158
  and link signal.
@@ -156,8 +161,9 @@ up automatically.
156
161
  20. **Servo Health** — learns a baseline for typical servo duty at
157
162
  sea and flags recent, peak, temperature or voltage deviations
158
163
  from that baseline.
159
- 21. **Alarm engine — 14 rules** — heading deviation, cruise drift,
160
- unable-to-steer, servo overcurrent, servo current near trip,
164
+ 21. **Alarm engine — 17 rules** — heading deviation, cruise drift,
165
+ unable-to-steer, pilot stuck, pilot cannot recover, AP disengaged
166
+ from the remote / TinyPilot, servo overcurrent, servo current near trip,
161
167
  rudder near its end stop, servo controller temperature, motor
162
168
  temperature, high AP load, low voltage, sensor lost, pypilot
163
169
  disconnected, extreme heel, extreme pitch — all published as
package/dist/alarms.js CHANGED
@@ -122,7 +122,7 @@ exports.DEFAULT_RULES = [
122
122
  severity: "alarm",
123
123
  defaultEnabled: true,
124
124
  sustainSec: 20,
125
- description: `Sustained high error AND servo running at ${(exports.RULE_UNABLE_DUTY_MIN * 100) | 0}%+ duty - the AP is losing authority.`,
125
+ description: `Sustained high error AND servo running at ${(exports.RULE_UNABLE_DUTY_MIN * 100) | 0}%+ duty (or stopped by an overcurrent fault) - the AP is losing authority.`,
126
126
  evaluate: (c) => {
127
127
  if (!c.kpis || !c.sample || !c.sample.engaged)
128
128
  return false;
@@ -132,15 +132,57 @@ exports.DEFAULT_RULES = [
132
132
  return false; // Rev323: idem pseudo-modo
133
133
  const r = c.kpis.window1m.rmsErrorRad;
134
134
  const d = c.kpis.window1m.servoDutyPct;
135
- return typeof r === "number" && r > exports.RULE_UNABLE_ERR_RAD
136
- && typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN;
135
+ if (typeof r !== "number" || r <= exports.RULE_UNABLE_ERR_RAD)
136
+ return false;
137
+ // Rev508: servoDuty never arrives from Carlos's TinyPilot, so the
138
+ // rule could not fire on 2026-10-18. A directional overcurrent
139
+ // fault (false end stop) with the same error is the same verdict.
140
+ if (c.steer?.dirFault)
141
+ return true;
142
+ return typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN;
137
143
  },
138
144
  message: (c) => {
139
145
  const r = c.kpis?.window1m.rmsErrorRad ?? 0;
140
- const d = c.kpis?.window1m.servoDutyPct ?? 0;
141
- return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo ${(d * 100).toFixed(0)}% duty`;
146
+ const d = c.kpis?.window1m.servoDutyPct;
147
+ if (c.steer?.dirFault && !(typeof d === "number" && d > exports.RULE_UNABLE_DUTY_MIN)) {
148
+ return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo stopped by overcurrent`;
149
+ }
150
+ return `AP losing authority: ${(r * 180 / Math.PI).toFixed(0)}° error, servo ${((d ?? 0) * 100).toFixed(0)}% duty`;
142
151
  },
143
152
  },
153
+ // Rev508 (Carlos sea trial 2026-10-18): the pilot stopped steering.
154
+ // Opt-in like every alarm (BASIC vs Performance rule); enabled on the
155
+ // Tunatunes from Setup > Alarms. Sustain lives in steer-watch.
156
+ {
157
+ id: "steer-stuck",
158
+ label: "Pilot stuck",
159
+ severity: "alarm",
160
+ defaultEnabled: false,
161
+ sustainSec: 0,
162
+ description: "AP engaged, heading error > 25° and growing, servo not pushing (overcurrent false end stop or 0 A) for 4 s. With recovery enabled the plugin cycles AP off/on.",
163
+ evaluate: (c) => !!c.steer?.stuck && !c.steer.gaveUp,
164
+ message: () => "Pilot stuck: servo not pushing, error growing",
165
+ },
166
+ {
167
+ id: "steer-gave-up",
168
+ label: "Pilot cannot recover",
169
+ severity: "alarm",
170
+ defaultEnabled: false,
171
+ sustainSec: 0,
172
+ description: "Three recovery cycles in one minute did not get the servo pushing again. Steer by hand.",
173
+ evaluate: (c) => !!c.steer?.gaveUp,
174
+ message: () => "Pilot cannot recover: steer by hand",
175
+ },
176
+ {
177
+ id: "steer-dropped",
178
+ label: "AP disengaged from outside the app",
179
+ severity: "alarm",
180
+ defaultEnabled: false,
181
+ sustainSec: 0,
182
+ description: "ap.enabled went off with no order from this app (TinyPilot keys, remote, native UI) while underway. Clears on re-engage or after 60 s.",
183
+ evaluate: (c) => !!c.steer?.droppedActive,
184
+ message: () => "AP disengaged from outside the app (remote / TinyPilot)",
185
+ },
144
186
  {
145
187
  id: "servo-overcurrent",
146
188
  label: "Servo overcurrent",
@@ -110,6 +110,9 @@ class AutopilotProvider {
110
110
  * adjust target, legacy countdown) that would compete with the
111
111
  * maneuver. */
112
112
  this._vtInternalWrite = false;
113
+ /** Rev506: recovery cycles (steer-recovery) toggle the AP off/on on
114
+ * purpose; that must NOT annul the tack the cycle is trying to save. */
115
+ this._recoveryCycleUntil = 0;
113
116
  if (opts?.allowDodge)
114
117
  this.allowDodge = true;
115
118
  this.onDataChanged = opts?.onDataChanged;
@@ -290,6 +293,8 @@ class AutopilotProvider {
290
293
  const pendVal = this.pendingEngaged.value;
291
294
  if (eng === pendVal) {
292
295
  this.pendingEngaged = null;
296
+ if (!eng)
297
+ this._cancelVirtualTackOnDisengage("disengage echo");
293
298
  this.onStageEvent?.("provider", {
294
299
  path: "ap.enabled",
295
300
  decision: "echo_dropped_match",
@@ -317,6 +322,17 @@ class AutopilotProvider {
317
322
  this.data.engaged = eng;
318
323
  changed = true;
319
324
  changedField = "engaged";
325
+ // Rev506: AP off from ANY source (pypilot itself, physical
326
+ // remote, native UI, KIP switch, /raw) annuls the virtual tack.
327
+ if (!eng)
328
+ this._cancelVirtualTackOnDisengage("disengaged externally");
329
+ // Rev508: steer-dropped alarm (skip our own recovery cycle).
330
+ if (!eng && Date.now() >= this._recoveryCycleUntil) {
331
+ try {
332
+ this.onExternalDisengage?.();
333
+ }
334
+ catch { /* observer only */ }
335
+ }
320
336
  }
321
337
  this.onStageEvent?.("provider", {
322
338
  path: "ap.enabled",
@@ -391,6 +407,16 @@ class AutopilotProvider {
391
407
  // echoes while pending.
392
408
  const eng = st.engaged;
393
409
  const apSt = eng ? "enabled" : "disabled";
410
+ // Rev506 (Carlos sea trial 2026-10-18): taking the AP off annuls a
411
+ // running virtual tack ("si quito AP la virada queda anulada"). The
412
+ // FSM used to keep driving after a disengage and then rejected the
413
+ // sailor's own targets ("FSM is driving") until it finished.
414
+ if (!eng)
415
+ this._cancelVirtualTackOnDisengage("disengage order");
416
+ try {
417
+ this.onAppEngageOrder?.();
418
+ }
419
+ catch { /* observer only */ }
394
420
  // Rev271 (audit R01+R04): bump generation and cancel any NAV
395
421
  // pending timer. If this call is superseded before we make it past
396
422
  // the retry backoff, the stale isStale() check aborts the retry
@@ -983,22 +1009,33 @@ class AutopilotProvider {
983
1009
  // pypilot pick the short arc in the WRONG direction).
984
1010
  let remainingIntermediates = geometry.intermediatesRad.slice();
985
1011
  let stepCounter = 0;
986
- let finalCompassTargetRad = geometry.hTargetRad;
987
- // Rev410 (fix T.1-A, Carlos 2026-10-04): publish an initial
988
- // `remainingDeg` so the visor's mirror HUD shows a meaningful
989
- // number from the first snapshot, not just elapsed seconds.
990
- // Granularity thereafter is "once per intermediate step reached"
991
- // (option A, confirmed by Carlos — no 500 ms tick).
992
- {
993
- const h0 = this._readHeadingRad();
994
- if (h0 !== null && this._virtualTack && this._virtualTack.id === vtId) {
995
- this._virtualTack.remainingDeg = Math.abs(shortestArcRad(finalCompassTargetRad, h0) * RAD_TO_DEG);
1012
+ // Rev506: final targets follow the sailor's nudges (adjustRad).
1013
+ const curAdjust = () => (this._virtualTack && this._virtualTack.id === vtId) ? this._virtualTack.adjustRad : 0;
1014
+ const curAdjustVersion = () => (this._virtualTack && this._virtualTack.id === vtId) ? this._virtualTack.adjustVersion : 0;
1015
+ const finalCompass = () => (0, virtual_tack_1.adjustedFinalCompassRad)(geometry, curAdjust());
1016
+ const finalWind = () => (0, virtual_tack_1.adjustedFinalWindRad)(geometry, curAdjust());
1017
+ let seenAdjustVersion = curAdjustVersion();
1018
+ // Rev506: publish remaining degrees on every tick (not only per
1019
+ // step) so the countdown shows what is LEFT to turn.
1020
+ const publishRemaining = (hRad) => {
1021
+ if (hRad === null || !this._virtualTack || this._virtualTack.id !== vtId)
1022
+ return;
1023
+ const rem = Math.abs(shortestArcRad(finalCompass(), hRad) * RAD_TO_DEG);
1024
+ const prev = this._virtualTack.remainingDeg;
1025
+ if (prev === null || Math.abs(prev - rem) >= 1) {
1026
+ this._virtualTack.remainingDeg = rem;
996
1027
  try {
997
1028
  this.notifyChanged("virtualTack");
998
1029
  }
999
1030
  catch { /* noop */ }
1000
1031
  }
1001
- }
1032
+ };
1033
+ // Rev410 (fix T.1-A, Carlos 2026-10-04): publish an initial
1034
+ // `remainingDeg` so the visor's mirror HUD shows a meaningful
1035
+ // number from the first snapshot, not just elapsed seconds.
1036
+ // Granularity thereafter is "once per intermediate step reached"
1037
+ // (option A, confirmed by Carlos — no 500 ms tick).
1038
+ publishRemaining(this._readHeadingRad());
1002
1039
  while (remainingIntermediates.length > 0) {
1003
1040
  checkCancelled();
1004
1041
  this._virtualTack.stepIndex = stepCounter;
@@ -1018,8 +1055,16 @@ class AutopilotProvider {
1018
1055
  // if they want to abort for any other reason.
1019
1056
  let lastHeadingRad = null;
1020
1057
  let lastHeadingChangeTs = Date.now();
1058
+ let retarget = false;
1021
1059
  while (true) {
1022
1060
  const hNow = this._readHeadingRad();
1061
+ publishRemaining(hNow);
1062
+ // Rev506: sailor nudged the destination -> re-aim from here.
1063
+ if (curAdjustVersion() !== seenAdjustVersion) {
1064
+ seenAdjustVersion = curAdjustVersion();
1065
+ retarget = true;
1066
+ break;
1067
+ }
1023
1068
  if (hNow !== null) {
1024
1069
  if ((0, virtual_tack_1.isAtTarget)({
1025
1070
  hNowRad: hNow,
@@ -1046,27 +1091,35 @@ class AutopilotProvider {
1046
1091
  await sleep(500);
1047
1092
  checkCancelled();
1048
1093
  }
1094
+ if (retarget) {
1095
+ const hNow = this._readHeadingRad();
1096
+ if (hNow !== null) {
1097
+ remainingIntermediates = (0, virtual_tack_1.recomputeRemainingIntermediates)({
1098
+ hNowRad: hNow,
1099
+ finalCompassTargetRad: finalCompass(),
1100
+ originalDeltaSign: Math.sign(geometry.deltaHRad),
1101
+ });
1102
+ }
1103
+ else {
1104
+ // No heading: keep the plan but aim the last step at the new final.
1105
+ remainingIntermediates[remainingIntermediates.length - 1] = finalCompass();
1106
+ }
1107
+ // eslint-disable-next-line no-console
1108
+ console.log(`[virtual-tack ${vtId}] re-aimed by sailor: adjust=${(curAdjust() * RAD_TO_DEG).toFixed(0)}deg, steps=${remainingIntermediates.length}`);
1109
+ continue; // write the new first step; nothing was "reached"
1110
+ }
1049
1111
  // Step reached. Pop it and recompute the rest from REAL heading.
1050
1112
  remainingIntermediates.shift();
1051
1113
  stepCounter++;
1052
1114
  // Rev410 (fix T.1-A): refresh `remainingDeg` from the current
1053
1115
  // heading. One update per step reached is the agreed granularity.
1054
- {
1055
- const hAfter = this._readHeadingRad();
1056
- if (hAfter !== null && this._virtualTack && this._virtualTack.id === vtId) {
1057
- this._virtualTack.remainingDeg = Math.abs(shortestArcRad(finalCompassTargetRad, hAfter) * RAD_TO_DEG);
1058
- try {
1059
- this.notifyChanged("virtualTack");
1060
- }
1061
- catch { /* noop */ }
1062
- }
1063
- }
1116
+ publishRemaining(this._readHeadingRad());
1064
1117
  if (remainingIntermediates.length > 0) {
1065
1118
  const hNow = this._readHeadingRad();
1066
1119
  if (hNow !== null) {
1067
1120
  remainingIntermediates = (0, virtual_tack_1.recomputeRemainingIntermediates)({
1068
1121
  hNowRad: hNow,
1069
- finalCompassTargetRad,
1122
+ finalCompassTargetRad: finalCompass(),
1070
1123
  originalDeltaSign: Math.sign(geometry.deltaHRad),
1071
1124
  });
1072
1125
  }
@@ -1091,12 +1144,13 @@ class AutopilotProvider {
1091
1144
  checkCancelled();
1092
1145
  this._vtInternalWrite = true;
1093
1146
  try {
1094
- await this.setTarget(geometry.angleNewRad);
1147
+ await this.setTarget(finalWind());
1095
1148
  }
1096
1149
  finally {
1097
1150
  this._vtInternalWrite = false;
1098
1151
  }
1099
1152
  checkCancelled();
1153
+ seenAdjustVersion = curAdjustVersion();
1100
1154
  // Rev387: settling phase. Watches the wind angle error until
1101
1155
  // it stays under tolerance for a short dwell.
1102
1156
  // Rev407: no artificial "declaring completed anyway" fallback.
@@ -1139,10 +1193,23 @@ class AutopilotProvider {
1139
1193
  }
1140
1194
  }
1141
1195
  catch { /* noop */ }
1196
+ // Rev506: a nudge during settling re-aims the wind target.
1197
+ if (curAdjustVersion() !== seenAdjustVersion) {
1198
+ seenAdjustVersion = curAdjustVersion();
1199
+ this._vtInternalWrite = true;
1200
+ try {
1201
+ await this.setTarget(finalWind());
1202
+ }
1203
+ finally {
1204
+ this._vtInternalWrite = false;
1205
+ }
1206
+ checkCancelled();
1207
+ dwellStart = null;
1208
+ }
1142
1209
  const sampleFresh = windNowRad !== null && (sampleAgeMs === null || sampleAgeMs < STALE_SAMPLE_MS);
1143
1210
  if (sampleFresh) {
1144
1211
  awaEmaRad = (0, virtual_tack_1.awaEmaStep)(awaEmaRad, windNowRad, EMA_ALPHA);
1145
- const errDeg = Math.abs(((((awaEmaRad - geometry.angleNewRad) * RAD_TO_DEG) + 540) % 360) - 180);
1212
+ const errDeg = Math.abs(((((awaEmaRad - finalWind()) * RAD_TO_DEG) + 540) % 360) - 180);
1146
1213
  if (errDeg < SETTLE_TOLERANCE_DEG) {
1147
1214
  if (dwellStart === null)
1148
1215
  dwellStart = Date.now();
@@ -1286,18 +1353,84 @@ class AutopilotProvider {
1286
1353
  * when the sailor taps the tack button a second time (frontend
1287
1354
  * Rev379). Sets phase to "abort" so the running driver bails and
1288
1355
  * rolls back on its next await. */
1289
- cancelVirtualTack() {
1356
+ cancelVirtualTack(reason = "user cancelled") {
1290
1357
  if (!this._virtualTack)
1291
1358
  return;
1292
1359
  const terminal = ["completed", "cancelled", "failed"];
1293
1360
  if (terminal.includes(this._virtualTack.phase))
1294
1361
  return;
1295
1362
  // eslint-disable-next-line no-console
1296
- console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase}`);
1363
+ console.log(`[virtual-tack] external cancel at phase=${this._virtualTack.phase} (${reason})`);
1297
1364
  this._virtualTack.phase = "cancelling";
1298
- this._virtualTack.outcomeReason = "user cancelled";
1365
+ this._virtualTack.outcomeReason = reason;
1299
1366
  this.notifyChanged("all");
1300
1367
  }
1368
+ /** Rev506 (Carlos sea trial 2026-10-18): +-1/+-10 during a virtual tack
1369
+ * move its final destination. deltaRad is bow-relative, positive = bow
1370
+ * to starboard (same sign as the visor's nudge buttons). Returns false
1371
+ * when no tack is running so the caller can fall back to a normal nudge. */
1372
+ adjustVirtualTack(deltaRad) {
1373
+ if (!Number.isFinite(deltaRad) || !this.isVirtualTackActive())
1374
+ return false;
1375
+ const vt = this._virtualTack;
1376
+ if (vt.phase === "cancelling")
1377
+ return false;
1378
+ vt.adjustRad = (0, virtual_tack_1.accumulateTackAdjust)(vt.adjustRad, deltaRad);
1379
+ vt.adjustVersion += 1;
1380
+ // eslint-disable-next-line no-console
1381
+ console.log(`[virtual-tack ${vt.id}] sailor adjust ${(deltaRad * RAD_TO_DEG).toFixed(0)}deg -> total ${(vt.adjustRad * RAD_TO_DEG).toFixed(0)}deg`);
1382
+ this.notifyChanged("virtualTack");
1383
+ return true;
1384
+ }
1385
+ /** Rev506: true while a virtual tack is running (not terminal). */
1386
+ isVirtualTackActive() {
1387
+ const vt = this._virtualTack;
1388
+ return !!vt && !["idle", "completed", "cancelled", "failed"].includes(vt.phase);
1389
+ }
1390
+ /** Rev508 (steer recovery): AP off -> gapMs -> AP on, same mode and
1391
+ * target (pypilot keeps both). Changing ap.enabled clears pypilot's
1392
+ * directional overcurrent fault (servo.py), the false end stop that
1393
+ * left the servo at 0 A on 2026-10-18. A running virtual tack is NOT
1394
+ * annulled by the cycle. If any other engage/disengage order arrives
1395
+ * meanwhile, the cycle aborts and does not re-engage. */
1396
+ async recoveryCycle(gapMs = 700) {
1397
+ if (!this.data.engaged || !this.client.connected)
1398
+ return "skipped";
1399
+ this._recoveryCycleUntil = Date.now() + gapMs + 3000;
1400
+ try {
1401
+ await this.setState("disabled");
1402
+ }
1403
+ catch {
1404
+ this._recoveryCycleUntil = 0;
1405
+ return "aborted";
1406
+ }
1407
+ const gen = this.engageGen;
1408
+ await new Promise((r) => setTimeout(r, gapMs));
1409
+ if (this.engageGen !== gen) {
1410
+ // The sailor gave an order during the gap: theirs wins. If it was a
1411
+ // disengage, the tack must be annulled as usual.
1412
+ this._recoveryCycleUntil = 0;
1413
+ if (!this.data.engaged)
1414
+ this._cancelVirtualTackOnDisengage("disengage during recovery");
1415
+ return "aborted";
1416
+ }
1417
+ try {
1418
+ await this.setState("enabled");
1419
+ }
1420
+ catch {
1421
+ this._recoveryCycleUntil = 0;
1422
+ this._cancelVirtualTackOnDisengage("recovery re-engage failed");
1423
+ return "aborted";
1424
+ }
1425
+ return "done";
1426
+ }
1427
+ _cancelVirtualTackOnDisengage(reason) {
1428
+ if (Date.now() < this._recoveryCycleUntil)
1429
+ return;
1430
+ if (!this.isVirtualTackActive())
1431
+ return;
1432
+ this.cancelVirtualTack(reason);
1433
+ }
1301
1434
  async engage() {
1302
1435
  // Rev279 (audit follow-up A): the catch used to fall through to
1303
1436
  // setState("enabled") on ANY setNavMode failure, including a