ReviewOS

also looking at this

stacks/drone

feat: expose observed VTOL wind phase cases

#92
Merged chris wants to merge codex/vtol-wind-phase-cases into main
26 files +609 -87

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
@@ -176,6 +176,10 @@ jobs:
176176 working-directory: ${{ runner.temp }}
177177 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical
178178 working-directory: ${{ runner.temp }}
179 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical
180 working-directory: ${{ runner.temp }}
181 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical
182 working-directory: ${{ runner.temp }}
179183 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4
180184 working-directory: ${{ runner.temp }}
181185 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
app/Commands/VtolSimulate.tsmodified+1-1
Changes to app/Commands/VtolSimulate.ts
@@ -7,7 +7,7 @@ export default defineCommand({
77 description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control',
88 options: {
99 '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' },
10 '--phase <name>': { description: `Fault injection phase: ${vtolSimulationPhases.join(', ')}; default front` },
10 '--phase <name>': { description: `Observed wind onset or fault injection: ${vtolSimulationPhases.join(', ')}; wind also accepts startup (default, 1000 ms); faults default front` },
1111 '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' },
1212 '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false },
1313 },
docs/vehicles-vtol.mdmodified+35-12
Changes to docs/vehicles-vtol.md
@@ -54,7 +54,8 @@ unpowered airflow work. They establish bounded mathematical properties, not a ca
5454A predeclared 16-case runtime campaign introduces 2 m/s head, tail, east and west wind at observed
5555front, cruise, back and landing phase entries. All complete their declared heading plans through
5656source-qualified hover, contact and replay within the original budgets. Wind onset is applied on
57the next actual integration step; the CLI/workspace wind selectors still use the fixed 1000 ms onset.
57the next actual integration step. CLI/workspace selectors now expose the same observed phase onset;
58the default remains the fixed 1000 ms startup onset.
5859
5960Hover control aligns the thrust direction separately from its heading correction. A large, saturated
6061yaw error therefore cannot rotate the requested position correction into another axis. Opposite
@@ -78,7 +79,7 @@ The library campaign applies sustained 2 m/s headwind or tailwind to the actual
7879original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by
7980Buddy, the copied standalone CLI and the mission workspace:
8081
81| Scenario | World wind N/E/D, m/s | Back-hover heading from north | Required recorded outcome |
82| Scenario | World wind N/E/D, m/s | Back-hover heading from north | Default startup outcome |
8283| --- | --- | --- | --- |
8384| `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact |
8485| `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact |
@@ -98,8 +99,28 @@ Buddy, the copied standalone CLI and the mission workspace:
9899first/last matching application epochs, mismatch count and separate world/outcome checks. Actual
99100world inputs are observed after plant integration, outside controller inputs. An absent, wrong or
100101mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases
101reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched,
102so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
102accept `--phase startup|front|cruise|back|landing`; startup remains the default 1000 ms onset.
103For a phase onset, the factory observes the actual running task entry and schedules the weather
104for the next 4 ms plant integration. It never supplies wind or plant truth to controller inputs.
105The declaration has `beginsAtMs: null` until that entry is observed. Reports retain `phase`,
106`phaseEnteredAtMs`, actual onset and `phaseMatched`, independently bound to the recorded task entry.
107An unreached phase remains unexposed and unmatched. Exit 0 means the selected demonstration matched,
108so a startup crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact.
109
110All 24 wind/phase combinations run through the actual runtime with the original time/energy
111budget and heading plans. Headwind, tailwind and the two explicit wind-facing plans complete
112for each phase. The fixed north crosswind limit cases stop at the back-transition envelope
113when wind begins in front, cruise or back. With wind beginning only at landing, those same
114fixed north plans complete through contact. Their `expectedOutcome` is therefore
115`completed-route` for landing onset, and `envelope-stop` for the earlier phases. These are
116distinct modeled cases, not a general wind or landing guarantee. The original startup limit
117cases keep their blocked result. Every selected phase also binds the actual final heading.
118
119```bash
120./buddy vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical
121./buddy vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical
122./storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --phase cruise
123```
103124
104125A tailwind library run can also be constructed explicitly from a script under `scripts/`:
105126
@@ -112,7 +133,7 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0,
112133const result = runVehicleSimulation(profile, { seconds: 160 })
113134```
114135
115The fixed north-facing east/west crosswind plans still reach the back-transition envelope guard
136With startup, front, cruise or back onset, the fixed north-facing east/west crosswind plans reach the back-transition envelope guard
116137without confirmed hover or landing. The retained v2 east-case diagnosis, before the v3 flow projection,
117138records a reverse return at 38,000 ms where
118139the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing
@@ -134,7 +155,7 @@ confirmation and contact route in the declared wind. Their extra `backHeadingRad
134155a different heading remains unmatched. The plan retains its declared final heading, and there are no automatic retries.
135156The original `*-limit-2mps` cases keep their prior plans and blocked outcomes.
136157
137Wider wind directions, onset phases, a general feasibility planner and measured full-angle model qualification
158Wider wind directions and arbitrary onset times, a general feasibility planner and measured full-angle model qualification
138159remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or
139160physical flight acceptance is claimed.
140161
@@ -242,8 +263,8 @@ locked-rotor electrical current.
242263
243264For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`,
244265`host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal
245three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient
246duration exit 1. A duration that excludes a scheduled operator action is rejected before resources
266three confirmations. Invalid selectors, `--phase` on a case without a fault or wind fixture,
267mismatched outcomes or insufficient duration exit 1. A duration that excludes a scheduled operator action is rejected before resources
247268are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime.
248269
249270The controller receives measured relative airspeed, differential pressure, airflow angles and modeled
@@ -445,8 +466,8 @@ it does not model a seized motor's electrical current.
445466## Mission workspace
446467
447468Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`.
448The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection
449phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
469The six source/manual/host faults expose `front`, `cruise`, `back` and `landing` injection
470phases. All six wind cases expose startup or observed front/cruise/back/landing onset. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle,
450471scenario, fault phase or electrical selection clears the previous recording.
451472
452473The workspace shows the validated worst-case time and energy allowance, requested modes and counted
@@ -458,8 +479,10 @@ it does not assert a continuously measured current phase.
458479Task playback shows each requested VTOL heading in radians from north. Wind playback shows
459480the declared vector/onset, the actual world exposure check and the expected completed-route or
460481envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan
461binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading
462crosswind limit recordings retain their blocked status and unsupported-envelope disclosure.
482binding. The wind is a fixture input, not a measured wind estimate. With startup, front, cruise
483or back onset, fixed-heading crosswind limit recordings remain blocked and show the unsupported-envelope
484disclosure. Landing-only onset completes through contact and displays the recorded phase binding
485and completed-route expectation.
463486
464487With the electrical model enabled, each row uses the runtime's already qualified sensor capture.
465488Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining
resources/components/MissionWorkspace.stxmodified+10-5
Changes to resources/components/MissionWorkspace.stx
@@ -3,7 +3,7 @@ import type { MissionWorkspaceProfile, MissionWorkspaceRecording } from '../func
33import { getMissionWorkspaceDefinition, missionWorkspaceProfiles, missionWorkspacePlot, runMissionWorkspace } from '../functions/drone/vehicles/workspace'
44const profile = state<MissionWorkspaceProfile>('quadcopter')
55const scenario = state('nominal')
6const phase = state('front')
6const phase = state('startup')
77const electrical = state(false)
88const result = state<MissionWorkspaceRecording | null>(null)
99const offset = state(600)
@@ -14,7 +14,7 @@ const definition = derived(() => {
1414 const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal'
1515 if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected)
1616 const initial = getMissionWorkspaceDefinition('vtol', selected, { electrical: electrical() })
17 return 'vtol' in initial && initial.vtol?.phase ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial
17 return 'vtol' in initial && initial.vtol?.phases.includes(phase()) && phase() !== 'startup' ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial
1818})
1919const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null })
2020const recording = derived(() => {
@@ -55,6 +55,10 @@ effect(() => {
5555 phase(); electrical()
5656 computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600)
5757 if (!getMissionWorkspaceDefinition(selectedProfile, 'nominal').scenarios.includes(selectedScenario)) scenario.set('nominal')
58 else {
59 const selected = getMissionWorkspaceDefinition(selectedProfile, selectedScenario)
60 if ('vtol' in selected && selected.vtol?.phases.length && !selected.vtol.phases.includes(phase())) phase.set(selected.vtol.phase ?? 'startup')
61 }
5862})
5963function execute() { error.set(''); result.set(null); offset.set(600); busy.set(true); computation.start() }
6064function reset() { computation.stop(); result.set(null); error.set(''); offset.set(600); busy.set(false) }
@@ -68,7 +72,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
6872 <div class="flex flex-wrap gap-4 items-end mt-8">
6973 <div><label for="mission-profile" class="block mb-2 text-sm">Vehicle profile</label><select id="mission-profile" x-model="profile" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in missionWorkspaceProfiles" :value="name">{{ name }}</option></select></div>
7074 <div><label for="mission-scenario" class="block mb-2 text-sm">Operator scenario</label><select id="mission-scenario" x-model="scenario" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in definition().scenarios" :value="name">{{ name }}</option></select></div>
71 <div :if="vtolDefinition()?.phases.length"><label for="mission-vtol-phase" class="block mb-2 text-sm">Fault injection phase</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name }}</option></select></div>
75 <div :if="vtolDefinition()?.phases.length"><label for="mission-vtol-phase" class="block mb-2 text-sm">{{ vtolDefinition()?.wind ? 'Wind onset' : 'Fault injection phase' }}</label><select id="mission-vtol-phase" x-model="phase" class="px-3 py-3 max-w-full text-neutral-100 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"><option :for="name in vtolDefinition()?.phases || []" :value="name">{{ name === 'startup' ? 'startup (1000 ms)' : name }}</option></select></div>
7276 <label :if="profile() === 'vtol'" for="mission-vtol-electrical" class="flex gap-3 items-center py-3 text-sm"><input id="mission-vtol-electrical" type="checkbox" x-model="electrical" class="accent-emerald-200">Modeled electrical load</label>
7377 <button type="button" @click="execute()" :disabled="busy()" class="px-4 py-3 font-medium text-neutral-950 whitespace-nowrap bg-emerald-200 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4 disabled:opacity-60">{{ busy() ? 'Recording…' : 'Run scenario' }}</button>
7478 <button type="button" @click="reset()" class="px-4 py-3 text-sm whitespace-nowrap border border-neutral-600 rounded focus-visible:outline-emerald-200 focus-visible:outline-offset-4">Clear recording</button>
@@ -123,10 +127,11 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value.
123127 <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>
124128 <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind>
125129 <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>
130 <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 === null ? 'unobserved onset' : vtolResult()?.windEvidence?.beginsAtMs + ' ms' }}.</p>
131 <p :if="vtolResult()?.windEvidence?.phase" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-phase>Observed {{ vtolResult()?.windEvidence?.phase }} entry: {{ vtolResult()?.windEvidence?.phaseEnteredAtMs }} ms. Phase binding: {{ vtolResult()?.windEvidence?.phaseMatched ? 'matched' : 'unmatched' }}. Wind starts on the next 4 ms integration step.</p>
127132 <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>
128133 <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?.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>
134 <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 result does not qualify other wind onset or heading plans.</p>
130135 <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>
131136 </div>
132137 <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p>
resources/functions/drone/vehicles/vtol/simulation.tsmodified+31-12
Changes to resources/functions/drone/vehicles/vtol/simulation.ts
@@ -26,6 +26,7 @@ const windCases = {
2626const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null })
2727export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)])
2828export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const)
29export const vtolSimulationWindPhases = Object.freeze(['startup', ...vtolSimulationPhases])
2930const phases = [
3031 { phase: 'front', atMs: 13000, taskId: 'front' },
3132 { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' },
@@ -39,14 +40,16 @@ export interface VtolSimulationOptions { seconds?: number, electrical?: boolean,
3940/** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */
4041export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) {
4142 if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario')
42 if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
4343 const faultCase = Object.hasOwn(faults, scenario)
44 if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario')
44 const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null
45 const availablePhases: readonly string[] = windCase ? vtolSimulationWindPhases : faultCase ? vtolSimulationPhases : []
46 if (options.phase !== undefined && !faultCase && !windCase) throw new RangeError('VTOL phase selection requires a fault or wind scenario')
47 if (options.phase !== undefined && !availablePhases.includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase')
4548 const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null
4649 if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase')
50 const windPhase = windCase && options.phase !== undefined && options.phase !== 'startup' ? phases.find(row => row.phase === options.phase)! : null
4751 const profile = createVtolTransitionProfile({ electrical: options.electrical })
4852 const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined
49 const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null
5053 const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null
5154 if (backHeadingRad !== null) {
5255 const back = profile.mission.tasks.find(task => task.id === 'back')
@@ -54,8 +57,10 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
5457 back.headingRad = backHeadingRad
5558 profile.mission.id = `vtol-${scenario}`
5659 }
57 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome, ...(backHeadingRad === null ? {} : { backHeadingRad }) } : null
60 const declaredHeading = backHeadingRad ?? 0
61 const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: windPhase ? null : 1000, expectedOutcome: windPhase?.phase === 'landing' ? 'completed-route' as const : windCase.expectedOutcome, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingRad: declaredHeading }), ...(windPhase ? { phase: windPhase.phase } : {}) } : null
5862 let exposure = windExposure()
63 let phaseEnteredAtMs: number | null = null, windBeginsAtMs: number | null = wind?.beginsAtMs ?? null
5964 const actions = operator?.actions
6065 if (operator?.recovery) {
6166 const source = profile.mission.tasks.find(task => task.id === operator.recovery)
@@ -99,11 +104,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
99104 }
100105 if (windCase) {
101106 const factory = profile.createSession
102 profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] })
107 profile.faults = (_scenario, atMs) => ({ windMps: windBeginsAtMs !== null && atMs >= windBeginsAtMs ? [...windCase.windMps] as SensorVector : [0, 0, 0] })
103108 profile.createSession = () => {
104109 const session = factory()
105110 let appliedWind: SensorVector | null = null
106 return { ...session, createPlant() {
111 return { ...session, ...(windPhase ? { createController() {
112 const controller = session.createController()
113 return { step(context) {
114 if (phaseEnteredAtMs === null && context.enabled && context.mission.state === 'running' && context.mission.taskId === windPhase.taskId) {
115 phaseEnteredAtMs = context.nowMs
116 windBeginsAtMs = context.nowMs + 4
117 }
118 return controller.step(context)
119 } }
120 } } : {}), createPlant() {
107121 const plant = session.createPlant()
108122 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
109123 const state = plant.step(actuators, dtS, world)
@@ -113,17 +127,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
113127 }, observeIndependent(state, atMs, actuators, hostAvailable) {
114128 session.observeIndependent?.(state, atMs, actuators, hostAvailable)
115129 if (!appliedWind || atMs <= 600) return
116 const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0))
130 const windy = windBeginsAtMs !== null && atMs >= windBeginsAtMs
131 const matches = appliedWind.every((value, index) => value === (windy ? windCase.windMps[index] : 0))
117132 if (!matches) exposure.mismatchSteps++
118 else if (atMs < 1000) exposure.calmSteps++
133 else if (!windy) exposure.calmSteps++
119134 else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs }
120135 } }
121136 }
122137 }
123138 const seconds = options.seconds ?? operator?.seconds ?? 160
124 return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() {
139 return { profile, seconds, phase: phase?.phase ?? windPhase?.phase ?? null, phases: [...availablePhases], electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() {
125140 sources.last = null
126141 exposure = windExposure()
142 phaseEnteredAtMs = null
143 windBeginsAtMs = wind?.beginsAtMs ?? null
127144 const result = runVehicleSimulation(profile, { seconds, missionActions: actions })
128145 const { trace, path, ...report } = result
129146 const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : [])
@@ -166,14 +183,16 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu
166183 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
167184 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'
168185 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)
169 const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
186 const phaseEntry = windPhase ? trace.rows.find(row => row.result.state === 'running' && row.result.taskId === windPhase.taskId) : null
187 const phaseMatched = !windPhase || phaseEnteredAtMs !== null && phaseEntry?.observation.nowMs === phaseEnteredAtMs && windBeginsAtMs === phaseEnteredAtMs + 4
188 const worldMatched = !!wind && phaseMatched && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === windBeginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0
170189 const backTask = trace.plan.tasks.find(task => task.id === 'back')
171 const backHeadingMatched = backHeadingRad === null || backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === backHeadingRad
190 const backHeadingMatched = backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === declaredHeading
172191 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)
173192 const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral
174193 const summary = { ...report, scope: 'development-lift-and-cruise' as const, aerodynamicsModel: vtolModel.aerodynamicsModel, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length,
175194 ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}),
176 ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null ? {} : { backHeadingMatched }) } } : {}),
195 ...(wind ? { windEvidence: { ...structuredClone(wind), beginsAtMs: windBeginsAtMs, ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null && !windPhase ? {} : { backHeadingMatched }), ...(windPhase ? { phaseEnteredAtMs, phaseMatched } : {}) } } : {}),
177196 ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) }
178197 return { result, report: summary }
179198 } }
resources/functions/drone/vehicles/workspace.tsmodified+2-2
Changes to resources/functions/drone/vehicles/workspace.ts
@@ -14,7 +14,7 @@ import type { SubseaScenario } from './subsea'
1414import { reduceSubseaInspection } from './subsea/inspection'
1515import { runVehicleSimulation } from './runtime'
1616import { observeVehicleBattery } from './measurements'
17import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation'
17import { createVtolSimulationCase, vtolSimulationScenarios } from './vtol/simulation'
1818export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const
1919export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number]
2020export 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 }
@@ -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, 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,
49 ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) }
49 ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: [...vtolCase.phases], 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 {
tests/unit/vtol-scenarios.test.tsmodified+2-2
Changes to tests/unit/vtol-scenarios.test.ts
@@ -44,7 +44,7 @@ test('unknown or inapplicable selectors and pre-onset duration cannot qualify a
4444 expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario')
4545 expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase')
4646 expect(() => runVtolSimulation('host-loss', { phase: null } as never)).toThrow('Unknown VTOL simulation phase')
47 expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault scenario')
47 expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault or wind scenario')
4848 for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) {
4949 const report = runVtolSimulation(scenario, { seconds: 2 })
5050 expect(report.status).toBe('duration-exhausted')
@@ -141,7 +141,7 @@ for (const electrical of [false, true]) for (const [scenario, reason] of [
141141})
142142
143143test('operator presets refuse phase selectors and duration that would drop scheduled actions', () => {
144 expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault scenario')
144 expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault or wind scenario')
145145 expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule')
146146 expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule')
147147 const first = runVtolSimulation('back-abort', { electrical: true })
tests/unit/vtol-transition-cli.test.tsmodified+17-1
Changes to tests/unit/vtol-transition-cli.test.ts
@@ -19,6 +19,8 @@ test('CI qualifies nominal and failure full-route commands outside the checkout'
1919 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' },
2020 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical', directory: '$' },
2121 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' },
22 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase back --electrical', directory: '$' },
23 { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --phase landing --electrical', directory: '$' },
2224 ])
2325})
2426
@@ -143,7 +145,7 @@ test('native wind selectors retain actual exposure and distinct completed or env
143145 expect(report.board.expiresAtMs).toBeNull()
144146 expect(report.board.physicalActuationEnabled).toBe(false)
145147 }
146 const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000)
148 const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'unknown']).withTimeout(60000)
147149 expect(invalid.exitCode).toBe(1)
148150 expect(invalid.stdout).toBe('')
149151 const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000)
@@ -151,6 +153,20 @@ test('native wind selectors retain actual exposure and distinct completed or env
151153 expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false)
152154}, 90000)
153155
156test('native wind phase selection retains observed onset and the landing-only fixed-heading result', async () => {
157 for (const phase of ['back', 'landing']) {
158 const result = await command(['vtol:simulate', '--scenario', 'east-crosswind-limit-2mps', '--phase', phase, '--electrical']).withTimeout(60000)
159 expect(result.exitCode).toBe(0)
160 const report = JSON.parse(result.stdout)
161 expect(report.status).toBe(phase === 'landing' ? 'completed' : 'blocked')
162 expect(report.windEvidence).toMatchObject({ phase, backHeadingRad: 0, backHeadingMatched: true, phaseMatched: true, worldMatched: true, outcomeMatched: true })
163 expect(report.windEvidence.firstAppliedAtMs).toBe(report.windEvidence.phaseEnteredAtMs + 4)
164 expect(report.landingContact).toBe(phase === 'landing')
165 expect(report.demonstrationMatched).toBe(true)
166 expect(report.boardNeutral).toBe(true)
167 }
168}, 90000)
169
154170test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => {
155171 for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) {
156172 const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000)
tests/unit/vtol-wind-phase-cases.test.tsadded+114-0
Changes to tests/unit/vtol-wind-phase-cases.test.ts
@@ -0,0 +1,114 @@
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: phase selection must schedule actual world weather
6// from observed runtime entry. This does not qualify arbitrary weather or hardware.
7test('a fixed north crosswind beginning only at landing reports the completed phase-specific route', () => {
8 const scenario = createVtolSimulationCase('east-crosswind-limit-2mps', { phase: 'landing', electrical: true })
9 const { result, report } = scenario.run()
10 const entry = result.trace.rows.find(row => row.result.state === 'running' && row.result.taskId === 'land')!
11 expect(entry).toBeDefined()
12 expect(report.status).toBe('completed')
13 expect(report.demonstrationMatched).toBe(true)
14 expect(report.windEvidence).toMatchObject({ phase: 'landing', phaseEnteredAtMs: entry.observation.nowMs, beginsAtMs: entry.observation.nowMs + 4, firstAppliedAtMs: entry.observation.nowMs + 4, expectedOutcome: 'completed-route', backHeadingRad: 0, backHeadingMatched: true, phaseMatched: true, worldMatched: true, outcomeMatched: true })
15 expect(report.landingContact).toBe(true)
16 expect(report.completeTransitions).toBe(true)
17 expect(report.boardNeutral).toBe(true)
18 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
19 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
20})
21
22const winds = ['headwind-2mps', 'tailwind-2mps', 'east-crosswind-limit-2mps', 'west-crosswind-limit-2mps', 'east-crosswind-2mps', 'west-crosswind-2mps'] as const
23const entries = [{ phase: 'front', task: 'front' }, { phase: 'cruise', task: 'cruise-leg' }, { phase: 'back', task: 'back' }, { phase: 'landing', task: 'land' }] as const
24for (const name of winds) for (const entry of entries) test(`${name} exposes genuine ${entry.phase} onset and its distinct route outcome`, () => {
25 const scenario = createVtolSimulationCase(name, { phase: entry.phase, electrical: true }), factory = scenario.profile.createSession
26 let enteredAt: number | null = null, firstApplied: number | null = null, mismatch = 0
27 const vector = scenario.wind!.windMps
28 scenario.profile.createSession = () => {
29 const session = factory()
30 return { ...session, createController() {
31 const controller = session.createController()
32 return { step(context) {
33 if (enteredAt === null && context.enabled && context.mission.state === 'running' && context.mission.taskId === entry.task) enteredAt = context.nowMs
34 return controller.step(context)
35 } }
36 }, createPlant() {
37 const plant = session.createPlant()
38 let atMs = 600
39 return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) {
40 atMs += dtS * 1000
41 const state = plant.step(actuators, dtS, world)
42 const windy = enteredAt !== null && atMs >= enteredAt + 4
43 if (!world.windMps.every((value, i) => value === (windy ? vector[i] : 0))) mismatch++
44 if (world.windMps.some(n => n !== 0)) firstApplied ??= atMs
45 return state
46 } }
47 } }
48 }
49 const { result, report } = scenario.run()
50 const blocked = name.includes('-limit-') && entry.phase !== 'landing'
51 expect(enteredAt).not.toBeNull()
52 expect(firstApplied).toBe(enteredAt! + 4)
53 expect(mismatch).toBe(0)
54 expect(report.windEvidence).toMatchObject({ phase: entry.phase, phaseEnteredAtMs: enteredAt, beginsAtMs: firstApplied, firstAppliedAtMs: firstApplied, phaseMatched: true, worldMatched: true, outcomeMatched: true, expectedOutcome: blocked ? 'envelope-stop' : 'completed-route', backHeadingMatched: true })
55 expect(report.demonstrationMatched).toBe(true)
56 expect(report.status).toBe(blocked ? 'blocked' : 'completed')
57 expect(report.fault).toBe(blocked ? 'vtol-transition-envelope' : null)
58 expect(report.landingContact).toBe(!blocked)
59 expect(report.completeTransitions).toBe(!blocked)
60 expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 })
61 expect(report.boardNeutral).toBe(true)
62 expect(report.board.physicalActuationEnabled).toBe(false)
63 expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission)
64})
65
66for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`${mutation} weather cannot qualify the selected observed back-phase wind`, () => {
67 const scenario = createVtolSimulationCase('headwind-2mps', { phase: 'back' }), faults = scenario.profile.faults
68 scenario.profile.faults = (name, atMs) => {
69 const declared = faults(name, atMs - (mutation === 'late' ? 4 : 0))
70 return { windMps: mutation === 'absent' ? [0, 0, 0] : mutation === 'early' ? atMs >= 1000 ? [-2, 0, 0] : [0, 0, 0] : mutation === 'opposite' ? [-(declared.windMps?.[0] ?? 0), 0, 0] : declared.windMps }
71 }
72 const { report } = scenario.run()
73 expect(report.windEvidence!.worldMatched).toBe(false)
74 expect(report.windEvidence!.mismatchSteps).toBeGreaterThan(0)
75 expect(report.demonstrationMatched).toBe(false)
76 expect(report.boardNeutral).toBe(true)
77})
78
79test('an unreached wind phase stays unexposed and cannot qualify a partial run', () => {
80 const { report } = createVtolSimulationCase('tailwind-2mps', { phase: 'landing', seconds: 2 }).run()
81 expect(report.status).toBe('duration-exhausted')
82 expect(report.windEvidence).toMatchObject({ beginsAtMs: null, phaseEnteredAtMs: null, firstAppliedAtMs: null, appliedSteps: 0, phaseMatched: false, worldMatched: false, outcomeMatched: false })
83 expect(report.demonstrationMatched).toBe(false)
84 expect(report.boardNeutral).toBe(true)
85})
86
87test('wind phase runs reset exposure and own copies of their declarations', () => {
88 const scenario = createVtolSimulationCase('west-crosswind-limit-2mps', { phase: 'landing' })
89 const first = scenario.run().report, original = structuredClone(first)
90 first.windEvidence!.phaseEnteredAtMs = 0
91 scenario.wind!.windMps[1] = 999
92 scenario.phases.length = 0
93 expect(scenario.run().report).toEqual(original)
94})
95
96test('a different completed heading plan cannot qualify the fixed north landing-onset name', () => {
97 const scenario = createVtolSimulationCase('east-crosswind-limit-2mps', { phase: 'landing' })
98 const back = scenario.profile.mission.tasks.find(task => task.id === 'back')!
99 if (back.kind !== 'vtol-mode') throw new Error('Expected back heading task')
100 back.headingRad = -Math.PI / 2
101 const { report } = scenario.run()
102 expect(report.status).toBe('completed')
103 expect(report.windEvidence).toMatchObject({ worldMatched: true, phaseMatched: true, outcomeMatched: true, backHeadingMatched: false })
104 expect(report.demonstrationMatched).toBe(false)
105})
106
107test('startup is an explicit wind-only default and unrelated phase selectors remain rejected', () => {
108 const selected = createVtolSimulationCase('headwind-2mps', { phase: 'startup' })
109 expect(selected.phase).toBeNull()
110 expect(selected.wind).toMatchObject({ beginsAtMs: 1000, expectedOutcome: 'completed-route' })
111 expect(selected.phases).toEqual(['startup', 'front', 'cruise', 'back', 'landing'])
112 expect(() => createVtolSimulationCase('host-loss', { phase: 'startup' })).toThrow('Unknown VTOL simulation phase')
113 expect(() => createVtolSimulationCase('nominal', { phase: 'back' })).toThrow('requires a fault or wind scenario')
114})
tests/unit/vtol-wind-scenarios.test.tsmodified+2-2
Changes to tests/unit/vtol-wind-scenarios.test.ts
@@ -69,8 +69,8 @@ for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`$
6969 expect(report.boardNeutral).toBe(true)
7070})
7171
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')
72test('wind selection rejects unknown phases and cannot qualify a partial route', () => {
73 expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'unknown' })).toThrow('Unknown VTOL simulation phase')
7474 const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run()
7575 expect(report.status).toBe('duration-exhausted')
7676 expect(report.windEvidence!.worldMatched).toBe(true)
tests/unit/vtol-workspace.test.tsmodified+16-1
Changes to tests/unit/vtol-workspace.test.ts
@@ -74,7 +74,7 @@ test('the workspace retains planned wind separately from observed exposure and a
7474 const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true })
7575 if (!('vtol' in definition)) throw new Error('Expected the VTOL definition')
7676 expect(definition.vtol!.wind).toEqual({ frame: 'LOCAL_NED', windMps: [2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route' })
77 expect(definition.vtol!.phases).toEqual([])
77 expect(definition.vtol!.phases).toEqual(['startup', 'front', 'cruise', 'back', 'landing'])
7878 const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true })
7979 if (!('vtol' in run)) throw new Error('Expected the VTOL recording')
8080 expect(run.status).toBe('blocked')
@@ -88,6 +88,21 @@ test('the workspace retains planned wind separately from observed exposure and a
8888 expect(run.physicalActuationEnabled).toBe(false)
8989})
9090
91test('workspace wind onset remains a declaration until the selected phase is actually observed', () => {
92 const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-limit-2mps', { phase: 'landing', electrical: true })
93 if (!('vtol' in definition)) throw new Error('Expected VTOL definition')
94 expect(definition.vtol!.phase).toBe('landing')
95 expect(definition.vtol!.wind).toMatchObject({ phase: 'landing', beginsAtMs: null, expectedOutcome: 'completed-route', backHeadingRad: 0 })
96 const run = runMissionWorkspace('vtol', 'east-crosswind-limit-2mps', { phase: 'landing', electrical: true })
97 if (!('vtol' in run)) throw new Error('Expected VTOL recording')
98 const enteredAt = run.rows.find(row => row.state === 'running' && row.taskId === 'land')!.atMs
99 expect(run.vtol!.windEvidence).toMatchObject({ phase: 'landing', phaseEnteredAtMs: enteredAt, beginsAtMs: enteredAt + 4, firstAppliedAtMs: enteredAt + 4, phaseMatched: true, worldMatched: true, outcomeMatched: true })
100 expect(run.status).toBe('completed')
101 expect(run.vtol!.landingContact).toBe(true)
102 expect(run.vtol!.demonstrationMatched).toBe(true)
103 expect(run.boardNeutral).toBe(true)
104})
105
91106test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => {
92107 const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true })
93108 const first = selected.run(), second = selected.run()