fix(failsafe): FC comms loss now forces unconditional RED per DIR-7/DIR-12

This commit is contained in:
Grant 2026-07-11 11:56:38 +02:00
parent 2add8c9e68
commit 86b272802f

View File

@ -18,7 +18,11 @@ Priority order (highest wins when multiple conditions active):
2. Critical/emergency battery 2. Critical/emergency battery
3. Safety depth limit (300m) 3. Safety depth limit (300m)
4. Obstacle detected in RED state 4. Obstacle detected in RED state
5. Comms loss (assessed response) 5. Comms loss (assessed response) flag_comms_loss now forces
assessment_state straight to RED in _assess_conditions() per DIR-7/
DIR-12 (FC comms loss is an unconditional RED, not a negotiable
"marginal" parameter). This priority level therefore always takes the
RED branch of _handle_comms_loss() see that method's docstring.
6. Manual abort (assessed response) 6. Manual abort (assessed response)
7. Thruster anomaly (AMBER downgrade) 7. Thruster anomaly (AMBER downgrade)
8. Low battery warning (informational) 8. Low battery warning (informational)
@ -121,6 +125,19 @@ class FailsafeMonitor(Node):
# Sensor readings # Sensor readings
self.battery_percent = 1.0 # 0.01.0 self.battery_percent = 1.0 # 0.01.0
self.fcu_connected = False self.fcu_connected = False
# DIR-7: "the assessment MUST incorporate flight-controller
# connectivity and heartbeat age. ... A monitor with no input MUST
# NOT report GREEN — absence of data is not health." fcu_connected
# alone only reflects the most recent /mavros/state message; it
# cannot tell "never received a heartbeat" apart from "was connected,
# heartbeat just ticked over". This flag latches True the first time
# msg.connected is True (see _state_callback) and never resets. It is
# used in _assess_conditions to force flag_comms_loss True for the
# entire startup window before any FC data has ever arrived — closing
# the gap where the node would otherwise report GREEN for up to
# comms_timeout_s with zero FC input — and in _publish_status to pick
# the correct cause text ("Vehicle Disconnected" vs "Heartbeat Lost").
self.fcu_ever_connected = False
self.current_depth_m = 0.0 self.current_depth_m = 0.0
# Timing # Timing
@ -215,6 +232,12 @@ class FailsafeMonitor(Node):
# Reset comms loss tracking on each received heartbeat # Reset comms loss tracking on each received heartbeat
self.last_heartbeat = self.get_clock().now() self.last_heartbeat = self.get_clock().now()
self.comms_loss_start = None self.comms_loss_start = None
# DIR-7: latch that real FC data has arrived at least once. Set
# True here only, never reset back to False — distinguishes "FC
# has never connected" (-> "Vehicle Disconnected") from "FC
# connected and then dropped" (-> "Heartbeat Lost") for the
# cause text in _publish_status.
self.fcu_ever_connected = True
def _altitude_callback(self, msg: Float64): def _altitude_callback(self, msg: Float64):
""" """
@ -253,15 +276,43 @@ class FailsafeMonitor(Node):
def _assess_conditions(self): def _assess_conditions(self):
""" """
Evaluate all monitored parameters and derive assessment state. Evaluate all monitored parameters and derive assessment state.
GREEN: all nominal, 5+ parameters assessed. GREEN: all nominal, 4 marginal parameters assessed.
AMBER: core navigation intact, <= 1 parameter marginal. AMBER: core navigation intact, <= 1 of those 4 parameters marginal.
RED: navigation sensor failure, >= 2 marginal, or any safety-critical lost. RED: navigation sensor failure, >= 2 marginal, any safety-critical
lost, OR flag_comms_loss.
flag_comms_loss is deliberately NOT one of the 4 "marginal"
parameters below (it was previously a 5th equally-weighted marginal
parameter, which meant comms loss alone only reached AMBER). Per
DIR-7 ("A dead or unreachable FC is a RED condition, not optional")
and DIR-12 ("treat loss of FC communications as a vehicle-health RED
regardless of how healthy the autonomy stack is"), it is applied as
its own unconditional OR term in the RED branch instead see below.
""" """
now = self.get_clock().now() now = self.get_clock().now()
# --- Comms --- # --- Comms ---
# DIR-7 (quoted in full): "the assessment MUST incorporate flight-
# controller connectivity and heartbeat age. A dead or unreachable FC
# is a RED condition, not optional. A monitor with no input MUST NOT
# report GREEN — absence of data is not health."
elapsed_s = (now - self.last_heartbeat).nanoseconds / 1e9 elapsed_s = (now - self.last_heartbeat).nanoseconds / 1e9
self.flag_comms_loss = elapsed_s > self._comms_timeout_s # flag_comms_loss has two independent triggers, either sufficient on
# its own:
# 1. elapsed_s > comms_timeout_s — the normal loss-of-link case:
# the FC connected at some point and its heartbeat has since
# aged out.
# 2. not fcu_ever_connected — the startup-window case: no FC data
# has EVER arrived, so elapsed_s is only measuring time since
# node start (self.last_heartbeat is initialised at construction,
# not from a real heartbeat). Without this term the node would
# report GREEN for up to comms_timeout_s after boot despite
# having zero FC input the entire time — precisely the "monitor
# with no input MUST NOT report GREEN" case DIR-7 prohibits.
self.flag_comms_loss = (
elapsed_s > self._comms_timeout_s
or not self.fcu_ever_connected
)
if self.flag_comms_loss and self.comms_loss_start is None: if self.flag_comms_loss and self.comms_loss_start is None:
self.comms_loss_start = now self.comms_loss_start = now
@ -287,15 +338,26 @@ class FailsafeMonitor(Node):
# self.flag_obstacle = <sonar_node data> # self.flag_obstacle = <sonar_node data>
# --- Derive assessment state --- # --- Derive assessment state ---
# flag_comms_loss is excluded from marginal_count on purpose (see the
# docstring above and the DIR-7/DIR-12 quotes there) — it is no
# longer one of the equally-weighted "marginal" parameters that only
# reach RED in combination. Folding it back in here would let FC
# comms health be traded off against unrelated marginal parameters,
# or sit at a mere AMBER when alone, which is exactly what DIR-12
# forbids ("regardless of how healthy the autonomy stack is").
marginal_count = sum([ marginal_count = sum([
self.flag_comms_loss,
self.flag_low_battery, self.flag_low_battery,
self.flag_depth_exceeded, self.flag_depth_exceeded,
self.flag_obstacle, self.flag_obstacle,
self.flag_thruster_anomaly, self.flag_thruster_anomaly,
]) ])
if self.flag_emergency_battery or self.flag_depth_exceeded or marginal_count >= 2: # DIR-7 + DIR-12: flag_comms_loss is its own unconditional OR term,
# separate from marginal_count >= 2, so a comms loss alone is always
# RED — never merely AMBER, never dependent on a second marginal
# parameter also being true.
if (self.flag_emergency_battery or self.flag_depth_exceeded
or self.flag_comms_loss or marginal_count >= 2):
self.assessment_state = AssessmentState.RED self.assessment_state = AssessmentState.RED
elif marginal_count == 1: elif marginal_count == 1:
self.assessment_state = AssessmentState.AMBER self.assessment_state = AssessmentState.AMBER
@ -334,6 +396,17 @@ class FailsafeMonitor(Node):
""" """
Apply failsafe conditions in strict priority order per design spec. Apply failsafe conditions in strict priority order per design spec.
Higher priority conditions override lower priority ones. Higher priority conditions override lower priority ones.
Priority 5 (comms loss) note: _assess_conditions() now sets
assessment_state to RED whenever flag_comms_loss is True (DIR-7:
"A dead or unreachable FC is a RED condition, not optional"; DIR-12:
"treat loss of FC communications as a vehicle-health RED regardless
of how healthy the autonomy stack is"). So by the time this method
runs, a genuine comms loss always has assessment_state == RED, and
_handle_comms_loss() below always takes its RED branch (immediate
RETURN_TO_SAFE). Its GREEN/AMBER hold-and-retry branches are dead
code for that case retained as a structural fallback rather than
deleted, but not expected to execute.
""" """
now = self.get_clock().now() now = self.get_clock().now()
@ -404,6 +477,16 @@ class FailsafeMonitor(Node):
GREEN: hold 60s then RETURN_TO_SAFE. GREEN: hold 60s then RETURN_TO_SAFE.
AMBER: hold 30s then RETURN_TO_SAFE. AMBER: hold 30s then RETURN_TO_SAFE.
RED: RETURN_TO_SAFE immediately. RED: RETURN_TO_SAFE immediately.
DIR-7/DIR-12: flag_comms_loss now forces assessment_state to RED
directly in _assess_conditions() (DIR-7: "A dead or unreachable FC is
a RED condition, not optional"; DIR-12: "treat loss of FC
communications as a vehicle-health RED regardless of how healthy the
autonomy stack is"). This method is therefore only ever entered with
assessment_state == RED for a genuine comms loss, so it always takes
the RED branch immediately below. The GREEN/AMBER branches (60s/30s
hold before returning to safe) are no longer reachable for a genuine
comms loss they are retained as a structural fallback only.
""" """
if self.assessment_state == AssessmentState.RED: if self.assessment_state == AssessmentState.RED:
self._transition(FSMState.RETURN_TO_SAFE, self._transition(FSMState.RETURN_TO_SAFE,
@ -507,7 +590,22 @@ class FailsafeMonitor(Node):
FSMState.ABORTED: FailsafeStatus.ACTION_NONE, FSMState.ABORTED: FailsafeStatus.ACTION_NONE,
}.get(self.fsm_state, FailsafeStatus.ACTION_NONE) }.get(self.fsm_state, FailsafeStatus.ACTION_NONE)
# DIR-7 (quoted in full): "The status line MUST carry cause text such
# as 'Vehicle Disconnected' or 'Heartbeat Lost', not a bare colour."
# Prefix the message with the specific comms-loss cause so an
# operator (or the W1 widget) can distinguish an FC that has NEVER
# connected (fcu_ever_connected False — likely wiring/power/board)
# from one that connected and then dropped (heartbeat aged out past
# comms_timeout_s).
cause_prefix = ''
if self.flag_comms_loss:
cause_prefix = (
'Vehicle Disconnected | ' if not self.fcu_ever_connected
else 'Heartbeat Lost | '
)
msg.message = ( msg.message = (
f'{cause_prefix}'
f'Assessment: {self._assessment_state_name()} | ' f'Assessment: {self._assessment_state_name()} | '
f'State: {self._fsm_state_name(self.fsm_state)} | ' f'State: {self._fsm_state_name(self.fsm_state)} | '
f'Battery: {self.battery_percent*100:.0f}%' f'Battery: {self.battery_percent*100:.0f}%'