CogniPilot

MODELICA LIBRARY · ENGINEERING REFERENCE

model

Tests.AidingHorizonTests

Aiding measurements reach the filter at the fusion instant their own timestamps name, in order, with a bounded residual, and nothing is lost

Simulated as a top-level model through Tests/run-horizon.mos. The epoch and the release pulse come from a real Estimation.FusionHorizon.OutputPredictor rather than a synthetic clock, because the property under test is that the queues and the delta ring agree about which instant the filter stands on, and a test that manufactured its own epoch would agree with itself.

The flight arm runs the real lattice and the real latencies. The horizon, the source rates and the source transport delays are the ones Vehicles.Rdd2.WaypointVehicleSystem carries, and those delays are PX4's shipped EKF2 defaults, so the residual measured here is the one the deployed configuration produces rather than one produced by a lattice chosen to make the run short. Two further arms bracket the refusal boundary at fusionHorizon_s + maximumResidualAge_s by half a release window on each side; the short-horizon arms remain for the mechanism properties, overflow and the degenerate anchor, where the rate is not what is under test.

The filter itself is absent, and that is a tool limitation rather than a choice: OpenModelica cannot build a simulation containing a navigation estimator sub-component, which is recorded in Tests.HorizonEstimatorWiring and was re-measured for the composed horizon while this model was written -- a build of one HorizonEstimator was still running after ten minutes with no result. Every property here is a property of the aiding path up to the filter's connector, which is where the delayed-fusion contract lives.

Why the ordering and epoch checks are published by the block rather than made here. They were made here first, and they could not be trusted. Reading a sub-component's output-connector members from the parent does not report the written values under OpenModelica 1.27: a delivered GPS packet whose row inside the block held timestamp 0.03 and whose computed residual was correct read as timestamp_s = 0 both in a parent when-clause and in the result file, while the Boolean valid on the same connector read correctly. The same model also failed to report a driven input connector's Boolean at all. Three distinct reporting failures, all silent, and each one makes an assertion pass rather than fail -- a queue whose delivered payload reads as zeros satisfies every check written about its contents.

So the invariants are checked where the data is, inside Estimation.FusionHorizon.AidingBuffer, against the rows themselves, and published as deliveryOutOfOrder and deliveryAfterHorizon. That is weaker evidence than an external check and it is worth saying so: a block that computes its own invariant could be wrong in the way that hides it. It is also better supervision than the external form would have been, because these are properties of a SEQUENCE and a consumer that sees one packet at a time cannot reconstruct them. The production path is not affected by any of this -- Estimation.FusionHorizon.HorizonEstimator consumes every aiding connector with a whole-record equation into the filter, which is the case that works.

Public declarations

Explicit source declarations; dimensions, modifications and defaults are shown as written, without evaluation.

RoleNameTypeDimensions / attributes / defaultDescription
constantsamplePeriodReal= 0.00125800 Hz inertial tick
constantfusionPeriod_sReal= 0.01100 Hz fusion release
constantfusionHorizon_sReal= 0.2PX4 EKF2_DELAY_MAX, default 200 ms: the delay between now and the delayed-time horizon. The same quantity and the same value.
constantgpsPeriod_sReal= 0.110 Hz
constantgpsLatency_sReal= 0.11PX4 EKF2_GPS_DELAY, v1.15.0 default 110 ms
constantopticalFlowPeriod_sReal= 0.01100 Hz
constantopticalFlowLatency_sReal= 0.02PX4 EKF2_OF_DELAY, main default 20 ms
constantbarometerPeriod_sReal= 0.0250 Hz
constantbarometerLatency_sReal= 0.0PX4 EKF2_BARO_DELAY, main default 0 ms: an onboard sensor on the autopilot's own clock has no transport to model. It is still DELAYED by the horizon, which is the point -- a zero-latency source waits the whole fusionHorizon_s before it ripens, and that path is exercised here.
constantdeclaredSourceDelay_sReal= gpsLatency_sThe slowest source these arms declare. GPS, and the horizon has to cover it with margin.
constantjitterMargin_sReal= 0.05
constantmarginalLatency_sReal= fusionHorizon_s - 0.5 * fusionPeriod_s0.195 s: arrives 0.005 s ahead of the fusion instant, half a release window inside the boundary. Must be DELIVERED
constantlateLatency_sReal= fusionHorizon_s + 1.5 * fusionPeriod_s0.215 s: arrives 0.015 s past the fusion instant, beyond the residual bound. Must be REFUSED by name
constantsettled_sReal= 0.45Past the horizon fill, the first release, and the first GPS delivery
constantstopTime_sReal= 1.2
constantresidualBound_sReal= fusionPeriod_sDerived, not chosen: a measurement ripens on the first release at or after its own timestamp, and releases are fusionPeriod_s apart, so the offset is in [0, fusionPeriod_s).
constantcrowdedHorizon_sReal= 0.05

Modelica declarations and implementation

Show parameters, interfaces and source

Parameters, inputs, outputs, defaults and equations from the source file.

model AidingHorizonTests
  "Aiding measurements reach the filter at the fusion instant their own
   timestamps name, in order, with a bounded residual, and nothing is lost"

  constant Real samplePeriod = 0.00125 "800 Hz inertial tick";
  constant Real fusionPeriod_s = 0.01 "100 Hz fusion release";

  // ---- the flight lattice, with the delays the plant actually applies ------
  // Not a scaled-down stand-in. The horizon, the source rates and the source
  // latencies are the ones Vehicles.Rdd2.WaypointVehicleSystem carries, and
  // those latencies are PX4's shipped EKF2 delay defaults, so a residual
  // measured here is the residual the deployed configuration produces rather
  // than one produced by a lattice chosen to make the run short.
  constant Real fusionHorizon_s = 0.2
    "PX4 EKF2_DELAY_MAX, default 200 ms: the delay between now and the
     delayed-time horizon. The same quantity and the same value.";
  constant Real gpsPeriod_s = 0.1 "10 Hz";
  constant Real gpsLatency_s = 0.11
    "PX4 EKF2_GPS_DELAY, v1.15.0 default 110 ms";
  constant Real opticalFlowPeriod_s = 0.01 "100 Hz";
  constant Real opticalFlowLatency_s = 0.02
    "PX4 EKF2_OF_DELAY, main default 20 ms";
  constant Real barometerPeriod_s = 0.02 "50 Hz";
  constant Real barometerLatency_s = 0.0
    "PX4 EKF2_BARO_DELAY, main default 0 ms: an onboard sensor on the
     autopilot's own clock has no transport to model. It is still DELAYED by
     the horizon, which is the point -- a zero-latency source waits the whole
     fusionHorizon_s before it ripens, and that path is exercised here.";
  constant Real declaredSourceDelay_s = gpsLatency_s
    "The slowest source these arms declare. GPS, and the horizon has to cover
     it with margin.";
  constant Real jitterMargin_s = 0.05;

  // ---- the boundary these two bracket -------------------------------------
  // The boundary in TRANSPORT LATENCY is the horizon itself, and working out
  // why took a failed assertion. A measurement is FUSED when it arrives still
  // ahead of the fusion instant, so that it ripens on a later release with an
  // age inside one window. A measurement arriving already ripe cannot be
  // delivered on the tick it arrives -- delivery reads the queue as it stood
  // before that tick's store -- so by the next release the instant has moved a
  // whole window past it. The deliverable condition is therefore a latency
  // below fusionHorizon_s, not below fusionHorizon_s + maximumResidualAge_s.
  //
  // Between the two lies a narrow band, 0.2 to 0.21 s here, where a packet is
  // admitted and then discarded as stale instead of refused as late. Both
  // outcomes are named and counted, and the horizon assertion keeps every
  // DECLARED source far below the band, so only a packet later than its own
  // source declares can reach it. It is recorded rather than tested because
  // what distinguishes the two is which name an anomaly is reported under.
  constant Real marginalLatency_s = fusionHorizon_s - 0.5 * fusionPeriod_s
    "0.195 s: arrives 0.005 s ahead of the fusion instant, half a release
     window inside the boundary. Must be DELIVERED";
  constant Real lateLatency_s = fusionHorizon_s + 1.5 * fusionPeriod_s
    "0.215 s: arrives 0.015 s past the fusion instant, beyond the residual
     bound. Must be REFUSED by name";

  constant Real settled_s = 0.45
    "Past the horizon fill, the first release, and the first GPS delivery";
  constant Real stopTime_s = 1.2;
  constant Real residualBound_s = fusionPeriod_s
    "Derived, not chosen: a measurement ripens on the first release at or
     after its own timestamp, and releases are fusionPeriod_s apart, so the
     offset is in [0, fusionPeriod_s).";

  Real elapsed_s(start = 0.0, fixed = true)
    "Continuous anchor. A model assembled only from clocked blocks has no
     continuous equation at all and OpenModelica index reduction refuses to
     build one. Not under test.";

  // The epoch and the release pulse come from a REAL output predictor rather
  // than from a synthetic clock. That is the point of the composition: the
  // queues and the delta ring have to agree about which instant the filter is
  // standing on, and a test that manufactured its own epoch would agree with
  // itself and prove nothing about the pair.
  Estimation.FusionHorizon.OutputPredictor predictor(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=fusionHorizon_s);

  // Every arm below shrinks the mocap queue to its minimum by declaring a
  // slow mocap rate, which is the documented way to stop carrying a horizon of
  // slots for a stream a vehicle does not have. RDD2 has none.
  Estimation.FusionHorizon.AidingBuffer flight(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=fusionHorizon_s,
    mocapPeriod_s=1.0,
    gpsPeriod_s=gpsPeriod_s,
    magnetometerPeriod_s=1.0,
    barometerPeriod_s=barometerPeriod_s,
    opticalFlowPeriod_s=opticalFlowPeriod_s,
    maximumSourceDelay_s=declaredSourceDelay_s,
    horizonJitterMargin_s=jitterMargin_s);

  // Just INSIDE the refusal boundary. The declared delay stays legal; what is
  // near the boundary is the packet, not the configuration, which is the right
  // way round -- the assertion on the horizon governs what a deployment says
  // its sensors do, and the refusal path governs what a packet actually did.
  Estimation.FusionHorizon.AidingBuffer marginal(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=fusionHorizon_s,
    mocapPeriod_s=1.0,
    gpsPeriod_s=gpsPeriod_s,
    magnetometerPeriod_s=1.0,
    barometerPeriod_s=1.0,
    opticalFlowPeriod_s=1.0,
    maximumSourceDelay_s=declaredSourceDelay_s,
    horizonJitterMargin_s=jitterMargin_s);

  // Just OUTSIDE it.
  Estimation.FusionHorizon.AidingBuffer late(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=fusionHorizon_s,
    mocapPeriod_s=1.0,
    gpsPeriod_s=gpsPeriod_s,
    magnetometerPeriod_s=1.0,
    barometerPeriod_s=1.0,
    opticalFlowPeriod_s=1.0,
    maximumSourceDelay_s=declaredSourceDelay_s,
    horizonJitterMargin_s=jitterMargin_s);

  // A source delivering far faster than it declares, so its queue fills and
  // has to refuse. Kept on a SHORT horizon because the property is a mechanism
  // and not a rate, and a short horizon reaches it in a fraction of the run.
  constant Real crowdedHorizon_s = 0.05;
  Estimation.FusionHorizon.OutputPredictor crowdedPredictor(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=crowdedHorizon_s);
  Estimation.FusionHorizon.AidingBuffer crowded(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=crowdedHorizon_s,
    mocapPeriod_s=1.0,
    gpsPeriod_s=0.2,
    magnetometerPeriod_s=1.0,
    barometerPeriod_s=1.0,
    opticalFlowPeriod_s=1.0,
    maximumSourceDelay_s=0.03,
    horizonJitterMargin_s=0.01);

  // THE DEGENERATE-CASE ANCHOR. One release window of horizon, the shortest
  // the block admits, and measurements arriving with no latency at all. There
  // the delay a measurement experiences is at most one release period, which
  // is the delay the live-edge path imposes by sampling, so a queue at the
  // minimum horizon is indistinguishable from handing the measurement to the
  // filter on its next tick. If the queue machinery has a cost that does not
  // vanish as the horizon shrinks, this is the arm that shows it.
  Estimation.FusionHorizon.OutputPredictor degeneratePredictor(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=fusionPeriod_s);
  Estimation.FusionHorizon.AidingBuffer degenerate(
    samplePeriod=samplePeriod,
    fusionPeriod_s=fusionPeriod_s,
    fusionHorizon_s=fusionPeriod_s,
    mocapPeriod_s=1.0,
    gpsPeriod_s=0.02,
    magnetometerPeriod_s=1.0,
    barometerPeriod_s=1.0,
    opticalFlowPeriod_s=1.0,
    maximumSourceDelay_s=0.0,
    horizonJitterMargin_s=0.0);

  discrete Boolean gpsPresent(start = false, fixed = true);
  discrete Real gpsTimestamp_s(start = -1.0, fixed = true);
  discrete Boolean barometerPresent(start = false, fixed = true);
  discrete Real barometerTimestamp_s(start = -1.0, fixed = true);
  discrete Boolean opticalFlowPresent(start = false, fixed = true);
  discrete Real opticalFlowTimestamp_s(start = -1.0, fixed = true);
  discrete Real marginalTimestamp_s(start = -1.0, fixed = true);
  discrete Real lateTimestamp_s(start = -1.0, fixed = true);
  discrete Boolean crowdedPresent(start = false, fixed = true);
  discrete Real crowdedTimestamp_s(start = -1.0, fixed = true);
  discrete Boolean degeneratePresent(start = false, fixed = true);

  discrete Real worstArrivalAge_s(start = 0.0, fixed = true)
    "The age a GPS fix carried when it was PRESENTED, which is the interval
     the live-edge path would have transported its Jacobian over. Measured on
     the same stream as the residual, so the comparison is one run rather than
     two.";

algorithm
  when sample(0.0, samplePeriod) then
    // Each source pulses at its own rate and is stamped its own latency in the
    // past. Held between pulses with a fixed timestamp, so the queue's novelty
    // rule is what makes it enter exactly once -- the same rule and the same
    // waveform the deployed pulsed GPS driver produces.
    gpsPresent := abs(time / gpsPeriod_s - floor(time / gpsPeriod_s + 0.5))
      < 1.0e-9;
    gpsTimestamp_s := if gpsPresent then time - gpsLatency_s
      else pre(gpsTimestamp_s);
    barometerPresent := abs(time / barometerPeriod_s
      - floor(time / barometerPeriod_s + 0.5)) < 1.0e-9;
    barometerTimestamp_s := if barometerPresent
      then time - barometerLatency_s else pre(barometerTimestamp_s);
    opticalFlowPresent := abs(time / opticalFlowPeriod_s
      - floor(time / opticalFlowPeriod_s + 0.5)) < 1.0e-9;
    opticalFlowTimestamp_s := if opticalFlowPresent
      then time - opticalFlowLatency_s else pre(opticalFlowTimestamp_s);
    marginalTimestamp_s := if gpsPresent then time - marginalLatency_s
      else pre(marginalTimestamp_s);
    lateTimestamp_s := if gpsPresent then time - lateLatency_s
      else pre(lateTimestamp_s);
    crowdedPresent := abs(time / 0.005 - floor(time / 0.005 + 0.5)) < 1.0e-9;
    crowdedTimestamp_s := if crowdedPresent then time - 0.03
      else pre(crowdedTimestamp_s);
    degeneratePresent := abs(time / 0.02 - floor(time / 0.02 + 0.5)) < 1.0e-9;

    // The only thing observed from out here is the stream this model DRIVES.
    // Everything about what the queues did with it is read from the queues'
    // own published signals; see the documentation below for why.
    // Measured over the whole run, not just the settled part: it is a property
    // of the stream rather than of the queue.
    worstArrivalAge_s := if gpsPresent
      then max(pre(worstArrivalAge_s), time - gpsTimestamp_s)
      else pre(worstArrivalAge_s);
  end when;

equation
  der(elapsed_s) = 1.0;

  predictor.reset = false;
  predictor.angularVelocityMeasuredBodyFlu_rad_s =
    {0.4 * sin(11.0 * time), 0.3 * cos(7.0 * time), 0.2 * sin(5.0 * time)};
  predictor.specificForceMeasuredBodyFlu_m_s2 =
    {0.6 * sin(9.0 * time), 0.5 * cos(13.0 * time),
     9.81 + 0.4 * sin(3.0 * time)};
  predictor.horizonStateValid = predictor.horizonReady;
  predictor.horizonStateShifted = false;
  predictor.horizonPositionWorldEnu_m = zeros(3);
  predictor.horizonVelocityWorldEnu_m_s = zeros(3);
  predictor.horizonQuaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  predictor.horizonGyroscopeBiasBodyFlu_rad_s = zeros(3);
  predictor.horizonAccelerometerBiasBodyFlu_m_s2 = zeros(3);

  crowdedPredictor.reset = false;
  crowdedPredictor.angularVelocityMeasuredBodyFlu_rad_s =
    predictor.angularVelocityMeasuredBodyFlu_rad_s;
  crowdedPredictor.specificForceMeasuredBodyFlu_m_s2 =
    predictor.specificForceMeasuredBodyFlu_m_s2;
  crowdedPredictor.horizonStateValid = crowdedPredictor.horizonReady;
  crowdedPredictor.horizonStateShifted = false;
  crowdedPredictor.horizonPositionWorldEnu_m = zeros(3);
  crowdedPredictor.horizonVelocityWorldEnu_m_s = zeros(3);
  crowdedPredictor.horizonQuaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  crowdedPredictor.horizonGyroscopeBiasBodyFlu_rad_s = zeros(3);
  crowdedPredictor.horizonAccelerometerBiasBodyFlu_m_s2 = zeros(3);

  degeneratePredictor.reset = false;
  degeneratePredictor.angularVelocityMeasuredBodyFlu_rad_s =
    predictor.angularVelocityMeasuredBodyFlu_rad_s;
  degeneratePredictor.specificForceMeasuredBodyFlu_m_s2 =
    predictor.specificForceMeasuredBodyFlu_m_s2;
  degeneratePredictor.horizonStateValid = degeneratePredictor.horizonReady;
  degeneratePredictor.horizonStateShifted = false;
  degeneratePredictor.horizonPositionWorldEnu_m = zeros(3);
  degeneratePredictor.horizonVelocityWorldEnu_m_s = zeros(3);
  degeneratePredictor.horizonQuaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  degeneratePredictor.horizonGyroscopeBiasBodyFlu_rad_s = zeros(3);
  degeneratePredictor.horizonAccelerometerBiasBodyFlu_m_s2 = zeros(3);

  // ---- the flight arm: three sources at their real rates and latencies -----
  // FIELD BY FIELD, never a whole-record equality between two connectors. That
  // is not something OpenModelica generates code for: it publishes zeros on
  // the target and reports nothing. Measured while this model was written --
  // an arm aliased that way was never valid, so its queue admitted nothing for
  // the whole run and every assertion about it passed vacuously.
  flight.reset = false;
  flight.horizonValid = predictor.horizonReady;
  flight.horizonEpoch_s = predictor.horizonPacket.timestamp_s;
  flight.horizonReleased = predictor.horizonPacket.valid;
  flight.mocap.valid = false;
  flight.mocap.fresh = false;
  flight.mocap.timestamp_s = time;
  flight.mocap.positionWorldEnu_m = zeros(3);
  flight.mocap.quaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  flight.mocap.positionCovarianceWorld_m2 = identity(3);
  flight.mocap.attitudeCovarianceBody_rad2 = identity(3);
  flight.gps.valid = gpsPresent;
  flight.gps.fresh = gpsPresent;
  flight.gps.positionValid = true;
  flight.gps.velocityValid = true;
  flight.gps.timestamp_s = gpsTimestamp_s;
  flight.gps.geodetic_deg_m = zeros(3);
  flight.gps.positionWorldEnu_m = zeros(3);
  flight.gps.velocityWorldEnu_m_s = zeros(3);
  flight.gps.positionCovarianceWorld_m2 = identity(3);
  flight.gps.velocityCovarianceWorld_m2_s2 = identity(3);
  flight.magnetometer.valid = false;
  flight.magnetometer.fresh = false;
  flight.magnetometer.timestamp_s = time;
  flight.magnetometer.magneticFieldBodyFlu_T = zeros(3);
  flight.magnetometer.covarianceBody_T2 = identity(3);
  flight.barometer.valid = barometerPresent;
  flight.barometer.fresh = barometerPresent;
  flight.barometer.timestamp_s = barometerTimestamp_s;
  flight.barometer.altitudeWorldEnu_m = 1.0;
  flight.barometer.variance_m2 = 1.0;
  flight.opticalFlow.valid = opticalFlowPresent;
  flight.opticalFlow.fresh = opticalFlowPresent;
  flight.opticalFlow.timestamp_s = opticalFlowTimestamp_s;
  flight.opticalFlow.integratedLineOfSight_rad = zeros(2);
  flight.opticalFlow.integratedLineOfSightCovariance_rad2 = identity(2);
  flight.opticalFlow.integratedGyroscopeBodyFlu_rad = zeros(3);
  flight.opticalFlow.integratedGyroscopeCovariance_rad2 = identity(3);
  flight.opticalFlow.integrationTime_s = 0.01;
  flight.opticalFlow.groundDistance_m = 1.0;
  flight.opticalFlow.groundDistanceVariance_m2 = 0.01;
  flight.opticalFlow.quality = 1.0;

  marginal.reset = false;
  marginal.horizonValid = predictor.horizonReady;
  marginal.horizonEpoch_s = predictor.horizonPacket.timestamp_s;
  marginal.horizonReleased = predictor.horizonPacket.valid;
  marginal.mocap.valid = false;
  marginal.mocap.fresh = false;
  marginal.mocap.timestamp_s = time;
  marginal.mocap.positionWorldEnu_m = zeros(3);
  marginal.mocap.quaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  marginal.mocap.positionCovarianceWorld_m2 = identity(3);
  marginal.mocap.attitudeCovarianceBody_rad2 = identity(3);
  marginal.gps.valid = gpsPresent;
  marginal.gps.fresh = gpsPresent;
  marginal.gps.positionValid = true;
  marginal.gps.velocityValid = false;
  marginal.gps.timestamp_s = marginalTimestamp_s;
  marginal.gps.geodetic_deg_m = zeros(3);
  marginal.gps.positionWorldEnu_m = zeros(3);
  marginal.gps.velocityWorldEnu_m_s = zeros(3);
  marginal.gps.positionCovarianceWorld_m2 = identity(3);
  marginal.gps.velocityCovarianceWorld_m2_s2 = identity(3);
  marginal.magnetometer.valid = false;
  marginal.magnetometer.fresh = false;
  marginal.magnetometer.timestamp_s = time;
  marginal.magnetometer.magneticFieldBodyFlu_T = zeros(3);
  marginal.magnetometer.covarianceBody_T2 = identity(3);
  marginal.barometer.valid = false;
  marginal.barometer.fresh = false;
  marginal.barometer.timestamp_s = time;
  marginal.barometer.altitudeWorldEnu_m = 0.0;
  marginal.barometer.variance_m2 = 1.0;
  marginal.opticalFlow.valid = false;
  marginal.opticalFlow.fresh = false;
  marginal.opticalFlow.timestamp_s = time;
  marginal.opticalFlow.integratedLineOfSight_rad = zeros(2);
  marginal.opticalFlow.integratedLineOfSightCovariance_rad2 = identity(2);
  marginal.opticalFlow.integratedGyroscopeBodyFlu_rad = zeros(3);
  marginal.opticalFlow.integratedGyroscopeCovariance_rad2 = identity(3);
  marginal.opticalFlow.integrationTime_s = 0.01;
  marginal.opticalFlow.groundDistance_m = 1.0;
  marginal.opticalFlow.groundDistanceVariance_m2 = 0.01;
  marginal.opticalFlow.quality = 1.0;

  late.reset = false;
  late.horizonValid = predictor.horizonReady;
  late.horizonEpoch_s = predictor.horizonPacket.timestamp_s;
  late.horizonReleased = predictor.horizonPacket.valid;
  late.mocap.valid = false;
  late.mocap.fresh = false;
  late.mocap.timestamp_s = time;
  late.mocap.positionWorldEnu_m = zeros(3);
  late.mocap.quaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  late.mocap.positionCovarianceWorld_m2 = identity(3);
  late.mocap.attitudeCovarianceBody_rad2 = identity(3);
  // The late arm carries ONE source and every other stream is off, so its
  // delivered count is a statement about the late fixes alone.
  late.gps.valid = gpsPresent;
  late.gps.fresh = gpsPresent;
  late.gps.positionValid = true;
  late.gps.velocityValid = false;
  late.gps.timestamp_s = lateTimestamp_s;
  late.gps.geodetic_deg_m = zeros(3);
  late.gps.positionWorldEnu_m = zeros(3);
  late.gps.velocityWorldEnu_m_s = zeros(3);
  late.gps.positionCovarianceWorld_m2 = identity(3);
  late.gps.velocityCovarianceWorld_m2_s2 = identity(3);
  late.magnetometer.valid = false;
  late.magnetometer.fresh = false;
  late.magnetometer.timestamp_s = time;
  late.magnetometer.magneticFieldBodyFlu_T = zeros(3);
  late.magnetometer.covarianceBody_T2 = identity(3);
  late.barometer.valid = false;
  late.barometer.fresh = false;
  late.barometer.timestamp_s = time;
  late.barometer.altitudeWorldEnu_m = 0.0;
  late.barometer.variance_m2 = 1.0;
  late.opticalFlow.valid = false;
  late.opticalFlow.fresh = false;
  late.opticalFlow.timestamp_s = time;
  late.opticalFlow.integratedLineOfSight_rad = zeros(2);
  late.opticalFlow.integratedLineOfSightCovariance_rad2 = identity(2);
  late.opticalFlow.integratedGyroscopeBodyFlu_rad = zeros(3);
  late.opticalFlow.integratedGyroscopeCovariance_rad2 = identity(3);
  late.opticalFlow.integrationTime_s = 0.01;
  late.opticalFlow.groundDistance_m = 1.0;
  late.opticalFlow.groundDistanceVariance_m2 = 0.01;
  late.opticalFlow.quality = 1.0;

  crowded.reset = false;
  crowded.horizonValid = crowdedPredictor.horizonReady;
  crowded.horizonEpoch_s = crowdedPredictor.horizonPacket.timestamp_s;
  crowded.horizonReleased = crowdedPredictor.horizonPacket.valid;
  crowded.mocap.valid = false;
  crowded.mocap.fresh = false;
  crowded.mocap.timestamp_s = time;
  crowded.mocap.positionWorldEnu_m = zeros(3);
  crowded.mocap.quaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  crowded.mocap.positionCovarianceWorld_m2 = identity(3);
  crowded.mocap.attitudeCovarianceBody_rad2 = identity(3);
  crowded.gps.valid = crowdedPresent;
  crowded.gps.fresh = crowdedPresent;
  crowded.gps.positionValid = true;
  crowded.gps.velocityValid = false;
  crowded.gps.timestamp_s = crowdedTimestamp_s;
  crowded.gps.geodetic_deg_m = zeros(3);
  crowded.gps.positionWorldEnu_m = zeros(3);
  crowded.gps.velocityWorldEnu_m_s = zeros(3);
  crowded.gps.positionCovarianceWorld_m2 = identity(3);
  crowded.gps.velocityCovarianceWorld_m2_s2 = identity(3);
  crowded.magnetometer.valid = false;
  crowded.magnetometer.fresh = false;
  crowded.magnetometer.timestamp_s = time;
  crowded.magnetometer.magneticFieldBodyFlu_T = zeros(3);
  crowded.magnetometer.covarianceBody_T2 = identity(3);
  crowded.barometer.valid = false;
  crowded.barometer.fresh = false;
  crowded.barometer.timestamp_s = time;
  crowded.barometer.altitudeWorldEnu_m = 0.0;
  crowded.barometer.variance_m2 = 1.0;
  crowded.opticalFlow.valid = false;
  crowded.opticalFlow.fresh = false;
  crowded.opticalFlow.timestamp_s = time;
  crowded.opticalFlow.integratedLineOfSight_rad = zeros(2);
  crowded.opticalFlow.integratedLineOfSightCovariance_rad2 = identity(2);
  crowded.opticalFlow.integratedGyroscopeBodyFlu_rad = zeros(3);
  crowded.opticalFlow.integratedGyroscopeCovariance_rad2 = identity(3);
  crowded.opticalFlow.integrationTime_s = 0.01;
  crowded.opticalFlow.groundDistance_m = 1.0;
  crowded.opticalFlow.groundDistanceVariance_m2 = 0.01;
  crowded.opticalFlow.quality = 1.0;

  degenerate.reset = false;
  degenerate.horizonValid = degeneratePredictor.horizonReady;
  degenerate.horizonEpoch_s = degeneratePredictor.horizonPacket.timestamp_s;
  degenerate.horizonReleased = degeneratePredictor.horizonPacket.valid;
  degenerate.mocap.valid = false;
  degenerate.mocap.fresh = false;
  degenerate.mocap.timestamp_s = time;
  degenerate.mocap.positionWorldEnu_m = zeros(3);
  degenerate.mocap.quaternionWorldBody = {1.0, 0.0, 0.0, 0.0};
  degenerate.mocap.positionCovarianceWorld_m2 = identity(3);
  degenerate.mocap.attitudeCovarianceBody_rad2 = identity(3);
  // ZERO LATENCY: stamped at the instant it is presented.
  degenerate.gps.valid = degeneratePresent;
  degenerate.gps.fresh = degeneratePresent;
  degenerate.gps.positionValid = true;
  degenerate.gps.velocityValid = false;
  degenerate.gps.timestamp_s = time;
  degenerate.gps.geodetic_deg_m = zeros(3);
  degenerate.gps.positionWorldEnu_m = zeros(3);
  degenerate.gps.velocityWorldEnu_m_s = zeros(3);
  degenerate.gps.positionCovarianceWorld_m2 = identity(3);
  degenerate.gps.velocityCovarianceWorld_m2_s2 = identity(3);
  degenerate.magnetometer.valid = false;
  degenerate.magnetometer.fresh = false;
  degenerate.magnetometer.timestamp_s = time;
  degenerate.magnetometer.magneticFieldBodyFlu_T = zeros(3);
  degenerate.magnetometer.covarianceBody_T2 = identity(3);
  degenerate.barometer.valid = false;
  degenerate.barometer.fresh = false;
  degenerate.barometer.timestamp_s = time;
  degenerate.barometer.altitudeWorldEnu_m = 0.0;
  degenerate.barometer.variance_m2 = 1.0;
  degenerate.opticalFlow.valid = false;
  degenerate.opticalFlow.fresh = false;
  degenerate.opticalFlow.timestamp_s = time;
  degenerate.opticalFlow.integratedLineOfSight_rad = zeros(2);
  degenerate.opticalFlow.integratedLineOfSightCovariance_rad2 = identity(2);
  degenerate.opticalFlow.integratedGyroscopeBodyFlu_rad = zeros(3);
  degenerate.opticalFlow.integratedGyroscopeCovariance_rad2 = identity(3);
  degenerate.opticalFlow.integrationTime_s = 0.01;
  degenerate.opticalFlow.groundDistance_m = 1.0;
  degenerate.opticalFlow.groundDistanceVariance_m2 = 0.01;
  degenerate.opticalFlow.quality = 1.0;

  // ---- 1. nothing is fused before it was measured -------------------------
  // The whole delayed-fusion contract in one line. A measurement handed to
  // the filter must be one the fusion instant has already REACHED; delivering
  // one ahead of the instant the filter stands on is handing it a measurement
  // from its own future, and that is exactly the state the merged horizon was
  // in before these queues existed, because the filter's epoch moved back and
  // the aiding did not move with it.
  assert(not flight.deliveryAfterHorizon,
    "A measurement was delivered before the fusion instant reached its
     timestamp, so the filter would fuse a measurement from its own future");
  assert(not marginal.deliveryAfterHorizon,
    "The marginal arm delivered ahead of the fusion instant");
  assert(not late.deliveryAfterHorizon,
    "The refusing queue delivered a measurement ahead of the fusion instant");
  assert(not crowded.deliveryAfterHorizon,
    "The overflowing queue delivered ahead of the fusion instant");
  assert(not degenerate.deliveryAfterHorizon,
    "The degenerate case delivered ahead of its own fusion instant");

  // ---- 2. the residual is bounded by one release window -------------------
  // AT THE FLIGHT LATTICE AND THE FLIGHT LATENCIES. Measured: worst delivered
  // residual 0.008750 s against the derived bound of one fusionPeriod_s, on a
  // stream whose fixes ARRIVED 0.110 s old -- a factor of 12.6 on this
  // lattice, and the interval the transport error is cubic in. That ratio is the whole
  // quantitative claim: the interval the measurement Jacobian is transported
  // over falls from the sensor's transport latency to one release window, and
  // the transport error is cubic in it.
  assert(flight.worstDeliveredAge_s <= residualBound_s + 1.0e-9,
    "A measurement was fused with a residual larger than one release window,
     so the sub-window transport the design rests on is not bounded by the
     release lattice");
  assert(marginal.worstDeliveredAge_s <= residualBound_s + 1.0e-9,
    "The marginal arm delivered outside the residual bound");
  assert(crowded.worstDeliveredAge_s <= residualBound_s + 1.0e-9,
    "An overflowing queue delivered outside the residual bound");
  assert(degenerate.worstDeliveredAge_s <= residualBound_s + 1.0e-9,
    "The degenerate case delivered outside the residual bound, so the bound is
     not a property of the release lattice");

  // ---- 3. the improvement is measured, not asserted in prose --------------
  assert(time < settled_s or worstArrivalAge_s >= gpsLatency_s - 1.0e-9,
    "The simulated fixes did not actually arrive aged, so this model is not
     measuring the delayed-aiding case it claims to");
  assert(worstArrivalAge_s >= flight.worstDeliveredAge_s - 1.0e-9,
    "Fusing at the horizon transported the measurement Jacobian over a LONGER
     interval than fusing at the live edge would have, which inverts the
     entire argument for the horizon");

  // ---- 4. order -----------------------------------------------------------
  assert(not flight.deliveryOutOfOrder,
    "Measurements were delivered out of timestamp order");
  assert(not crowded.deliveryOutOfOrder,
    "An overflowing queue delivered out of timestamp order, so refusing an
     arrival disturbed the order of what was already queued");
  assert(not degenerate.deliveryOutOfOrder,
    "The degenerate case delivered out of timestamp order");

  // ---- 5. nothing is lost at the flight lattice ---------------------------
  // Every source samples no faster than it declares and every latency is
  // inside the horizon by the declared margin, so no refusal of any kind may
  // occur. This is the assertion that fails if a queue silently drops what it
  // cannot place, and it is the one that says the horizon is long enough for
  // the sensors the vehicle actually carries.
  assert(flight.refusedLateCount == 0,
    "A measurement inside the horizon was refused as later than it, so the
     horizon is not long enough for the declared sensor latencies");
  assert(flight.refusedOverflowCount == 0,
    "A queue sized for a whole horizon of its own source overflowed anyway");
  assert(flight.droppedStaleCount == 0,
    "A queued measurement went stale, which cannot happen while the source
     samples no faster than the release rate");
  assert(not flight.aidingRefused,
    "The flight arm reported a refusal");
  assert(time < settled_s or flight.beforeHorizonCount > 0,
    "No measurement was presented before the first release, so this run does
     not exercise the start-up window at all");
  // A queue that delivers NOTHING satisfies every assertion above. Say what
  // the run is required to have exercised.
  // Measured: 129 deliveries over 1.2 s across GPS, barometer and flow, and
  // 35 samples presented before the horizon existed.
  assert(time < stopTime_s - 1.0e-9 or flight.deliveredCount >= 100,
    "Too few measurements reached the filter for the delivery assertions above
     to mean anything");

  // ---- 5b. the degenerate case is a pass-through --------------------------
  // At the shortest horizon the block admits and with no sensor latency, the
  // queue must cost nothing: everything delivered, nothing refused, nothing
  // stale. This is the anchor the delayed case is measured against, and it is
  // what says the machinery has no fixed overhead that survives shrinking the
  // horizon.
  // Measured: 59 deliveries over 1.2 s, worst residual 0.008750 s, no
  // refusals -- the same residual the flight arm shows, which is the point:
  // the bound is a property of the release lattice and not of the horizon.
  assert(degenerate.refusedLateCount == 0,
    "A zero-latency measurement was refused as later than a horizon of one
     release window, so the queue is not a pass-through in the degenerate
     case");
  assert(degenerate.refusedOverflowCount == 0,
    "A zero-latency measurement overflowed a queue at the shortest horizon");
  assert(degenerate.droppedStaleCount == 0,
    "A zero-latency measurement went stale at the shortest horizon");
  assert(time < stopTime_s - 1.0e-9 or degenerate.deliveredCount >= 30,
    "The degenerate case delivered almost nothing, so the assertions above
     hold vacuously and the anchor is not anchoring anything");

  // ---- 6. the refusal boundary, from both sides --------------------------
  // The boundary in transport latency is fusionHorizon_s = 0.2 s, for the
  // reason recorded where the two latencies are declared. These arms sit on
  // otherwise identical configurations half a release window inside it and one
  // and a half windows outside it, which is what makes them a boundary test
  // rather than two unrelated cases.
  // Measured: marginal 9 delivered, 0 refused late, 0 dropped stale; late 0
  // delivered, 10 refused late.
  assert(marginal.refusedLateCount == 0,
    "A fix arriving half a release window INSIDE the horizon was refused as
     late, so the boundary is tighter than the horizon declares");
  assert(time < stopTime_s - 1.0e-9 or marginal.deliveredCount >= 5,
    "The marginal arm delivered almost nothing, so the inside half of the
     boundary test is not exercised");
  assert(late.deliveredCount == 0,
    "A measurement the fusion instant had already passed was delivered anyway,
     so it would be fused against a state that has moved past it, which is the
     case fusing at a horizon exists to eliminate");
  assert(time < settled_s or late.refusedLateCount > 0,
    "A fix arriving half a release window OUTSIDE the refusal boundary was
     neither delivered nor refused, so it vanished with no outcome, which is
     the predicate exhaustion this boundary refuses");

  // ---- 7. overflow refuses, counts, and does not blind the source ---------
  // THE ASSERTION THAT CAUGHT THE POLICY. An overflow that displaced the
  // OLDEST entry made each queue a sliding window of the newest arrivals; on
  // an oversampled source every one of them is still in the filter's future
  // when the next arrival displaces it, so NOTHING ever ripens and the source
  // is silent for the whole flight while every arrival is dutifully stored --
  // and every ordering and residual assertion above still passes, because a
  // queue that delivers nothing violates none of them. Refusing the ARRIVAL
  // keeps the entries the fusion instant is about to reach, and the queue
  // degrades to delivering at the release rate rather than to delivering
  // nothing.
  // Measured with the arrival refused: 114 refusals, 110 deliveries.
  // Measured with the oldest displaced: 0 deliveries.
  assert(time < settled_s or crowded.refusedOverflowCount > 0,
    "A source delivering forty times its declared rate did not overflow its
     queue, so the overflow path is not being exercised");
  assert(time < stopTime_s - 1.0e-9 or crowded.deliveredCount >= 30,
    "An overflowing queue stopped delivering, so one burst blinded the source
     for the rest of the flight. Refusing the ARRIVAL rather than displacing
     the oldest entry is what this assertion protects");

  annotation(experiment(StartTime=0.0, StopTime=1.2,
    Tolerance=1.0e-8, Interval=0.00125),
    Documentation(info="<html>
    <p>Simulated as a top-level model through
    <code>Tests/run-horizon.mos</code>. The epoch and the release pulse come
    from a real <code>Estimation.FusionHorizon.OutputPredictor</code> rather
    than a synthetic clock, because the property under test is that the
    queues and the delta ring agree about which instant the filter stands on,
    and a test that manufactured its own epoch would agree with itself.</p>
    <p><b>The flight arm runs the real lattice and the real latencies.</b> The
    horizon, the source rates and the source transport delays are the ones
    <code>Vehicles.Rdd2.WaypointVehicleSystem</code> carries, and those delays
    are PX4's shipped EKF2 defaults, so the residual measured here is the one
    the deployed configuration produces rather than one produced by a lattice
    chosen to make the run short. Two further arms bracket the refusal
    boundary at <code>fusionHorizon_s + maximumResidualAge_s</code> by half a
    release window on each side; the short-horizon arms remain for the
    mechanism properties, overflow and the degenerate anchor, where the rate is
    not what is under test.</p>
    <p>The filter itself is absent, and that is a tool limitation rather than
    a choice: OpenModelica cannot build a simulation containing a navigation
    estimator sub-component, which is recorded in
    <code>Tests.HorizonEstimatorWiring</code> and was re-measured for the
    composed horizon while this model was written -- a build of one
    <code>HorizonEstimator</code> was still running after ten minutes with no
    result. Every property here is a property of the aiding path up to the
    filter's connector, which is where the delayed-fusion contract lives.</p>
    <p><b>Why the ordering and epoch checks are published by the block rather
    than made here.</b> They were made here first, and they could not be
    trusted. Reading a sub-component's output-connector members from the parent
    does not report the written values under OpenModelica 1.27: a delivered
    GPS packet whose row inside the block held timestamp 0.03 and whose
    computed residual was correct read as <code>timestamp_s = 0</code> both in
    a parent when-clause and in the result file, while the Boolean
    <code>valid</code> on the same connector read correctly. The same model
    also failed to report a driven input connector's Boolean at all. Three
    distinct reporting failures, all silent, and each one makes an assertion
    pass rather than fail -- a queue whose delivered payload reads as zeros
    satisfies every check written about its contents.</p>
    <p>So the invariants are checked where the data is, inside
    <code>Estimation.FusionHorizon.AidingBuffer</code>, against the rows
    themselves, and published as <code>deliveryOutOfOrder</code> and
    <code>deliveryAfterHorizon</code>. That is weaker evidence than an external
    check and it is worth saying so: a block that computes its own invariant
    could be wrong in the way that hides it. It is also better supervision than
    the external form would have been, because these are properties of a
    SEQUENCE and a consumer that sees one packet at a time cannot reconstruct
    them. The production path is not affected by any of this --
    <code>Estimation.FusionHorizon.HorizonEstimator</code> consumes every
    aiding connector with a whole-record equation into the filter, which is the
    case that works.</p>
    </html>"));
end AidingHorizonTests;