ReviewOS

also looking at this

stacks/drone

fix: stabilize VTOL lateral flow and hover control

#91
Merged chris wants to merge codex/vtol-spanwise-continuity into main
17 files +556 -39

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -172,6 +172,10 @@ jobs:
172172 working-directory: ${{ runner.temp }}
173173 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical
174174 working-directory: ${{ runner.temp }}
175 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical
176 working-directory: ${{ runner.temp }}
177 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical
178 working-directory: ${{ runner.temp }}
175179 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
176180 working-directory: ${{ runner.temp }}
177181 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+6-4
Changes to README.md
@@ -84,11 +84,13 @@ mode observations. Named scenarios expose phase-bound sensor/manual/host loss, c
8484declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry.
8585Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements.
8686Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 18 named
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 20 named
8888cases, fault phases and optional electrical model, with captured mode and battery playback.
89The 2 m/s headwind and tailwind cases complete their routes. East/west crosswind limit cases retain
90their blocked envelope outcome, without confirmed hover or landing. Reports and playback distinguish
91declared world wind from observed exposure checks; controller inputs contain no wind oracle.
89The 2 m/s headwind and tailwind cases complete their routes. Separate east/west crosswind plans
90complete with explicitly wind-facing back-hover headings. Their original fixed north-facing limit
91cases retain blocked envelope outcomes, without confirmed hover or landing. Reports and playback
92show the requested heading and distinguish declared world wind from observed exposure checks;
93controller inputs contain no wind oracle. These are bounded fictional plans, not arbitrary-wind support.
9294
9395## Simulated carried load
9496
docs/vehicles-vtol.mdmodified+40-17
Changes to docs/vehicles-vtol.md
@@ -55,19 +55,22 @@ restart forward-flight braking during a reverse position return.
5555
5656The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from
57571000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their
58original budgets using the raw sensor/estimator/controller runtime. Four named cases are shared by
58original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by
5959Buddy, the copied standalone CLI and the mission workspace:
6060
61| Scenario | World wind N/E/D, m/s | Required recorded outcome |
62| --- | --- | --- |
63| `headwind-2mps` | -2, 0, 0 | Completed route and contact |
64| `tailwind-2mps` | 2, 0, 0 | Completed route and contact |
65| `east-crosswind-limit-2mps` | 0, 2, 0 | Blocked back-transition envelope, no hover/landing claim |
66| `west-crosswind-limit-2mps` | 0, -2, 0 | Blocked back-transition envelope, no hover/landing claim |
61| Scenario | World wind N/E/D, m/s | Back-hover heading from north | Required recorded outcome |
62| --- | --- | --- | --- |
63| `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact |
64| `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact |
65| `east-crosswind-limit-2mps` | 0, 2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim |
66| `west-crosswind-limit-2mps` | 0, -2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim |
67| `east-crosswind-2mps` | 0, 2, 0 | -pi/2 rad, west | Completed route and contact |
68| `west-crosswind-2mps` | 0, -2, 0 | +pi/2 rad, east | Completed route and contact |
6769
6870```sh
6971./buddy vtol:simulate --scenario tailwind-2mps --electrical
7072./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical
73./buddy vtol:simulate --scenario east-crosswind-2mps --electrical
7174./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
7275```
7376
@@ -89,12 +92,30 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0,
8992const result = runVehicleSimulation(profile, { seconds: 160 })
9093```
9194
92The east/west crosswind experiments still reach the back-transition envelope guard
93without confirmed hover or landing. Their yaw excursions and eventual pitch limits remain unsupported
94outcomes. Neutral outputs at those airborne failures do not establish a safe landing. Wider wind directions,
95onset phases and planning/heading feasibility remain in
96[#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical
97flight acceptance is claimed.
95The original fixed north-facing east/west crosswind plans still reach the back-transition
96envelope guard without confirmed hover or landing. During the east case's reverse return at 38,000 ms,
97the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing
98rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture
99is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an
100observed authority shortage, not evidence of a mismatched capture frame.
101
102A separate level-hover calculation with the same 2 m/s crosswind and full opposing rudder leaves
103-0.736 Nm of aerodynamic yaw moment. Lift rotors preserving the fictional 24.517 N weight support
104can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility
105envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs
106at the original airborne failure do not establish a safe landing.
107
108The two wind-facing plans change only the declared back-hover heading and plan identity. They use
109the existing `vtol-mode.headingRad` mission contract and unchanged controller, model, reserve and
110finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover
111confirmation and contact route in the declared wind. Their extra `backHeadingRad` declaration and
112`backHeadingMatched` check bind qualification to the actual recorded plan; a completed route under
113a different heading remains unmatched. No automatic heading substitution or retry is performed.
114The original `*-limit-2mps` cases keep their prior plans and blocked outcomes.
115
116Wider wind directions, onset phases, a general feasibility planner and full-angle model qualification
117remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or
118physical flight acceptance is claimed.
98119
99120The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw
100121IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified
@@ -400,7 +421,7 @@ it does not model a seized motor's electrical current.
400421
401422## Mission workspace
402423
403Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`.
424Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`.
404425The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
405426phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
406427scenario, fault phase or electrical selection clears the previous recording.
@@ -411,9 +432,11 @@ position, operator actions, request-bound controller observations and their capt
411432table retains changes and confirmations. The last mode observation includes its age at the cursor;
412433it does not assert a continuously measured current phase.
413434
414Wind playback shows the declared vector/onset, the actual world exposure check and the expected
415completed-route or envelope-stop outcome. The wind is a fixture input, not a measured wind estimate.
416Crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
435Task playback shows each requested VTOL heading in radians from north. Wind playback shows
436the declared vector/onset, the actual world exposure check and the expected completed-route or
437envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan
438binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading
439crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
417440
418441With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
419442Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
resources/components/MissionWorkspace.stxmodified+3-2
Changes to resources/components/MissionWorkspace.stx
@@ -88,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
8888 <div><dt class="text-neutral-400">Energy reserve checked</dt><dd class="mt-1">{{ definition().budget.requiredWh.toFixed(2) }} / {{ definition().budget.usableWh.toFixed(2) }} Wh usable</dd></div>
8989 </dl>
9090 <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3>
91 <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div>
91 <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.headingRad !== undefined && task.headingRad !== null" class="block font-mono text-neutral-400">Requested heading: {{ task.headingRad?.toFixed(3) }} rad from north</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div>
9292 <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p>
9393 </aside>
9494 <div class="min-w-0">
@@ -126,7 +126,8 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
126126 <p class="mt-2 break-words font-mono text-neutral-400 text-xs">LOCAL_NED {{ vectorLabel(vtolResult()?.windEvidence?.windMps) }} m/s, from {{ vtolResult()?.windEvidence?.beginsAtMs }} ms.</p>
127127 <p class="mt-2 text-neutral-400 text-xs">World exposure check: {{ vtolResult()?.windEvidence?.worldMatched ? 'matched' : 'unmatched' }}. Expected result: {{ vtolResult()?.windEvidence?.expectedOutcome === 'completed-route' ? 'completed route' : 'back-transition envelope stop' }}.</p>
128128 <p class="mt-2 text-neutral-400 text-xs">The wind is a model input. It is not supplied to the controller or reported as a measured wind estimate.</p>
129 <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This crosswind remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p>
129 <p :if="vtolResult()?.windEvidence?.backHeadingRad !== undefined" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-heading>Back hover heading: {{ vtolResult()?.windEvidence?.backHeadingRad?.toFixed(3) }} rad from north. Plan heading check: {{ vtolResult()?.windEvidence?.backHeadingMatched ? 'matched' : 'unmatched' }}. This separately declared plan keeps the fixed north-facing limit cases unchanged.</p>
130 <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This plan with a fixed north heading remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p>
130131 </div>
131132 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
132133 <p :if="modeObservation()" class="mt-2 break-words font-mono text-neutral-400 text-xs">Last mode observation captured {{ modeObservation()?.capturedAtMs }} ms, sensor capture {{ modeObservation()?.measurementCapturedAtMs }} ms. Age at cursor: {{ playbackTime() - (modeObservation()?.capturedAtMs || 0) }} ms.</p>
resources/functions/drone/vehicles/vtol/simulation.tsmodified+14-3
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -19,6 +19,8 @@ const windCases = {
1919 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' },
2020 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' },
2121 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' },
22 'east-crosswind-2mps': { windMps: [0, 2, 0], expectedOutcome: 'completed-route', backHeadingRad: -Math.PI / 2 },
23 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 },
2224} as const
2325const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
2426export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
@@ -44,7 +46,14 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
4446 const profile = createVtolTransitionProfile({ electrical: options.electrical })
4547 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
4648 const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null
47 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome } : null
49 const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null
50 if (backHeadingRad !== null) {
51 const back = profile.mission.tasks.find(task => task.id === 'back')
52 if (!back || back.kind !== 'vtol-mode') throw new TypeError('Missing VTOL back heading task')
53 back.headingRad = backHeadingRad
54 profile.mission.id = `vtol-${scenario}`
55 }
56 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome, ...(backHeadingRad === null ? {} : { backHeadingRad }) } : null
4857 let exposure = windExposure()
4958 const actions = operator?.actions
5059 if (operator?.recovery) {
@@ -157,11 +166,13 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
157166 const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused'
158167 const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved)
159168 const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
169 const backTask = trace.plan.tasks.find(task => task.id === 'back')
170 const backHeadingMatched = backHeadingRad === null || backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === backHeadingRad
160171 const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral)
161 const demonstrationMatched = wind ? worldMatched && windOutcomeMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
172 const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
162173 const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
163174 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
164 ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched } } : {}),
175 ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null ? {} : { backHeadingMatched }) } } : {}),
165176 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
166177 return { result, report: summary }
167178 } }
resources/functions/drone/vehicles/workspace.tsmodified+2-2
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -17,7 +17,7 @@ import { observeVehicleBattery } from './measurements'
1717import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation'
1818export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const
1919export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number]
20export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null }
20export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null, headingRad?: number | null }
2121export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null }
2222export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean }
2323export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null, vtol?: { action: string | null, request: VehicleVtolTransitionCommand | null, observation: VehicleVtolTransitionObservation | null, battery: VehicleBatteryObservation | null } }
@@ -45,7 +45,7 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile =
4545 }
4646 const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null
4747 const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission)
48 return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions,
48 return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null, headingRad: t.kind === 'vtol-mode' ? t.headingRad : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions,
4949 ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) }
5050}
5151function request(payload: VehiclePayloadCommand): WorkspacePayload { return { eventId: payload.eventId, taskId: payload.taskId, operation: payload.operation, containerId: payload.containerId, issuedAtMs: payload.issuedAtMs, expiresAtMs: payload.expiresAtMs, reference: payload.reference, evidence: 'modeled-request' } }
tests/unit/vtol-crosswind.test.tsadded+69-0
Changes to tests/unit/vtol-crosswind.test.ts
@@ -0,0 +1,69 @@
1import { expect, test } from 'bun:test'
2import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
3import { vtolAerodynamics, vtolModel } from '../../resources/functions/drone/vehicles/vtol/plant'
4import { quaternionEuler } from '../../resources/functions/drone/estimation/frames'
5import { allocateVtolLift } from '../../resources/functions/drone/vehicles/vtol/allocation'
6import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
7
8// Existing actual-runtime seam: explicit mission heading, sustained world wind, raw
9// sensors, estimator, controller and independent board. No physical qualification.
10test('an explicitly west-facing back hover completes the east crosswind route within the original budget', () => {
11 const selected = createVtolSimulationCase('east-crosswind-2mps', { electrical: true })
12 const back = selected.profile.mission.tasks.find(task => task.id === 'back')
13 expect(back?.kind === 'vtol-mode' && back.headingRad).toBe(-Math.PI / 2)
14 const { result, report } = selected.run()
15 expect(report.status).toBe('completed')
16 expect(report.demonstrationMatched).toBe(true)
17 expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, expectedOutcome: 'completed-route', worldMatched: true, outcomeMatched: true, backHeadingMatched: true })
18 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
19 expect(report.transitionEvidence.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']])
20 const backCapture = result.path.find(row => row.atMs === report.transitionEvidence.at(-1)!.capturedAtMs)!.measurementPose!
21 const yaw = quaternionEuler(backCapture.quaternionWxyz)[2]
22 expect(Math.abs(Math.atan2(Math.sin(-Math.PI / 2 - yaw), Math.cos(-Math.PI / 2 - yaw)))).toBeLessThan(0.1)
23 expect(backCapture.capturedAtMs).toBeLessThanOrEqual(report.transitionEvidence.at(-1)!.capturedAtMs)
24 expect(result.mission.reason).toBe('landed')
25 expect(report.landingContact).toBe(true)
26 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
27 expect(report.board.expiresAtMs).toBeNull()
28 expect(report.board.physicalActuationEnabled).toBe(false)
29 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
30})
31
32test('an explicitly east-facing back hover completes the west crosswind route without changing its deadline', () => {
33 const selected = createVtolSimulationCase('west-crosswind-2mps', { electrical: true })
34 const back = selected.profile.mission.tasks.find(task => task.id === 'back')
35 expect(back?.kind === 'vtol-mode' && back.headingRad).toBe(Math.PI / 2)
36 const { result, report } = selected.run()
37 expect(report.status).toBe('completed')
38 expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], backHeadingRad: Math.PI / 2, worldMatched: true, outcomeMatched: true, backHeadingMatched: true })
39 expect(report.demonstrationMatched).toBe(true)
40 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
41 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back'])
42 expect(report.landingContact).toBe(true)
43 expect(report.boardNeutral).toBe(true)
44 expect(report.board.physicalActuationEnabled).toBe(false)
45 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
46})
47
48test('a completed route under a different back heading cannot qualify the named declared plan', () => {
49 const selected = createVtolSimulationCase('east-crosswind-2mps', { electrical: true })
50 const back = selected.profile.mission.tasks.find(task => task.id === 'back')
51 if (!back || back.kind !== 'vtol-mode') throw new Error('Missing back task')
52 back.headingRad = -1.5
53 const { report } = selected.run()
54 expect(report.status).toBe('completed')
55 expect(report.windEvidence!.worldMatched).toBe(true)
56 expect(report.windEvidence!.backHeadingMatched).toBe(false)
57 expect(report.demonstrationMatched).toBe(false)
58})
59
60test('the retained north-facing level-hover example lacks yaw authority even at full rudder and rotor allocation', () => {
61 const aero = vtolAerodynamics([0, -2, 0], [0, 0, 0], [0, 0, 0, 0.4, 0])
62 const allocation = allocateVtolLift({ thrustN: vtolModel.massKg * vtolModel.gravityMps2, torqueNm: [0, 0, -aero.torqueNm[2]] })
63 // Worked fictional-model example: -0.73615511 Nm with full opposing rudder,
64 // +0.4696675 Nm available rotor yaw while preserving 24.516625 N support.
65 expect(allocation.limited).toBe(true)
66 expect(allocation.achieved.thrustN).toBeCloseTo(24.516625, 7)
67 expect(aero.torqueNm[2] + allocation.achieved.torqueNm[2]).toBeCloseTo(-0.2664876105491832, 7)
68 expect(allocation.rotorsN.every(value => value >= 0 && value <= 12)).toBe(true)
69})
tests/unit/vtol-transition-cli.test.tsmodified+18-0
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -17,6 +17,8 @@ test('CI qualifies nominal and failure full-route commands outside the checkout'
1717 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' },
1818 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' },
1919 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' },
20 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical', directory: '$' },
21 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' },
2022 ])
2123})
2224
@@ -147,3 +149,19 @@ test('native wind selectors retain actual exposure and distinct completed or env
147149 expect(short.exitCode).toBe(1)
148150 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
149151}, 90000)
152
153test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => {
154 for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) {
155 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
156 expect(result.exitCode).toBe(0)
157 const report = JSON.parse(result.stdout)
158 expect(report.status).toBe('completed')
159 expect(report.demonstrationMatched).toBe(true)
160 expect(report.windEvidence).toMatchObject({ backHeadingRad: heading, backHeadingMatched: true, worldMatched: true, outcomeMatched: true })
161 expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back'])
162 expect(report.transitionEvidence.at(-1).eventId).toStartWith(`vtol-${scenario}:`)
163 expect(report.landingContact).toBe(true)
164 expect(report.boardNeutral).toBe(true)
165 expect(report.board.physicalActuationEnabled).toBe(false)
166 }
167}, 90000)
tests/unit/vtol-workspace.test.tsmodified+14-1
Changes to tests/unit/vtol-workspace.test.ts
@@ -4,7 +4,7 @@ import { createVtolSimulationCase } from '../../resources/functions/drone/vehicl
44
55test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => {
66 const definition = getMissionWorkspaceDefinition('vtol')
7 expect(definition.scenarios).toHaveLength(18)
7 expect(definition.scenarios).toHaveLength(20)
88 const run = runMissionWorkspace('vtol')
99 expect(run.status).toBe('completed')
1010 expect(run.physicalActuationEnabled).toBe(false)
@@ -100,3 +100,16 @@ test('the shared VTOL case factory runs isolated sessions and retains the CLI re
100100 first.result.path[0]!.actuators[0] = 999
101101 expect(second.result.path[0]!.actuators[0]).not.toBe(999)
102102}, 60000)
103
104test('the workspace exposes the explicit crosswind back heading with its completed recording', () => {
105 const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-2mps', { electrical: true })
106 expect(definition.planId).toBe('vtol-east-crosswind-2mps')
107 expect(definition.tasks.find(task => task.id === 'back')?.headingRad).toBe(-Math.PI / 2)
108 const run = runMissionWorkspace('vtol', 'east-crosswind-2mps', { electrical: true })
109 if (!('vtol' in run)) throw new Error('Expected VTOL recording')
110 expect(run.status).toBe('completed')
111 expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, backHeadingMatched: true, worldMatched: true, outcomeMatched: true })
112 expect(run.vtol!.landingContact).toBe(true)
113 expect(run.rows.at(-1)!.state).toBe('completed')
114 expect(run.boardNeutral).toBe(true)
115})