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+8-0
Changes to .github/workflows/ci.yml
@@ -164,6 +164,14 @@ jobs:
164164 working-directory: ${{ runner.temp }}
165165 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical
166166 working-directory: ${{ runner.temp }}
167 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
168 working-directory: ${{ runner.temp }}
169 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical
170 working-directory: ${{ runner.temp }}
171 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical
172 working-directory: ${{ runner.temp }}
173 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical
174 working-directory: ${{ runner.temp }}
167175 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
168176 working-directory: ${{ runner.temp }}
169177 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
README.mdmodified+4-1
Changes to README.md
@@ -84,8 +84,11 @@ 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 14 named
87and measured hybrid energy qualification remain open. The VTOL workspace selects the same 18 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.
8992
9093## Simulated carried load
9194
docs/vehicles-vtol.mdmodified+34-8
Changes to docs/vehicles-vtol.md
@@ -51,12 +51,34 @@ thrust while supporting the requested vertical force; high-speed candidate incid
5151apply. Replacing a correlated back request preserves rotary capture once reached, so recovery cannot
5252restart forward-flight braking during a reverse position return.
5353
54### Bounded library wind experiment
54### Bounded wind cases
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. This does not add wind selectors
59to the 14 named CLI/workspace cases below. A tailwind library run can be constructed explicitly:
58original budgets using the raw sensor/estimator/controller runtime. Four named cases are shared by
59Buddy, the copied standalone CLI and the mission workspace:
60
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 |
67
68```sh
69./buddy vtol:simulate --scenario tailwind-2mps --electrical
70./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical
71./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical
72```
73
74`windEvidence` retains the declared LOCAL_NED vector and onset, observed calm/applied plant steps,
75first/last matching application epochs, mismatch count and separate world/outcome checks. Actual
76world inputs are observed after plant integration, outside controller inputs. An absent, wrong or
77mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases
78reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched,
79so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
80
81A tailwind library run can also be constructed explicitly from a script under `scripts/`:
6082
6183```ts
6284import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile'
@@ -67,10 +89,10 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0,
6789const result = runVehicleSimulation(profile, { seconds: 160 })
6890```
6991
70The same `[0, 2, 0]` m/s crosswind experiment still reaches the back-transition envelope guard
71without confirmed hover or landing. Its yaw excursion and eventual pitch limit remain an unsupported
72case. Neutral outputs at that airborne failure do not establish a safe landing. Wider wind directions,
73onset phases, planning/heading feasibility and named native/workspace cases remain in
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
7496[#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical
7597flight acceptance is claimed.
7698
@@ -378,7 +400,7 @@ it does not model a seized motor's electrical current.
378400
379401## Mission workspace
380402
381Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`.
403Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`.
382404The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
383405phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
384406scenario, fault phase or electrical selection clears the previous recording.
@@ -389,6 +411,10 @@ position, operator actions, request-bound controller observations and their capt
389411table retains changes and confirmations. The last mode observation includes its age at the cursor;
390412it does not assert a continuously measured current phase.
391413
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.
417
392418With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
393419Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
394420lower bound are displayed with capture and observation times. A missing capture stays missing.
resources/components/MissionWorkspace.stxmodified+7-0
Changes to resources/components/MissionWorkspace.stx
@@ -121,6 +121,13 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
121121 <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3>
122122 <p class="mt-2 text-neutral-400 text-xs" data-vtol-demonstration>Named outcome check: {{ vtolResult()?.demonstrationMatched ? 'matched' : 'unmatched' }}. Final qualified landing contact: {{ vtolResult()?.landingContact ? 'observed' : 'not observed' }}.</p>
123123 <p class="mt-2 leading-relaxed text-neutral-400 text-xs">A matched case can be blocked, aborted or host-lost. These are recorded model observations, not physical flight qualification.</p>
124 <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind>
125 <h4 class="font-semibold text-sm">Declared world wind</h4>
126 <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>
127 <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>
128 <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>
130 </div>
124131 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
125132 <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>
126133 <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p>
resources/functions/drone/vehicles/vtol/simulation.tsmodified+41-3
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -1,6 +1,7 @@
11import { createVtolTransitionProfile } from './transition-profile'
22import { runVehicleSimulation } from '../runtime'
33import type { VehicleMissionAction } from '../types'
4import type { SensorVector } from '../../sensors/types'
45
56const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const
67interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number }
@@ -13,7 +14,14 @@ const operatorCases = {
1314 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true },
1415 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 },
1516} as const satisfies Record<string, OperatorCase>
16export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)])
17const windCases = {
18 'headwind-2mps': { windMps: [-2, 0, 0], expectedOutcome: 'completed-route' },
19 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' },
20 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' },
21 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' },
22} as const
23const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
24export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
1725export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
1826const phases = [
1927 { phase: 'front', atMs: 13000, taskId: 'front' },
@@ -35,6 +43,9 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
3543 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
3644 const profile = createVtolTransitionProfile({ electrical: options.electrical })
3745 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
46 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
48 let exposure = windExposure()
3849 const actions = operator?.actions
3950 if (operator?.recovery) {
4051 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
@@ -76,9 +87,33 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
7687 } }
7788 }
7889 }
90 if (windCase) {
91 const factory = profile.createSession
92 profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] })
93 profile.createSession = () => {
94 const session = factory()
95 let appliedWind: SensorVector | null = null
96 return { ...session, createPlant() {
97 const plant = session.createPlant()
98 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
99 const state = plant.step(actuators, dtS, world)
100 appliedWind = [...world.windMps]
101 return state
102 } }
103 }, observeIndependent(state, atMs, actuators, hostAvailable) {
104 session.observeIndependent?.(state, atMs, actuators, hostAvailable)
105 if (!appliedWind || atMs <= 600) return
106 const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0))
107 if (!matches) exposure.mismatchSteps++
108 else if (atMs < 1000) exposure.calmSteps++
109 else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs }
110 } }
111 }
112 }
79113 const seconds = options.seconds ?? operator?.seconds ?? 160
80 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() {
114 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() {
81115 sources.last = null
116 exposure = windExposure()
82117 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
83118 const { trace, path, ...report } = result
84119 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
@@ -121,9 +156,12 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
121156 const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral
122157 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'
123158 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)
124 const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
159 const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
160 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
125162 const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
126163 ...(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 } } : {}),
127165 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
128166 return { result, report: summary }
129167 } }
resources/functions/drone/vehicles/workspace.tsmodified+2-2
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -46,7 +46,7 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile =
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)
4848 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,
49 ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds } } : {}) }
49 ...(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' } }
5252function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck {
@@ -83,7 +83,7 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt
8383 }
8484 const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null
8585 return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600,
86 ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null } } : {}) }
86 ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null, ...(vtolRun.report.windEvidence ? { windEvidence: structuredClone(vtolRun.report.windEvidence) } : {}) } } : {}) }
8787}
8888export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace>
8989/** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */
tests/unit/vtol-transition-cli.test.tsmodified+27-0
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -13,6 +13,10 @@ test('CI qualifies nominal and failure full-route commands outside the checkout'
1313 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' },
1414 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' },
1515 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' },
16 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical', directory: '$' },
17 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' },
18 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' },
19 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' },
1620 ])
1721})
1822
@@ -120,3 +124,26 @@ test('native scenario selectors cannot silently become a nominal demonstration',
120124 expect(report.demonstrationMatched).toBe(false)
121125 expect(report.scenarioEvidence.faultObserved).toBe(false)
122126}, 90000)
127
128test('native wind selectors retain actual exposure and distinct completed or envelope-stop outcomes', async () => {
129 for (const [scenario, status] of [['headwind-2mps', 'completed'], ['tailwind-2mps', 'completed'], ['east-crosswind-limit-2mps', 'blocked'], ['west-crosswind-limit-2mps', 'blocked']] as const) {
130 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
131 expect(result.exitCode).toBe(0)
132 const report = JSON.parse(result.stdout)
133 expect(report.scenario).toBe(scenario)
134 expect(report.status).toBe(status)
135 expect(report.demonstrationMatched).toBe(true)
136 expect(report.windEvidence.worldMatched).toBe(true)
137 expect(report.windEvidence.outcomeMatched).toBe(true)
138 expect(report.windEvidence.firstAppliedAtMs).toBe(1000)
139 expect(report.landingContact).toBe(status === 'completed')
140 expect(report.board.expiresAtMs).toBeNull()
141 expect(report.board.physicalActuationEnabled).toBe(false)
142 }
143 const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000)
144 expect(invalid.exitCode).toBe(1)
145 expect(invalid.stdout).toBe('')
146 const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000)
147 expect(short.exitCode).toBe(1)
148 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
149}, 90000)
tests/unit/vtol-wind-scenarios.test.tsadded+89-0
Changes to tests/unit/vtol-wind-scenarios.test.ts
@@ -0,0 +1,89 @@
1import { expect, test } from 'bun:test'
2import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation'
3import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4
5// Shared public case seam: actual plant wind, raw sensors, estimator, mission,
6// authority and board loop. These observations do not qualify a measured airframe.
7test('the named headwind case observes actual world exposure and completes the original finite route', () => {
8 const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true })
9 const { result, report } = scenario.run()
10 expect(report.status).toBe('completed')
11 expect(report.demonstrationMatched).toBe(true)
12 expect(report.windEvidence).toMatchObject({ frame: 'LOCAL_NED', windMps: [-2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route', firstAppliedAtMs: 1000, mismatchSteps: 0, worldMatched: true, outcomeMatched: true })
13 expect(report.windEvidence!.appliedSteps).toBeGreaterThan(100)
14 expect(report.windEvidence!.lastAppliedAtMs).toBe(result.trace.rows.at(-1)!.observation.nowMs)
15 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back'])
16 expect(report.landingContact).toBe(true)
17 expect(report.boardNeutral).toBe(true)
18 expect(report.board.physicalActuationEnabled).toBe(false)
19 expect(result.trace.rows.at(-1)!.result.reason).toBe('landed')
20})
21
22test('the named tailwind completes captured transitions and replay without extending its reserve or deadline', () => {
23 const { result, report } = createVtolSimulationCase('tailwind-2mps', { electrical: true }).run()
24 expect(report.status).toBe('completed')
25 expect(report.demonstrationMatched).toBe(true)
26 expect(report.windEvidence).toMatchObject({ windMps: [2, 0, 0], beginsAtMs: 1000, worldMatched: true, outcomeMatched: true })
27 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
28 expect(report.transitionEvidence.map(row => row.phase)).toEqual(['hover', 'cruise', 'hover'])
29 expect(report.landingContact).toBe(true)
30 expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0])
31 expect(report.board.expiresAtMs).toBeNull()
32 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
33})
34
35test('the east crosswind limit records the actual back-envelope stop without certifying hover or landing', () => {
36 const { result, report } = createVtolSimulationCase('east-crosswind-limit-2mps', { electrical: true }).run()
37 expect(report.status).toBe('blocked')
38 expect(report.fault).toBe('vtol-transition-envelope')
39 expect(report.mission.taskId).toBe('back')
40 expect(report.mission.state).toBe('blocked')
41 expect(report.demonstrationMatched).toBe(true)
42 expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true, mismatchSteps: 0 })
43 expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front'])
44 expect(report.completeTransitions).toBe(false)
45 expect(report.landingContact).toBe(false)
46 expect(report.boardNeutral).toBe(true)
47 expect(report.board.physicalActuationEnabled).toBe(false)
48 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
49})
50
51test('the west crosswind limit keeps its own signed world vector and blocked outcome', () => {
52 const { report } = createVtolSimulationCase('west-crosswind-limit-2mps').run()
53 expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true })
54 expect(report.status).toBe('blocked')
55 expect(report.fault).toBe('vtol-transition-envelope')
56 expect(report.demonstrationMatched).toBe(true)
57 expect(report.landingContact).toBe(false)
58 expect(report.completeTransitions).toBe(false)
59 expect(report.boardNeutral).toBe(true)
60})
61
62for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`${mutation} world wind cannot qualify under the declared headwind name`, () => {
63 const scenario = createVtolSimulationCase('headwind-2mps')
64 scenario.profile.faults = (_name, atMs) => ({ windMps: mutation === 'absent' ? [0, 0, 0] : atMs >= (mutation === 'early' ? 800 : mutation === 'late' ? 1200 : 1000) ? [mutation === 'opposite' ? 2 : -2, 0, 0] : [0, 0, 0] })
65 const { report } = scenario.run()
66 expect(report.windEvidence!.worldMatched).toBe(false)
67 expect(report.windEvidence!.mismatchSteps).toBeGreaterThan(0)
68 expect(report.demonstrationMatched).toBe(false)
69 expect(report.boardNeutral).toBe(true)
70})
71
72test('wind selection rejects unrelated phase controls and cannot qualify a partial route', () => {
73 expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'front' })).toThrow('requires a fault scenario')
74 const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run()
75 expect(report.status).toBe('duration-exhausted')
76 expect(report.windEvidence!.worldMatched).toBe(true)
77 expect(report.windEvidence!.outcomeMatched).toBe(false)
78 expect(report.demonstrationMatched).toBe(false)
79 expect(report.landingContact).toBe(false)
80})
81
82test('each wind run owns its declaration, exposure and mutable vehicle session', () => {
83 const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true })
84 const first = scenario.run().report, original = structuredClone(first)
85 first.windEvidence!.windMps[0] = 99
86 first.windEvidence!.appliedSteps = 0
87 scenario.wind!.windMps[0] = 999
88 expect(scenario.run().report).toEqual(original)
89})
tests/unit/vtol-workspace.test.tsmodified+19-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(14)
7 expect(definition.scenarios).toHaveLength(18)
88 const run = runMissionWorkspace('vtol')
99 expect(run.status).toBe('completed')
1010 expect(run.physicalActuationEnabled).toBe(false)
@@ -70,6 +70,24 @@ test('VTOL choices reject irrelevant phases and do not apply electrical flags to
7070 expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow()
7171})
7272
73test('the workspace retains planned wind separately from observed exposure and a blocked crosswind recording', () => {
74 const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true })
75 if (!('vtol' in definition)) throw new Error('Expected the VTOL definition')
76 expect(definition.vtol!.wind).toEqual({ frame: 'LOCAL_NED', windMps: [2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route' })
77 expect(definition.vtol!.phases).toEqual([])
78 const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true })
79 if (!('vtol' in run)) throw new Error('Expected the VTOL recording')
80 expect(run.status).toBe('blocked')
81 expect(run.fault).toBe('vtol-transition-envelope')
82 expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true })
83 expect(run.vtol!.demonstrationMatched).toBe(true)
84 expect(run.vtol!.landingContact).toBe(false)
85 expect(run.rows.at(-1)!.taskId).toBe('back')
86 expect(run.rows.at(-1)!.state).toBe('blocked')
87 expect(run.boardNeutral).toBe(true)
88 expect(run.physicalActuationEnabled).toBe(false)
89})
90
7391test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => {
7492 const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true })
7593 const first = selected.run(), second = selected.run()