d/dt(V^2/2) = V * dV/dt. Using the airspeed setpoint instead of the current
airspeed scales the demand by the airspeed error ratio. After a fast descend
(sim log 2026-09-09 11:47) at 30 m/s with a 15 m/s setpoint the demanded
deceleration of 0.36 m/s^2 was converted to half the energy rate it needs,
the throttle feedforward stopped at 0.22 instead of the minimum, and the
airspeed took 80 s to recover. Shared helper used by the energy rate
computation and by the envelope projection.
The controller reports the altitude rate bounds it determined in the envelope
projection (throttle and pitch envelope, before rate limiting). The altitude
reference model uses them, capped by the parameters, as the velocity limits
of both trajectory generators and of the target climb/sink rate. The
reference thus stays achievable and no longer runs away from the aircraft
when the performance parameters overstate what can be flown; the projection
in the controller degrades to a guard for the feedback terms.
One cycle of delay between controller and reference model; NAN bounds (before
the first controller update) fall back to the parameters.
The altitude loop output clamp to [-max_sink_rate, max_climb_rate] and the
total energy rate setpoint clamp in the throttle loop duplicate the throttle
envelope applied in _projectAltitudeRateSetpointToEnvelope (which is tighter
on the sink side: only min_sink_rate is fundable at trim speed and minimum
throttle). Closed-loop tests pass unchanged.
More code is now stale or only guards the feedback terms, left for follow-ups:
- Pitch integrator anti-windup (_calcPitchControlUpdate): feedforward plus
integrator can no longer exceed the pitch limit, only the damping term
can, so freezing the integrator whenever the clamped setpoint is at the
limit blocks learning the offset in a pitch-limited climb (the envelope
then overestimates the flyable climb rate and a standing airspeed error
remains). Bound the integrator state to the pitch limits instead.
- Pitch rate limiter (_calcPitchControl): the feedforward path is already
slewed by the projection; the limiter only bounds feedback-driven pitch
rate and could be dropped or reduced to a guard.
- The 0.5 factor on the airspeed rate demand limits
(_calcAirspeedControlOutput), blended to 1 in fast descend: it reserves
throttle margin for the total energy loop, but the projection already
gives the kinetic energy rate demand priority and backs the climb rate
off instead. The factor halves the achievable deceleration at large
airspeed errors (80 s recovery after fast descend in sim). Candidate for
1.0, or drop the limit and let the projection clip the kinetic demand as
the last resort.
- Fast descend throttle fade (_calcThrottleControl): the projection fades
the deliverable energy rate to STE_rate_min, so the feedforward already
goes to minimum throttle; the explicit fade only overrides feedback.
- _calculateTotalEnergyRateLimit is evaluated four times per cycle
(airspeed loop, projection, throttle loop, initialize); compute once.
- With an exact feedforward on a feasible demand, FW_T_HRATE_FF and
FW_T_SEB_R_FF should be 1 and could go.
With a consistent, flyable demand in place two inconsistencies inside the
loops remain:
- The pitch feedforward flies the climb angle of the speed-weighted energy
balance rate setpoint, (2 - w) * PE_rate_sp - w * KE_rate_sp. Pitch sets
the flight path angle, so the only feedforward consistent with the throttle
funding the total energy rate is the climb angle of the potential energy
rate demand. The weight leaks into the airspeed: at w = 1.8 pitch flies
20 % of the demanded climb angle and the throttle pours the remaining 80 %
of the climb energy into speed; at w = 0.2 (and in airspeed-less mode,
w = 0) pitch flies nearly twice the climb angle and loses speed. Even at
w = 1 the kinetic term makes pitch trade height for a speed change that the
throttle already funds, doubling the speed response and sagging altitude.
- The throttle feedback filters the total energy rate estimate, but compares
it against the unfiltered setpoint. A setpoint step therefore shows up as
a phantom error decaying with the filter time constant, which the damping
and integrator act on although the feedforward already delivers it.
Fixes:
- Feed forward the climb angle of the projected potential energy rate
demand only. The speed weight now applies to the feedback errors
alone, as the FW_T_SPDWEIGHT description states.
- Filter the total energy rate error instead of the estimate, so the
feedback only sees what the feedforward does not deliver.
The closed-loop tests pass with these two changes.
TECS forms its energy rate demands from the outer loop outputs without
checking that pitch and throttle can deliver them. Pitch and throttle
then work from inconsistent demands, and the difference goes into the
airspeed:
- When pitch is limited the throttle still funds the full climb rate,
and the surplus becomes overspeed; the mirror case ends in underspeed
- When the throttle is saturated pitch still flies the full climb angle,
and the deficit comes out of the airspeed (should not happen with
correct config)
- The pitch setpoint is rate limited but the throttle feedforward is not,
so every climb rate change is funded before pitch can fly it
Add a single stage between the outer loops and the energy rate computation
that projects the controlled altitude rate setpoint onto the envelope:
- the throttle envelope (total energy rate limits, less the kinetic energy
rate demand which has priority, less turn drag, faded for fast descend),
- the pitch envelope (pitch limits less the pitch integrator state, which
holds the pitch-to-flight-path offset),
- the vertical acceleration limit, so that throttle does not fund a climb
rate change before pitch may fly it.
Both loops then see the same feasible demand and need no knowledge of each
other's limits. The direct height rate path goes through the same point.
- test(tecs): improve sim stats collection
- test(tecs): factor out and decrease numerical tolerance
- test(tecs): use firmware default parameters in closed-loop tests
- test(tecs): tighten airspeed tracking tolerance to 0.03 m/s
- test(tecs): run closed-loop tests in the functional gtest harness
- test(tecs): add closed-loop tests with a plant pitch-to-flight-path offset
- test(tecs): re-capture the airspeed recovery envelope for the designed response
- test(tecs): closed loop test with pitch limit close to plant offset
- test(tecs): tighten tolerance and simplify limits
Purpose: define, pinpoint and fix basic internal consistency issues.
TECS should be able to handle this case (zero noise or model mismatch)
almost perfectly.
Some of them fail currently, will come up with fixes afterwards.
The model has 2 states (airspeed, height) and 2 inputs (throttle and
pitch). The dynamics correspond exactly to the model assumed in TECS and
the pitch input applies immediately without any inner dynamics.
This model is simulated at 50Hz, and the test harness allows changing
tecs parameters, and airspeed / altitude setpoints. It also records the
maximum airspeed error over each simulation run.
Signed-off-by: Balduin <balduin@auterion.com>
The driver only hard-resets when SPI6_nRESET_EXTERNAL1 / SPI6_RESET are defined. v6xrt named the pin GPIO_SPI6_nRESET_EXTERNAL1, so SCH16T never left software reset.
NUTTX_GIT_TAG_STR was computed by taking the highest nuttx-X.Y.Z tag in
the NuttX submodule, without checking that the tag is an ancestor of the
checkout. The PX4/NuttX fork's tags were last synced in Oct 2022 and
topped out at nuttx-11.0.0, so every build has reported NuttX 11.0.0
regardless of the actual kernel (ver all, AUTOPILOT_VERSION.os_sw_version,
the ULog sys_os_ver_release field). With the nuttx-12.12.0 tag now pushed
for the NuttX 12.12 upgrade (#26215), fresh clones of main would flip to
misreporting 12.12.0 while still running the 10.3-era kernel.
Resolve the version from the fork branch name recorded in .gitmodules
(px4_firmware_nuttx-X.Y.Z+) instead, which is updated atomically with
every kernel change, and fall back to git describe for checkouts that do
not follow the fork convention. Output format is unchanged: vX.Y.Z, as
consumed by version_tag_to_number().
Assisted-by: Claude:claude-fable-5
Signed-off-by: Ramon Roche <mrpollo@gmail.com>
The FC-side GlobalTimeSyncMaster needs the TX timestamp of its previous
sync frame, delivered by a loopback receive; without it every broadcast
carries previous_transmission_timestamp_usec 0 and slaves never adjust.
The SocketCAN platform had no loopback at all, never filled the UTC
timestamp of received frames, and clock::adjustUtc() was a stub, so the
UTC clock was wall time rather than the HRT base the sensor bridges
expect. Time sync was therefore a silent no-op on every i.MX RT, Kinetis
and S32K board.
Frames sent with CanIOFlagLoopback are now handed back from receive()
stamped at the moment they entered a hardware mailbox: NuttX has no TX
echo, so this stands in for the true transmission time with a bias of at
most one frame time when the sync frame wins arbitration. All received
frames get a UTC timestamp derived from SO_TIMESTAMP, and SystemClock is
one shared instance whose first adjustUtc() is absolute, so the existing
hrt_absolute_time() seed makes bus time equal HRT as on STM32.
* fix(uavcan): report FlexCAN bus state and error counters on SocketCAN
The SocketCAN platform driver returned 0 from getErrorCount(),
getRxQueueOverflowCount() and isInBusOffState(), so a controller sitting
error-passive or bus-off was invisible from `uavcan status` and the
can_interface_status topic on every i.MX RT board. Read them through
NuttX's SIOCGCANERRORS instead, and print fault confinement, TEC/REC and
RX overruns per interface. Builds without the ioctl keep the old zeros.
* fix(uavcan): apply UAVCAN_BITRATE on SocketCAN
CanDriver::init() ignored its bitrate argument, so the controller stayed
at the Kconfig rate and a bus configured for anything but 1 Mbit/s never
came up. Program the nominal rate through SIOCSCANBITRATE when it differs
from what the driver reports, keeping the data-phase settings untouched.
The driver applies the timing at the next ifup, so the interface is taken
down around the request. Older PX4/NuttX restarted a running controller
from inside the ioctl instead, which on FlexCAN with ECC RAM
initialisation is a bus fault; against that NuttX the rate is left as
configured with a warning.
* fix(boards): switch SocketCAN defconfigs to NETDEV_CAN_IOCTL
PX4/NuttX#401 merges the per-command CAN ioctl options into a single
NETDEV_CAN_IOCTL, so the old BITRATE/FILTER/ERROR names no longer
exist there and would be silently dropped, disabling the whole CAN
ioctl block. Requires the NuttX gitlink to include PX4/NuttX#401;
until that bump the option is dropped against the old tree and the
bitrate/error ioctls are inert at runtime (the code builds either
way). mr-canhubk3 is included so its bitrate ioctl survives the bump.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* chore(nuttx): bump NuttX to the px4_firmware_nuttx-10.3.0+ tip
Picks up PX4/NuttX#401 so SIOCGCANERRORS, SIOCSCANBITRATE-requires-ifdown
and NETDEV_CAN_IOCTL are in the tree the SocketCAN changes need. Also
includes PX4/NuttX#399 (STM32F412VG/CG chip selections), which landed
between main's gitlink and #401.
Assisted-by: Grok:grok-4.6
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
---------
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* fix(ci): attach FMU bootloader .bin to GitHub releases
Releases only shipped `_bootloader.px4`, the USB uploader envelope. SWD recovery needs the raw `.bin` at 0x08000000.
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* fix(ci): do not attach unused bootloader .px4
USB flashing would write it into the application slot. SWD uses the raw .bin.
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* fix(ci): omit bootloader SBOMs from GitHub releases
GitHub Releases glob artifacts/**/*.sbom.spdx.json. Bootloader metadata dirs are not a recovery artifact.
*_bootloader_* variants (e.g. bootloader_secureboot) stay omitted: those images are baked with in-tree test keys.
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* docs(bootloader): point SWD recovery at the release .bin
Match the cannode pre-built blurb. The .px4 envelope is not the SWD image.
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* docs(bootloader): scope release .bin to SWD programmers
gdb load needs ELF program headers. The release image is a raw .bin for ST-Link / CubeProgrammer / OpenOCD, and only in-tree *_bootloader targets are attached.
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
---------
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
Picks up PX4/NuttX#402, where memmem() missed a needle that ends the
haystack, and a configurable STM32 Ethernet TX watchdog timeout whose
default is the 60 s the drivers hard-coded before.
The default label is multicopter-only and already occupies 96.8% of the
1792 KB application slot, so the fixed-wing stack cannot be added
alongside it. This variant swaps the two, mirroring ark_fpv_fw, and
lands at 95.3%.
PX4 only bundles the 2xxx/3xxx airframes when FW_RATE_CONTROL is
enabled, so the plane entries added to the board airframe whitelist are
inert for every other label.
Run() published heater_status at both GPIO edges, 200 Hz for a 100 Hz
controller, and the default log profile recorded it at full rate. At
100 % duty the off phase ran with a zero delay, so the element was
switched off and back on every period and heater_on read false for that
instant. heater_current and supply_voltage were battery_status.current_a
and voltage_v, already logged there, and nominal_multiplier read 0 unless
HEATERn_NOM_V compensation was active.
The off phase no longer publishes and is skipped at full duty; the status
goes out once per controller cycle with heater_on meaning the element is
driven this period. The two battery fields are removed (the voltage is
still read for the V_nom compensation), nominal_multiplier is 1 when no
scaling is applied, and heater_status is logged at 1 Hz by default.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
PC5 was initialized as an ADC and then reconfigured as a digital input with pull-down, preventing analog readings from the RSSI pad.
Assisted-by: OpenAI:GPT-5.6 Thinking
Signed-off-by: Watson-Sei <55475145+Watson-Sei@users.noreply.github.com>
* feat(battery_simulator): simulate all battery instances with per-battery drain
Simulate one battery per battery_status instance instead of a single
hardcoded one. The Battery array is constructed in place from
battery_status_s::MAX_INSTANCES, so the simulator follows the maximum
number of batteries without code changes. A battery is only published
when its BAT<N>_SOURCE is set to power module, which keeps the default
SITL behavior of a single battery unchanged.
Add SIM_BAT<N>_DRAIN and SIM_BAT<N>_MIN_PCT to override the shared
SIM_BAT_DRAIN and SIM_BAT_MIN_PCT per battery (-1 inherits the shared
value), and integrate the state of charge per battery. This allows
testing multi-battery behavior such as one battery draining faster
than the others.
* docs(simulation): describe simulating multiple batteries
Document how additional simulated batteries are enabled through
BAT<N>_SOURCE and how SIM_BAT<N>_DRAIN and SIM_BAT<N>_MIN_PCT override
the shared drain parameters per battery.
* docs(simulation): clarify per-battery fallback rules
or close to it. This leads to all kinds of issues 0% to numerical, close to 0 to logic e.g. land detection. This needs better dynamic limiting but a hard limit between 10 and 90% for the hover thrust make sense since >10:1 thust to weight ratio was not seen yet and we can adjust if someone has this use case.
* refactor(ina228): read CURRENT/SHUNT via ModuleParams
Matches the ina226/ina238 driver style instead of manual
param_find()/param_get().
* fix(drivers/power_monitor): raise INA228/INA238_CURRENT max to 1638.40
327.68 was derived from the driver's default 0.5 mOhm shunt
(163.84 mV / 0.0005 Ohm) and didn't scale for lower-resistance shunts.
1638.40 = 163.84 mV / 0.0001 Ohm; 0.1mOhm is near the practical floor for a
wattage-appropriate current-sense resistor in a reasonable package (such as 5930) for modern DPMs.
Flight tasks are statically allocated, so _terrain_hold and
_dist_to_ground_lock survive switching to another mode and back. On
reactivation with sticks centered and a valid distance to ground,
terrain following would resume on the old lock and command the height
above ground from before the mode switch, however long ago and far
away that was. Since no disengage or re-engage transition runs in
between, invalidating the lock on those transitions cannot cover this
path. Start every activation with terrain hold off and no lock; the
regular engage logic then re-latches at the current height above
ground when the conditions are met.
When terrain hold disengages due to horizontal stick input,
_dist_to_ground_lock currently keeps its last value. If terrain hold
then re-engages while the altitude sp is NaN (vertical velocity control)
the vehicle would fly to the old height above ground.
Fix: Always invalidate the lock when stopping terrain hold.
Reproduce the failure / verify the fix:
- make px4_sitl gz_x500_lidar_down with default params
- particularly: MPC_ALT_MODE 2
- Take off in position mode, hover at low altitude to lock
- Give horizontal stick input to disengage terrain hold
- Give throttle up to ascend
- Stop giving horizontal stick input
- Release throttle at higher altitude
Result before: Vehicle descends down to initial altitude
Result after: Vehicle stays at altitude where throttle was released +
braking distance
* feat(uavcan): parse STATUS_FLAG_CHARGING from BatteryInfo
- Map UAVCAN STATUS_FLAG_CHARGING to uORB STATE_CHARGING
- Cover both Raw and Filter data paths
- Enables MAVLink BATTERY_STATUS.charge_state to report CHARGING(7)
* refactor(msg): rename STATE_UNHEALTHY/STATE_CHARGING to WARNING_UNHEALTHY/WARNING_CHARGING
Rename for naming consistency: these enum values belong to the
'warning' field, so they should use the WARNING_ prefix like the
other values (WARNING_NONE, WARNING_LOW, etc.).
Also fix @enum annotation from 'WARNING STATE' to 'WARNING'.
* fix(land_detector): latch rotational_movement between publications
* fix(land_detector): latch all purely diagnostic flags
such that we don't have to publish on every change e.g. every movement threshold corssing but still see the least land detection friendly state per publish period.
---------
Co-authored-by: Matthias Grob <maetugr@gmail.com>
* fix(uavcan): reply to file services on the request interface
Firmware-update File.Read responses were queued on every CAN interface. Dual-CAN boards use independent buses, so the other interface's TX queue filled with a copy of the image.
Assisted-by: Grok:grok-4.6
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* perf(uavcan): set the response iface mask only for opted-in servers
The unconditional write restored AllIfacesMask on every response of every
ServiceServer instantiation, costing ~168 B of flash for a mask that was
already in place.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* fix(uavcan): restore all-iface mask when request-iface replies are off
The per-response write was dropped to save flash, so disabling the option left the publisher on the last request's interface.
Assisted-by: Grok:grok-4.6
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
---------
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
Require each known variable-length frame to contain the fixed prefix
consumed by its processor while continuing to allow trailing extension fields.
Bound ELRS status text copying by the received payload and add parser contract regression coverage.
* fix(navigator): do not report the end of a mission as a storage failure
getNonJumpItem() walks forward past items it skips, including a DO_JUMP
that has no repeats left. When such a DO_JUMP is the last item in the
mission the walk steps one index beyond the end and calls
loadMissionItemFromCache(), which short circuits on its own bounds check
without ever touching the datamanager. The caller then took the read
failure path and told the operator:
"Waypoint could not be read." (mavlink_log_critical)
"Waypoint {1} could not be read from storage" (events, Log::Error)
so a mission that simply ran to completion reported a Critical storage
error. A mission of the form [WP, WP, WP, DO_JUMP(repeat N)] hits this on
its final pass, and the look ahead for the next position setpoint takes
the same path, so it can repeat.
Check the index against the mission bounds inside the loop, exactly as
the function already does for the index it is entered with, and return
the same quiet PX4_ERROR that an out of range index produces there.
Callers already treat that as "no further item". Genuine datamanager
failures still report as before.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>
* test(navigator): cover traversal past the mission bounds over an exhausted DO_JUMP
Two regression tests for getNonJumpItem entered at a DO_JUMP whose
repeats are used up. Skipping it walks the index one step outside the
mission, forward off the end and backward in front of the start. Both
must report PX4_ERROR the same quiet way an out of range entry index
does, instead of taking the storage failure path.
On unfixed code the forward case reproduces the false
"Waypoint could not be read" critical error that a mission ending in an
exhausted DO_JUMP publishes on its final pass.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>
---------
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>
NuttX queues every received CAN frame in one I/O buffer from the pool
shared with the rest of the network stack. At 24 buffers the two FlexCAN
interfaces together have 7 ms of headroom at 3260 frames/s, and the
uavcan thread is preempted for longer than that: the socket layer
dropped 445 frames per 300 s on the bench, which shows up as transfer
errors and failed DroneCAN parameter reads. 192 buffers (40 KB of the
1.1 MB free) give 59 ms at that rate; IOB_NCHAINS follows by default.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Jacob Dahl <dahl.jakejacob@gmail.com>
* fix(ekf2): keep evaluating instance selection while the primary is unhealthy
The selection block only ran when UpdateErrorScores() reported a change:
a new instance appearing or a health transition. A primary that stops
publishing produces exactly one such transition, on the cycle its
timeout is detected. If no switch happens on that cycle, nothing sets
updated again: the stable alternatives do not count as primary updates
and the timed out instance is skipped thereafter, so the fallback logic
is never evaluated again even though the module keeps being scheduled.
Today that single evaluation always resolves the situation, because the
fallback switches unconditionally to the best healthy candidate on that
same cycle. But any selection policy that can decline to switch on the
transition cycle, for example one that waits out a transient fault,
needs the decision re-evaluated while the primary remains unhealthy.
Re-enter the selection block whenever the selected instance is
unhealthy. The decisions inside are unchanged and switching is
idempotent, so behaviour today is identical.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>
* fix(ekf2): do not fail over to an instance with a sustained test ratio failure
When the primary EKF goes unhealthy the selector falls back to the best
instance that is healthy, and healthy only requires zero filter fault
flags and a positive combined test ratio. An instance whose test ratio
has been failing for a long time therefore remains a first class switch
target even though its state can be far from the truth.
b7efd4f947 introduced this on purpose for the switch-away direction: a
test ratio at or above one became a warning rather than ill health, so a
transient ratio spike cannot hard fail an instance, and a warned primary
is left through the lower relative error path once the warning has been
sustained for one second. What that commit did not do is apply the same
reasoning to the switch-to direction. The candidate loop only filters on
healthy, so a brief hard fault on the primary, for example transient
accelerometer clipping, sends the selector straight to a diverged
instance.
That is the mechanism behind the repeated altitude jumps in issue 27013:
one instance had stopped fusing baro, its vertical state up to 155.6 m
from the other instance while its combined test ratio sat pegged at 2,
and each of the seven short clipping faults on the good instance bounced
the selector back to it (15 instance switches in total counting the
returns), the worst switch stepping the published altitude by 128.5 m
and provoking a hard TECS reaction.
Classify fallback candidates with the same sustained warning test the
switch-away trigger already uses. When the primary goes unhealthy, fail
over immediately to the best candidate without a sustained warning; the
different IMU preference is kept within each tier, and a candidate
without a sustained warning is preferred even over a warned candidate on
a different IMU, since a warned instance is the one known to be
diverging. A sustained warned candidate is accepted in two cases only:
the primary has timed out entirely, where frozen attitude and position
outputs are worse than any live alternative, or the primary has been
continuously unhealthy for kWarnedFallbackDelay (five seconds), so a
brief fault rides out on the current state while a persistently faulted
primary still gets the least bad alternative rather than none. The
re-evaluation of this decision while the primary stays unhealthy is
provided by the previous commit.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>
* test(ekf2): add a functional test for the instance selector
The selector had no test at any level. This drives EKF2Selector through
published multi instance estimator_status messages on the real work
queue and observes estimator_selector_status, so the selection policy is
exercised without a simulator.
The scenarios encode the failure pattern from issue 27013 and the
no-whipsaw property discussed there: a clean fallback on a hard primary
fault stays immediate, the switch away from a degraded primary through
the sustained warning path still works, three separate brief hard faults
on the primary no longer bounce the selector to an instance whose test
ratio has been failing for seconds, a primary that stops publishing
falls back to the degraded instance without delay, and a primary that
stays hard faulted for longer than the ride-out window still falls back
rather than being kept forever.
The scenario setup helpers run until the selector reaches the intended
starting state rather than assuming fixed timings, since the health
hysteresis and warning windows run on wall clock time. The fault-clear
windows exceed the selector's one second healthy hysteresis so the
faults are genuinely separate and the unhealthy-since tracking restarts
between them. The harness waits until the work queue manager actually
serves queues before constructing the selector: a fixed delay races the
manager startup on a loaded runner.
to run: make tests TESTFILTER=EKF2Selector
Assisted-by: Claude:claude-fable-5
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>
---------
Signed-off-by: Saibernard Yogendran <bernie97@seas.upenn.edu>