CogniPilot

MODELICA LIBRARY · ENGINEERING REFERENCE

package

Vehicles.Cubs2.OuterLoopComponents

Reusable records and sampled controller blocks for the Sport Cub S2 outer loop

Members

NameKindDescription
horizontalPartfunction
horizontalDisplacementfunction
VehicleParametersrecord
FlightStaterecordEstimated aircraft state carried as one sampled value
FlightStateInputconnector
FlightStateOutputconnector
GuidanceSetpointsrecordCohesive route-guidance demand
GuidanceSetpointsInputconnector
GuidanceSetpointsOutputconnector
TecsCommandsrecordTECS pitch and thrust command value
TecsCommandsInputconnector
TecsCommandsOutputconnector
StabilizerCommandsrecordNormalized command sent to the proprietary onboard stabilizer
StabilizerCommandsOutputconnector
StabilizerCommandsInputconnector
RouteParametersrecord
TecsParametersrecord
AttitudeParametersrecord
StateEstimatorblock
RouteGuidanceInterfaceblock
CrossTrackGuidanceblockStraight-segment guidance with nonlinear cross-track correction
TecsStaterecordNumeric state of the sampled TECS transition
TecsDiagnosticsrecordProof and tuning outputs from one TECS transition
tecsTransitionfunctionPure fixed-dimensional total-energy controller transition
TECSControllerblock
AttitudeControllerblock

Modelica declarations and implementation

Show parameters, interfaces and source

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

package OuterLoopComponents
  "Reusable records and sampled controller blocks for the Sport Cub S2 outer loop"


function horizontalPart
  input Real v[3];
  output Real result[2];
algorithm
  result := {v[1], v[2]};
annotation(
  Inline=true);
end horizontalPart;

function horizontalDisplacement
  input Real position[3];
  input Real origin[3];
  output Real result[2];
algorithm
  result := {position[1] - origin[1], position[2] - origin[2]};
annotation(
  Inline=true);
end horizontalDisplacement;

record VehicleParameters
  Real g(unit="m/s2", min=1.0e-9) = 9.81 "standard gravity";
  Real mass(unit="kg", min=1.0e-9) = 0.063 "flight-tuned 2026-07-10; SportCubPlant sims at 0.065";
  Real thrustMax(unit="N", min=1.0e-9) = 0.30 "FixedWingPlant.thr_max";
  Real trimThrust(unit="N") = 0.12 "40% throttle cruise trim (2026-07-10)";
  Real envelopeDrag(unit="N") = 0.07 "cruise drag";
  Real weight(unit="N") = mass * g "aircraft weight";
  Real drag(unit="N") = envelopeDrag "drag estimate";
end VehicleParameters;

record FlightState
  "Estimated aircraft state carried as one sampled value"
  Real position_m[3];
  Real euler_rad[3];
  Real velocity_m_s[3];
  Real speed(unit="m/s");
  Real flightPathAngle(unit="rad");
  Real acceleration_m_s2(unit="m/s2");
  Real eulerRate_rad_s[3];
end FlightState;

connector FlightStateInput = input FlightState
  "Typed consumer port for one sampled flight-state value";
connector FlightStateOutput = output FlightState
  "Single-producer port for one sampled flight-state value";

record GuidanceSetpoints
  "Cohesive route-guidance demand"
  Real speed(unit="m/s");
  Real flightPathAngle(unit="rad");
  Real heading(unit="rad");
  Real acceleration(unit="m/s2");
end GuidanceSetpoints;

connector GuidanceSetpointsInput = input GuidanceSetpoints
  "Typed consumer port for one sampled guidance demand";
connector GuidanceSetpointsOutput = output GuidanceSetpoints
  "Single-producer port for one sampled guidance demand";

record TecsCommands
  "TECS pitch and thrust command value"
  Real pitch(unit="rad");
  Real thrust(unit="N");
  Real pitchPreview(unit="rad");
  Real thrustPreview(unit="N");
end TecsCommands;

connector TecsCommandsInput = input TecsCommands
  "Typed consumer port for one sampled TECS command";
connector TecsCommandsOutput = output TecsCommands
  "Single-producer port for one sampled TECS command";

record StabilizerCommands
  "Normalized command sent to the proprietary onboard stabilizer"
  Real normalized[4]
    "{roll, pitch, yaw, throttle}";
end StabilizerCommands;

connector StabilizerCommandsOutput = output StabilizerCommands
  "Single-producer port for one sampled onboard-stabilizer command";
connector StabilizerCommandsInput = input StabilizerCommands
  "Typed consumer port for one sampled onboard-stabilizer command";

record RouteParameters
  Integer nSegments = 6 "flyable segments between route points";
  Real waypoints[7, 3] = [
    0.0, 0.0, 0.0;
    -8.0, -8.0, 3.0;
    -8.0, 2.0, 3.0;
    18.0, 2.0, 3.0;
    18.0, -8.0, 3.0;
    5.0, -8.0, 3.0;
    -8.0, -8.0, 3.0] "route point rows are [x, y, z] [m]";
  Real cruiseSpeed(unit="m/s") = 4.5;
  Real altitudeToFlightPathGain = 2.0;
  Real altitudeLookaheadDistance(unit="m") = 8.0;
  Real flightPathAngleLimit(unit="rad") = 0.12;
  Real speedToAccelerationGain = 1.0;
  Real crossTrackSteeringDistance(unit="m") = 4.25
    "atan steering distance; 45 deg correction occurs when |cross-track| = d";
  Real waypointSwitchingDistance(unit="m") = 4.0
    "advance when remaining along-track distance enters the endpoint guard";
end RouteParameters;

record TecsParameters
  Real thrustKp = 0.05 "P on energy-rate error";
  Real thrustKi = 0.004854133294500267
    "10 s unit-error ramp to 10% throttle integral authority";
  Real thrustIntegralLimit(unit="N") = 0.03
    "maximum thrust contribution from integral action";
  Real pitchKp = 0.075;
  Real pitchKi = 0.0;
  Real pitchIntegralLimit(unit="rad") = 0.0;
  Real trackingTime(unit="s", min=1.0e-9) = 0.2
    "anti-windup tracking time constant";
  Real pitchCommandLimit(unit="rad") = 0.20943951023931953;
  Real turnThrustGain = 0.5 "thrust FF per unit load-factor excess";
  Real turnPitchGain = 0.0 "pitch FF [rad] per unit load-factor excess";
end TecsParameters;

record AttitudeParameters
  Real trimElevator = 0.0
    "manual cruise trim from 2026-07-09/10 level-ish flight segments";
  Real stabilizerCommand(unit="us") = 2000.0;
  Real pitchCommandToElevatorGain = 1.0 / 0.5235987755982988
    "S2 pitch stick gain: full elevator stick maps to 30 deg (bench-measured 2026-07-10)";
  // Full aileron stick is 45 deg of bank on the real S2.
  Real rollCommandToAileronGain = 1.0 / 0.7853981633974483
    "S2 bank stick gain: full aileron stick maps to 45 deg";
  Real rollLimit(unit="rad") = 0.5235987755982988
    "30 deg bank command saturation";
  Real rollRateLimit(unit="rad/s") = 2.0943951023931953;
end AttitudeParameters;

block StateEstimator
  parameter Real dt(unit="s", min=1.0e-9) = 0.02;
  parameter Real filterCutoffHz(unit="Hz") = 10.0;
  parameter Real velocityFilterCutoffHz(unit="Hz") = 1.5;
  parameter Real accelFilterCutoffHz(unit="Hz") = 0.4;
  parameter Real freshnessDistance(unit="m", min=1.0e-12) = 1.0e-5
    "Pose-equivalent motion that gives a packet full correction weight";
  parameter Real attitudeFreshnessLength(unit="m") = 0.1
    "Length scale converting attitude motion into pose-equivalent motion";
  parameter Real positionCorrectionSpeedLimit(unit="m/s") = 10.0;
  parameter Real attitudeCorrectionRateLimit(unit="rad/s") = 4.0;
  constant Real pi = 3.141592653589793;

  input Real position_m[3];
  input Real euler_rad[3];
  input Real velocity_m_s[3];
  input Real eulerRate_rad_s[3];
  FlightStateOutput estimate(
    position_m(each start=0.0),
    euler_rad(each start=0.0),
    velocity_m_s(each start=0.0),
    speed(start=0.0),
    flightPathAngle(start=0.0),
    acceleration_m_s2(start=0.0),
    eulerRate_rad_s(each start=0.0));
  discrete output Real measurementWeight(start=0.0)
    "Numeric correction weight; zero for a repeated mocap packet";

protected
  discrete Real previousMeasurementPosition_m[3](each start=0.0);
  discrete Real previousMeasurementEuler_rad[3](each start=0.0);
  discrete Real previousMeasurementVelocity_m_s[3](each start=0.0);
  discrete Real previousMeasurementEulerRate_rad_s[3](each start=0.0);
  discrete Real measurementPositionDelta_m[3];
  discrete Real measurementEulerDelta_rad[3];
  discrete Real measurementVelocityDelta_m_s[3];
  discrete Real measurementEulerRateDelta_rad_s[3];
  discrete Real predictedPosition_m[3];
  discrete Real predictedEuler_rad[3];
  discrete Real positionInnovation_m[3];
  discrete Real attitudeInnovation_rad[3];
  discrete Real sampleMotion_m;
  discrete Real positionInnovationWeight;
  discrete Real attitudeInnovationWeight;
  discrete Real measuredSpeed;
  discrete Real measuredFlightPathAngle;
  discrete Real filterSampleWeight;
  discrete Real velocitySampleWeight;
  discrete Real accelSampleWeight;
  discrete Real rawAcceleration_m_s2;

algorithm
  when sample(0.0, dt) then
    filterSampleWeight := 1.0 - exp(-2.0 * pi * filterCutoffHz * dt);
    velocitySampleWeight := 1.0 - exp(-2.0 * pi * velocityFilterCutoffHz * dt);
    accelSampleWeight := 1.0 - exp(-2.0 * pi * accelFilterCutoffHz * dt);

    measurementPositionDelta_m :=
      position_m - pre(previousMeasurementPosition_m);
    measurementVelocityDelta_m_s :=
      velocity_m_s - pre(previousMeasurementVelocity_m_s);
    for axis in 1:3 loop
      measurementEulerDelta_rad[axis] := MathUtilities.wrapAngle(
        euler_rad[axis] - pre(previousMeasurementEuler_rad[axis]));
      measurementEulerRateDelta_rad_s[axis] :=
        eulerRate_rad_s[axis] - pre(previousMeasurementEulerRate_rad_s[axis]);
    end for;
    sampleMotion_m :=
      MathUtilities.norm3(measurementPositionDelta_m)
      + attitudeFreshnessLength * MathUtilities.norm3(measurementEulerDelta_rad)
      + dt * MathUtilities.norm3(measurementVelocityDelta_m_s)
      + dt * attitudeFreshnessLength
        * MathUtilities.norm3(measurementEulerRateDelta_rad_s);
    measurementWeight :=
      MathUtilities.clip(sampleMotion_m / freshnessDistance, 0.0, 1.0);

    predictedPosition_m :=
      pre(estimate.position_m) + dt * pre(estimate.velocity_m_s);
    for axis in 1:3 loop
      predictedEuler_rad[axis] := MathUtilities.wrapAngle(
        pre(estimate.euler_rad[axis])
          + dt * pre(estimate.eulerRate_rad_s[axis]));
      attitudeInnovation_rad[axis] := MathUtilities.wrapAngle(
        euler_rad[axis] - predictedEuler_rad[axis]);
    end for;
    positionInnovation_m := position_m - predictedPosition_m;
    positionInnovationWeight := MathUtilities.clip(
      positionCorrectionSpeedLimit * dt
        / max(MathUtilities.norm3(positionInnovation_m), 1.0e-12),
      0.0,
      1.0);
    attitudeInnovationWeight := MathUtilities.clip(
      attitudeCorrectionRateLimit * dt
        / max(MathUtilities.norm3(attitudeInnovation_rad), 1.0e-12),
      0.0,
      1.0);

    estimate.position_m := predictedPosition_m
      + filterSampleWeight * measurementWeight
        * positionInnovationWeight * positionInnovation_m;
    for axis in 1:3 loop
      estimate.euler_rad[axis] := MathUtilities.wrapAngle(
        predictedEuler_rad[axis]
          + filterSampleWeight * measurementWeight
            * attitudeInnovationWeight * attitudeInnovation_rad[axis]);
    end for;
    estimate.velocity_m_s := MathUtilities.lowPass3(
      velocity_m_s,
      pre(estimate.velocity_m_s),
      velocitySampleWeight * measurementWeight);
    estimate.eulerRate_rad_s := MathUtilities.lowPass3(
      eulerRate_rad_s,
      pre(estimate.eulerRate_rad_s),
      filterSampleWeight * measurementWeight);

    measuredSpeed := MathUtilities.norm3(estimate.velocity_m_s);
    measuredFlightPathAngle := asin(MathUtilities.clip(
      estimate.velocity_m_s[3] / max(measuredSpeed, 1.0e-5), -1.0, 1.0));
    estimate.speed := measuredSpeed;
    estimate.flightPathAngle := measuredFlightPathAngle;
    rawAcceleration_m_s2 := (estimate.speed - pre(estimate.speed)) / dt;
    estimate.acceleration_m_s2 := MathUtilities.lowPass(
      rawAcceleration_m_s2,
      pre(estimate.acceleration_m_s2),
      accelSampleWeight);

    previousMeasurementPosition_m := position_m;
    previousMeasurementEuler_rad := euler_rad;
    previousMeasurementVelocity_m_s := velocity_m_s;
    previousMeasurementEulerRate_rad_s := eulerRate_rad_s;
  end when;
end StateEstimator;

partial block RouteGuidanceInterface
  parameter Real dt(unit="s", min=1.0e-9) = 0.02;
  parameter RouteParameters route = RouteParameters();

  FlightStateInput estimate;

  discrete output Integer currentWaypoint(min=1, max=6, start=1);
  GuidanceSetpointsOutput setpoints;
end RouteGuidanceInterface;

block CrossTrackGuidance
  "Straight-segment guidance with nonlinear cross-track correction"
  extends RouteGuidanceInterface;

protected
  discrete Integer activeWaypoint(min=1, max=6, start=1);
  discrete Integer segmentEndIndex(min=2, max=7, start=2);
  discrete Real segmentStart[3];
  discrete Real segmentEnd[3];
  discrete Real segmentVector[3];
  discrete Real horizontalSegmentVector[2];
  discrete Real horizontalSegmentLength;
  discrete Real segmentHeading;
  discrete Real segmentUnit[2];
  discrete Real crossTrackUnit[2];
  discrete Real positionFromSegmentStart[2];
  discrete Real alongTrackDistance;
  discrete Real remainingAlongTrackDistance;
  discrete Real pathProgress;
  discrete Real pathAltitude;
  discrete Real altitudeError;
  discrete Real crossTrackError;
  discrete Real steeringCorrection;

algorithm
  when sample(0.0, dt) then
    activeWaypoint := pre(currentWaypoint);
    segmentEndIndex := pre(currentWaypoint) + 1;
    if pre(currentWaypoint) == 1 then
      segmentStart := route.waypoints[1, :];
      segmentEnd := route.waypoints[2, :];
    elseif pre(currentWaypoint) == 2 then
      segmentStart := route.waypoints[2, :];
      segmentEnd := route.waypoints[3, :];
    elseif pre(currentWaypoint) == 3 then
      segmentStart := route.waypoints[3, :];
      segmentEnd := route.waypoints[4, :];
    elseif pre(currentWaypoint) == 4 then
      segmentStart := route.waypoints[4, :];
      segmentEnd := route.waypoints[5, :];
    elseif pre(currentWaypoint) == 5 then
      segmentStart := route.waypoints[5, :];
      segmentEnd := route.waypoints[6, :];
    else
      segmentStart := route.waypoints[6, :];
      segmentEnd := route.waypoints[7, :];
    end if;

    segmentVector := segmentEnd - segmentStart;
    horizontalSegmentVector := horizontalPart(segmentVector);
    horizontalSegmentLength := max(MathUtilities.norm2(horizontalSegmentVector), 1e-6);
    segmentHeading := atan2(horizontalSegmentVector[2], horizontalSegmentVector[1]);
    segmentUnit := horizontalSegmentVector / horizontalSegmentLength;
    crossTrackUnit := {-segmentUnit[2], segmentUnit[1]};
    positionFromSegmentStart :=
      horizontalDisplacement(estimate.position_m, segmentStart);
    alongTrackDistance := positionFromSegmentStart * segmentUnit;
    remainingAlongTrackDistance :=
      max(0.0,
          horizontalSegmentLength
          - MathUtilities.clip(alongTrackDistance, 0.0, horizontalSegmentLength));
    pathProgress :=
      MathUtilities.clip(alongTrackDistance / horizontalSegmentLength, 0.0, 1.0);
    pathAltitude :=
      segmentStart[3] + pathProgress * (segmentEnd[3] - segmentStart[3]);
    altitudeError := pathAltitude - estimate.position_m[3];
    crossTrackError := positionFromSegmentStart * crossTrackUnit;
    steeringCorrection :=
      atan2(-crossTrackError, max(route.crossTrackSteeringDistance, 1e-6));

    setpoints.speed := route.cruiseSpeed;
    setpoints.flightPathAngle :=
      MathUtilities.clip(atan2(route.altitudeToFlightPathGain * altitudeError,
                 max(route.altitudeLookaheadDistance, 1e-6)),
           -route.flightPathAngleLimit,
           route.flightPathAngleLimit);
    setpoints.heading := MathUtilities.wrapAngle(segmentHeading + steeringCorrection);
    setpoints.acceleration :=
      route.speedToAccelerationGain * (setpoints.speed - estimate.speed);

    if remainingAlongTrackDistance < route.waypointSwitchingDistance then
      if pre(currentWaypoint) >= route.nSegments then
        currentWaypoint := 1;
      else
        currentWaypoint := pre(currentWaypoint) + 1;
      end if;
    else
      currentWaypoint := pre(currentWaypoint);
    end if;
  end when;
end CrossTrackGuidance;

record TecsState "Numeric state of the sampled TECS transition"
  Real thrustIntegralContribution(unit="N");
  Real thrustError;
  Real thrustDerivative;
  Real pitchIntegralContribution(unit="rad");
  Real pitchError;
  Real pitchDerivative;
end TecsState;

record TecsDiagnostics "Proof and tuning outputs from one TECS transition"
  Real boundedAcceleration(unit="m/s2");
  Real energyRateError;
  Real energyDistributionError;
  Real unconstrainedThrust(unit="N");
  Real unconstrainedPitch(unit="rad");
end TecsDiagnostics;

function tecsTransition
  "Pure fixed-dimensional total-energy controller transition"
  input Real dt(unit="s");
  input VehicleParameters vehicle;
  input TecsParameters tecs;
  input Control.PidParameters thrustPid;
  input Control.PidParameters pitchPid;
  input Control.PidCoefficients pidCoefficients;
  input GuidanceSetpoints setpoints;
  input FlightState estimate;
  input TecsState previous;
  input Real previousAppliedThrust(unit="N");
  input Real previousAppliedPitch(unit="rad");
  output TecsState next;
  output TecsCommands commands;
  output TecsDiagnostics diagnostics;

protected
  Real accelerationMin(unit="m/s2");
  Real accelerationMax(unit="m/s2");
  Real accelerationCommandOverG;
  Real accelerationEstimateOverG;
  Real totalEnergyRateCommand;
  Real totalEnergyRateEstimate;
  Real energyDistributionCommand;
  Real energyDistributionEstimate;
  Real loadFactorExcess;
  Real feedforwardThrust(unit="N");
  Real feedforwardPitch(unit="rad");
  Control.PidState nextThrustState;
  Control.PidState nextPitchState;
  Control.PidResult thrustFeedback;
  Control.PidResult pitchFeedback;

algorithm
  accelerationMin := -vehicle.drag / vehicle.mass;
  accelerationMax := (vehicle.thrustMax - vehicle.drag) / vehicle.mass;
  diagnostics.boundedAcceleration := MathUtilities.clip(
    setpoints.acceleration, accelerationMin, accelerationMax);
  accelerationCommandOverG := diagnostics.boundedAcceleration / vehicle.g;
  accelerationEstimateOverG := estimate.acceleration_m_s2 / vehicle.g;
  // NASA CR-178285, Sec. 2.1, Eq. (3): E_dot_s / V = gamma + V_dot / g.
  totalEnergyRateCommand := setpoints.flightPathAngle + accelerationCommandOverG;
  totalEnergyRateEstimate := estimate.flightPathAngle + accelerationEstimateOverG;
  // The distribution channel subtracts the normalized acceleration term so
  // pitch transfers energy between flight path and speed without changing
  // the total-energy channel.
  energyDistributionCommand := setpoints.flightPathAngle - accelerationCommandOverG;
  energyDistributionEstimate := estimate.flightPathAngle - accelerationEstimateOverG;

  loadFactorExcess := 1.0 / max(cos(estimate.euler_rad[1]), 0.5) - 1.0;
  feedforwardThrust := vehicle.trimThrust
    + vehicle.weight
      * (totalEnergyRateCommand + tecs.turnThrustGain * loadFactorExcess);
  feedforwardPitch := tecs.turnPitchGain * loadFactorExcess;
  diagnostics.energyRateError :=
    totalEnergyRateCommand - totalEnergyRateEstimate;
  diagnostics.energyDistributionError :=
    energyDistributionCommand - energyDistributionEstimate;

  (nextThrustState, thrustFeedback) := Control.pidTransition(
    thrustPid,
    pidCoefficients,
    Control.PidState(
      previous.thrustIntegralContribution,
      previous.thrustError,
      previous.thrustDerivative),
    totalEnergyRateCommand,
    totalEnergyRateEstimate,
    previousAppliedThrust - feedforwardThrust);
  (nextPitchState, pitchFeedback) := Control.pidTransition(
    pitchPid,
    pidCoefficients,
    Control.PidState(
      previous.pitchIntegralContribution,
      previous.pitchError,
      previous.pitchDerivative),
    energyDistributionCommand,
    energyDistributionEstimate,
    previousAppliedPitch - feedforwardPitch);

  next.thrustIntegralContribution := nextThrustState.integralContribution;
  next.thrustError := nextThrustState.error;
  next.thrustDerivative := nextThrustState.derivative;
  next.pitchIntegralContribution := nextPitchState.integralContribution;
  next.pitchError := nextPitchState.error;
  next.pitchDerivative := nextPitchState.derivative;

  commands.thrustPreview := MathUtilities.clip(
    feedforwardThrust + thrustFeedback.preview,
    0.0,
    vehicle.thrustMax);
  commands.pitchPreview := MathUtilities.clip(
    feedforwardPitch + pitchFeedback.preview,
    -tecs.pitchCommandLimit,
    tecs.pitchCommandLimit);

  diagnostics.unconstrainedThrust :=
    feedforwardThrust + thrustFeedback.unconstrainedCommand;
  commands.thrust := MathUtilities.clip(
    diagnostics.unconstrainedThrust, 0.0, vehicle.thrustMax);
  diagnostics.unconstrainedPitch :=
    feedforwardPitch + pitchFeedback.unconstrainedCommand;
  commands.pitch := MathUtilities.clip(
    diagnostics.unconstrainedPitch,
    -tecs.pitchCommandLimit,
    tecs.pitchCommandLimit);
  annotation(Documentation(info="<html>
    <p>The energy coordinates follow K. R. Bruce,
    <a href=\"https://ntrs.nasa.gov/api/citations/19870017485/downloads/19870017485.pdf\">
    NASA B737 Flight Test Results of the Total Energy Control System,
    NASA CR-178285</a>, Section 2.1, report page 6. Equation (3) gives
    <code>E_dot_s/V = gamma + V_dot/g</code>; Equation (4) gives
    <code>T_req = W (gamma + V_dot/g) + D</code>. The total-energy-rate
    and energy-distribution PI laws immediately following Equation (4) are
    represented by the two calls to <code>Control.pidTransition</code>.</p>
    <p>The trim/turn terms are explicit feedforward outside those PID maps.
    Command limits and applied-command tracking are CUBS2 adaptations and are
    not presented as equations from the NASA report.</p>
  </html>"));
end tecsTransition;

// TECS follows NASA CR-178285: thrust controls total energy rate while pitch
// redistributes energy between flight-path and speed.
block TECSController
  parameter Real dt(unit="s", min=1.0e-9) = 0.02;
  parameter VehicleParameters vehicle = VehicleParameters();
  parameter TecsParameters tecs = TecsParameters();
  final parameter Control.PidParameters thrustPid = Control.PidParameters(
    samplePeriod=dt,
    kp=vehicle.weight * tecs.thrustKp,
    ki=vehicle.weight * tecs.thrustKi,
    kd=0.0,
    integralLimit=tecs.thrustIntegralLimit,
    commandMin=-vehicle.thrustMax,
    commandMax=vehicle.thrustMax,
    trackingTime=tecs.trackingTime);
  final parameter Control.PidParameters pitchPid = Control.PidParameters(
    samplePeriod=dt,
    kp=tecs.pitchKp,
    ki=tecs.pitchKi,
    kd=0.0,
    integralLimit=tecs.pitchIntegralLimit,
    commandMin=-tecs.pitchCommandLimit,
    commandMax=tecs.pitchCommandLimit,
    trackingTime=tecs.trackingTime);
  final parameter Control.PidCoefficients pidCoefficients =
    Control.PidCoefficients(
      derivativeWeight=1.0,
      trackingCoefficient=dt / max(tecs.trackingTime, 1.0e-9));

  GuidanceSetpointsInput setpoints;
  FlightStateInput estimate;

  discrete output Real boundedAcceleration(unit="m/s2", start=0.0);
  discrete output Real energyRateError(start=0.0);
  discrete output Real thrustIntegralContribution(unit="N", start=0.0);
  discrete output Real unconstrainedThrustCommand(unit="N", start=0.0);
  TecsCommandsOutput commands;
  discrete output Real energyDistributionError(start=0.0);
  discrete output Real pitchIntegralContribution(unit="rad", start=0.0);
  discrete output Real unconstrainedPitchCommand(unit="rad", start=0.0);

protected
  TecsState nextState;
  TecsCommands nextCommands;
  TecsDiagnostics diagnostics;
  discrete Real thrustErrorDerivative(start=0.0);
  discrete Real pitchErrorDerivative(start=0.0);

algorithm
  when sample(0.0, dt) then
    (nextState, nextCommands, diagnostics) := tecsTransition(
      dt,
      vehicle,
      tecs,
      thrustPid,
      pitchPid,
      pidCoefficients,
      setpoints,
      estimate,
      TecsState(
        pre(thrustIntegralContribution),
        pre(energyRateError),
        pre(thrustErrorDerivative),
        pre(pitchIntegralContribution),
        pre(energyDistributionError),
        pre(pitchErrorDerivative)),
      pre(commands.thrust),
      pre(commands.pitch));
    thrustIntegralContribution := nextState.thrustIntegralContribution;
    pitchIntegralContribution := nextState.pitchIntegralContribution;
    thrustErrorDerivative := nextState.thrustDerivative;
    pitchErrorDerivative := nextState.pitchDerivative;
    commands.thrust := nextCommands.thrust;
    commands.pitch := nextCommands.pitch;
    commands.thrustPreview := nextCommands.thrustPreview;
    commands.pitchPreview := nextCommands.pitchPreview;
    boundedAcceleration := diagnostics.boundedAcceleration;
    energyRateError := diagnostics.energyRateError;
    energyDistributionError := diagnostics.energyDistributionError;
    unconstrainedThrustCommand := diagnostics.unconstrainedThrust;
    unconstrainedPitchCommand := diagnostics.unconstrainedPitch;
  end when;
end TECSController;

block AttitudeController
  parameter Real dt(unit="s", min=1.0e-9) = 0.02;
  parameter VehicleParameters vehicle = VehicleParameters();
  parameter AttitudeParameters params = AttitudeParameters();
  parameter Control.PidParameters headingPid =
    Control.PidParameters(samplePeriod=dt, kp=0.5, ki=0.0,
                          kd=0.5, integralLimit=0.0,
                          commandMin=-params.rollLimit,
                          commandMax=params.rollLimit);
  // Closed pitch loop: the plant's stick-to-pitch response is not trusted,
  // so the open-loop gain rides along as feedforward and the PID trims it.
  parameter Control.PidParameters pitchPid =
    Control.PidParameters(samplePeriod=dt, kp=2.3, ki=0.0, kd=0.0,
                          integralLimit=0.0,
                          commandMin=-1.0, commandMax=1.0);

  final parameter Control.PidCoefficients headingCoefficients =
    Control.PidCoefficients(
      derivativeWeight=if headingPid.derivativeCutoffHz > 0.0 then
        1.0 - exp(-2.0 * 3.141592653589793
          * headingPid.derivativeCutoffHz * dt) else 1.0,
      trackingCoefficient=dt / max(headingPid.trackingTime, 1.0e-9));
  final parameter Control.PidCoefficients pitchCoefficients =
    Control.PidCoefficients(
      derivativeWeight=if pitchPid.derivativeCutoffHz > 0.0 then
        1.0 - exp(-2.0 * 3.141592653589793
          * pitchPid.derivativeCutoffHz * dt) else 1.0,
      trackingCoefficient=dt / max(pitchPid.trackingTime, 1.0e-9));

  GuidanceSetpointsInput setpoints;
  FlightStateInput estimate;
  TecsCommandsInput tecsCommands;

  StabilizerCommandsOutput commands;
  discrete output Real rollCommand(start=0.0);
  discrete output Real rollCommandPreview(start=0.0)
    "integral-free bank; what guidance would fly while disengaged";
  output Real courseError;

protected
  discrete Real rollCommandState(start=0.0);
  discrete Real unlimitedRollCommand;
  discrete Real headingIntegral(start=0.0);
  discrete Real headingError(start=0.0);
  discrete Real headingDerivative(start=0.0);
  discrete Real headingUnconstrained(start=0.0);
  discrete Real headingCommand(start=0.0);
  discrete Real headingPreview(start=0.0);
  discrete Real pitchIntegral(start=0.0);
  discrete Real pitchError(start=0.0);
  discrete Real pitchDerivative(start=0.0);
  discrete Real pitchUnconstrained(start=0.0);
  discrete Real pitchCommand(start=0.0);

equation
  courseError = -MathUtilities.wrapAngle(
    setpoints.heading
    - atan2(estimate.velocity_m_s[2], estimate.velocity_m_s[1]));

algorithm
  when sample(0.0, dt) then
    // Keep both PID transitions inside this single guarded producer. Scalar
    // updates avoid a nested multi-output function transaction, which is not
    // representable by the checked eFMI/Solve-IR event contract.
    headingError := courseError;
    headingDerivative := pre(headingDerivative)
      + headingCoefficients.derivativeWeight
        * ((headingError - pre(headingError)) / dt
          - pre(headingDerivative));
    headingUnconstrained := headingPid.kp * headingError
      + pre(headingIntegral) + headingPid.kd * headingDerivative;
    headingCommand := MathUtilities.clip(
      headingUnconstrained, headingPid.commandMin, headingPid.commandMax);
    headingPreview := MathUtilities.clip(
      headingPid.kp * headingError + headingPid.kd * headingDerivative,
      headingPid.commandMin, headingPid.commandMax);
    headingIntegral := MathUtilities.clip(
      pre(headingIntegral) + headingPid.ki * headingError * dt
        + headingCoefficients.trackingCoefficient
          * (pre(headingCommand) - headingUnconstrained),
      -headingPid.integralLimit, headingPid.integralLimit);

    pitchError := tecsCommands.pitch - estimate.euler_rad[2];
    pitchDerivative := pre(pitchDerivative)
      + pitchCoefficients.derivativeWeight
        * ((pitchError - pre(pitchError)) / dt - pre(pitchDerivative));
    pitchUnconstrained := pitchPid.kp * pitchError
      + pre(pitchIntegral) + pitchPid.kd * pitchDerivative;
    pitchCommand := MathUtilities.clip(
      pitchUnconstrained, pitchPid.commandMin, pitchPid.commandMax);
    pitchIntegral := MathUtilities.clip(
      pre(pitchIntegral) + pitchPid.ki * pitchError * dt
        + pitchCoefficients.trackingCoefficient
          * (pre(pitchCommand) - pitchUnconstrained),
      -pitchPid.integralLimit, pitchPid.integralLimit);

    unlimitedRollCommand := headingCommand;
    rollCommandState :=
      MathUtilities.clip(
        MathUtilities.rateLimit(unlimitedRollCommand,
          pre(rollCommandState), params.rollRateLimit * dt),
        -params.rollLimit, params.rollLimit);
    rollCommand := rollCommandState;
    rollCommandPreview :=
      MathUtilities.clip(headingPreview, -params.rollLimit, params.rollLimit);
    // Assign the record array as one guarded producer. Besides expressing the
    // command atomically, this gives event schedulers a complete transaction
    // instead of four element writes to one array-valued variable.
    commands.normalized := {
      MathUtilities.clip(
        params.rollCommandToAileronGain * rollCommand, -1.0, 1.0),
      MathUtilities.clip(
        params.trimElevator
          + params.pitchCommandToElevatorGain * tecsCommands.pitch
          + pitchCommand,
        -1.0, 1.0),
      0.0,
      MathUtilities.clip(
        tecsCommands.thrust / vehicle.thrustMax, 0.0, 1.0)};
  end when;
end AttitudeController;

end OuterLoopComponents;