also looking at this
feat: expose observed VTOL wind phase cases
#92
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.
| @@ -176,6 +176,10 @@ jobs: | ||
| 176 | 176 | working-directory: ${{ runner.temp }} |
| 177 | 177 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical |
| 178 | 178 | 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 }} | |
| 179 | 183 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 180 | 184 | working-directory: ${{ runner.temp }} |
| 181 | 185 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -7,7 +7,7 @@ export default defineCommand({ | ||
| 7 | 7 | description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control', |
| 8 | 8 | options: { |
| 9 | 9 | '--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` }, | |
| 11 | 11 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' }, |
| 12 | 12 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false }, |
| 13 | 13 | }, |
| @@ -54,7 +54,8 @@ unpowered airflow work. They establish bounded mathematical properties, not a ca | ||
| 54 | 54 | A predeclared 16-case runtime campaign introduces 2 m/s head, tail, east and west wind at observed |
| 55 | 55 | front, cruise, back and landing phase entries. All complete their declared heading plans through |
| 56 | 56 | source-qualified hover, contact and replay within the original budgets. Wind onset is applied on |
| 57 | the next actual integration step; the CLI/workspace wind selectors still use the fixed 1000 ms onset. | |
| 57 | the next actual integration step. CLI/workspace selectors now expose the same observed phase onset; | |
| 58 | the default remains the fixed 1000 ms startup onset. | |
| 58 | 59 | |
| 59 | 60 | Hover control aligns the thrust direction separately from its heading correction. A large, saturated |
| 60 | 61 | yaw 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 | ||
| 78 | 79 | original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by |
| 79 | 80 | Buddy, the copied standalone CLI and the mission workspace: |
| 80 | 81 | |
| 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 | | |
| 82 | 83 | | --- | --- | --- | --- | |
| 83 | 84 | | `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact | |
| 84 | 85 | | `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: | ||
| 98 | 99 | first/last matching application epochs, mismatch count and separate world/outcome checks. Actual |
| 99 | 100 | world inputs are observed after plant integration, outside controller inputs. An absent, wrong or |
| 100 | 101 | mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases |
| 101 | reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched, | |
| 102 | so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact. | |
| 102 | accept `--phase startup|front|cruise|back|landing`; startup remains the default 1000 ms onset. | |
| 103 | For a phase onset, the factory observes the actual running task entry and schedules the weather | |
| 104 | for the next 4 ms plant integration. It never supplies wind or plant truth to controller inputs. | |
| 105 | The 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. | |
| 107 | An unreached phase remains unexposed and unmatched. Exit 0 means the selected demonstration matched, | |
| 108 | so a startup crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact. | |
| 109 | ||
| 110 | All 24 wind/phase combinations run through the actual runtime with the original time/energy | |
| 111 | budget and heading plans. Headwind, tailwind and the two explicit wind-facing plans complete | |
| 112 | for each phase. The fixed north crosswind limit cases stop at the back-transition envelope | |
| 113 | when wind begins in front, cruise or back. With wind beginning only at landing, those same | |
| 114 | fixed north plans complete through contact. Their `expectedOutcome` is therefore | |
| 115 | `completed-route` for landing onset, and `envelope-stop` for the earlier phases. These are | |
| 116 | distinct modeled cases, not a general wind or landing guarantee. The original startup limit | |
| 117 | cases 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 | ``` | |
| 103 | 124 | |
| 104 | 125 | A tailwind library run can also be constructed explicitly from a script under `scripts/`: |
| 105 | 126 | |
| @@ -112,7 +133,7 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, | ||
| 112 | 133 | const result = runVehicleSimulation(profile, { seconds: 160 }) |
| 113 | 134 | ``` |
| 114 | 135 | |
| 115 | The fixed north-facing east/west crosswind plans still reach the back-transition envelope guard | |
| 136 | With startup, front, cruise or back onset, the fixed north-facing east/west crosswind plans reach the back-transition envelope guard | |
| 116 | 137 | without confirmed hover or landing. The retained v2 east-case diagnosis, before the v3 flow projection, |
| 117 | 138 | records a reverse return at 38,000 ms where |
| 118 | 139 | the 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 | ||
| 134 | 155 | a different heading remains unmatched. The plan retains its declared final heading, and there are no automatic retries. |
| 135 | 156 | The original `*-limit-2mps` cases keep their prior plans and blocked outcomes. |
| 136 | 157 | |
| 137 | Wider wind directions, onset phases, a general feasibility planner and measured full-angle model qualification | |
| 158 | Wider wind directions and arbitrary onset times, a general feasibility planner and measured full-angle model qualification | |
| 138 | 159 | remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or |
| 139 | 160 | physical flight acceptance is claimed. |
| 140 | 161 | |
| @@ -242,8 +263,8 @@ locked-rotor electrical current. | ||
| 242 | 263 | |
| 243 | 264 | For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`, |
| 244 | 265 | `host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal |
| 245 | three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient | |
| 246 | duration exit 1. A duration that excludes a scheduled operator action is rejected before resources | |
| 266 | three confirmations. Invalid selectors, `--phase` on a case without a fault or wind fixture, | |
| 267 | mismatched outcomes or insufficient duration exit 1. A duration that excludes a scheduled operator action is rejected before resources | |
| 247 | 268 | are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime. |
| 248 | 269 | |
| 249 | 270 | The 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. | ||
| 445 | 466 | ## Mission workspace |
| 446 | 467 | |
| 447 | 468 | Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`. |
| 448 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection | |
| 449 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, | |
| 469 | The six source/manual/host faults expose `front`, `cruise`, `back` and `landing` injection | |
| 470 | phases. 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, | |
| 450 | 471 | scenario, fault phase or electrical selection clears the previous recording. |
| 451 | 472 | |
| 452 | 473 | The 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. | ||
| 458 | 479 | Task playback shows each requested VTOL heading in radians from north. Wind playback shows |
| 459 | 480 | the declared vector/onset, the actual world exposure check and the expected completed-route or |
| 460 | 481 | envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan |
| 461 | binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading | |
| 462 | crosswind limit recordings retain their blocked status and unsupported-envelope disclosure. | |
| 482 | binding. The wind is a fixture input, not a measured wind estimate. With startup, front, cruise | |
| 483 | or back onset, fixed-heading crosswind limit recordings remain blocked and show the unsupported-envelope | |
| 484 | disclosure. Landing-only onset completes through contact and displays the recorded phase binding | |
| 485 | and completed-route expectation. | |
| 463 | 486 | |
| 464 | 487 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. |
| 465 | 488 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining |
| @@ -3,7 +3,7 @@ import type { MissionWorkspaceProfile, MissionWorkspaceRecording } from '../func | ||
| 3 | 3 | import { getMissionWorkspaceDefinition, missionWorkspaceProfiles, missionWorkspacePlot, runMissionWorkspace } from '../functions/drone/vehicles/workspace' |
| 4 | 4 | const profile = state<MissionWorkspaceProfile>('quadcopter') |
| 5 | 5 | const scenario = state('nominal') |
| 6 | const phase = state('front') | |
| 6 | const phase = state('startup') | |
| 7 | 7 | const electrical = state(false) |
| 8 | 8 | const result = state<MissionWorkspaceRecording | null>(null) |
| 9 | 9 | const offset = state(600) |
| @@ -14,7 +14,7 @@ const definition = derived(() => { | ||
| 14 | 14 | const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal' |
| 15 | 15 | if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected) |
| 16 | 16 | 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 | |
| 18 | 18 | }) |
| 19 | 19 | const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null }) |
| 20 | 20 | const recording = derived(() => { |
| @@ -55,6 +55,10 @@ effect(() => { | ||
| 55 | 55 | phase(); electrical() |
| 56 | 56 | computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600) |
| 57 | 57 | 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 | } | |
| 58 | 62 | }) |
| 59 | 63 | function execute() { error.set(''); result.set(null); offset.set(600); busy.set(true); computation.start() } |
| 60 | 64 | function 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. | ||
| 68 | 72 | <div class="flex flex-wrap gap-4 items-end mt-8"> |
| 69 | 73 | <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> |
| 70 | 74 | <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> | |
| 72 | 76 | <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> |
| 73 | 77 | <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> |
| 74 | 78 | <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. | ||
| 123 | 127 | <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 | 128 | <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind> |
| 125 | 129 | <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> | |
| 127 | 132 | <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 | 133 | <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> | |
| 130 | 135 | <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> |
| 131 | 136 | </div> |
| 132 | 137 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> |
| @@ -26,6 +26,7 @@ const windCases = { | ||
| 26 | 26 | const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null }) |
| 27 | 27 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)]) |
| 28 | 28 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) |
| 29 | export const vtolSimulationWindPhases = Object.freeze(['startup', ...vtolSimulationPhases]) | |
| 29 | 30 | const phases = [ |
| 30 | 31 | { phase: 'front', atMs: 13000, taskId: 'front' }, |
| 31 | 32 | { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' }, |
| @@ -39,14 +40,16 @@ export interface VtolSimulationOptions { seconds?: number, electrical?: boolean, | ||
| 39 | 40 | /** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */ |
| 40 | 41 | export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) { |
| 41 | 42 | 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') | |
| 43 | 43 | 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') | |
| 45 | 48 | const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null |
| 46 | 49 | 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 | |
| 47 | 51 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) |
| 48 | 52 | 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 | |
| 50 | 53 | const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null |
| 51 | 54 | if (backHeadingRad !== null) { |
| 52 | 55 | const back = profile.mission.tasks.find(task => task.id === 'back') |
| @@ -54,8 +57,10 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 54 | 57 | back.headingRad = backHeadingRad |
| 55 | 58 | profile.mission.id = `vtol-${scenario}` |
| 56 | 59 | } |
| 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 | |
| 58 | 62 | let exposure = windExposure() |
| 63 | let phaseEnteredAtMs: number | null = null, windBeginsAtMs: number | null = wind?.beginsAtMs ?? null | |
| 59 | 64 | const actions = operator?.actions |
| 60 | 65 | if (operator?.recovery) { |
| 61 | 66 | const source = profile.mission.tasks.find(task => task.id === operator.recovery) |
| @@ -99,11 +104,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 99 | 104 | } |
| 100 | 105 | if (windCase) { |
| 101 | 106 | 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] }) | |
| 103 | 108 | profile.createSession = () => { |
| 104 | 109 | const session = factory() |
| 105 | 110 | 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() { | |
| 107 | 121 | const plant = session.createPlant() |
| 108 | 122 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { |
| 109 | 123 | const state = plant.step(actuators, dtS, world) |
| @@ -113,17 +127,20 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 113 | 127 | }, observeIndependent(state, atMs, actuators, hostAvailable) { |
| 114 | 128 | session.observeIndependent?.(state, atMs, actuators, hostAvailable) |
| 115 | 129 | 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)) | |
| 117 | 132 | if (!matches) exposure.mismatchSteps++ |
| 118 | else if (atMs < 1000) exposure.calmSteps++ | |
| 133 | else if (!windy) exposure.calmSteps++ | |
| 119 | 134 | else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs } |
| 120 | 135 | } } |
| 121 | 136 | } |
| 122 | 137 | } |
| 123 | 138 | 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() { | |
| 125 | 140 | sources.last = null |
| 126 | 141 | exposure = windExposure() |
| 142 | phaseEnteredAtMs = null | |
| 143 | windBeginsAtMs = wind?.beginsAtMs ?? null | |
| 127 | 144 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) |
| 128 | 145 | const { trace, path, ...report } = result |
| 129 | 146 | 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 | ||
| 166 | 183 | 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 |
| 167 | 184 | 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' |
| 168 | 185 | 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 | |
| 170 | 189 | 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 | |
| 172 | 191 | 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) |
| 173 | 192 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral |
| 174 | 193 | 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, |
| 175 | 194 | ...(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 } : {}) } } : {}), | |
| 177 | 196 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } |
| 178 | 197 | return { result, report: summary } |
| 179 | 198 | } } |
| @@ -14,7 +14,7 @@ import type { SubseaScenario } from './subsea' | ||
| 14 | 14 | import { reduceSubseaInspection } from './subsea/inspection' |
| 15 | 15 | import { runVehicleSimulation } from './runtime' |
| 16 | 16 | import { observeVehicleBattery } from './measurements' |
| 17 | import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation' | |
| 17 | import { createVtolSimulationCase, vtolSimulationScenarios } from './vtol/simulation' | |
| 18 | 18 | export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const |
| 19 | 19 | export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number] |
| 20 | 20 | export 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 = | ||
| 46 | 46 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null |
| 47 | 47 | const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission) |
| 48 | 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, |
| 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) } : {}) } } : {}) } | |
| 50 | 50 | } |
| 51 | 51 | function 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' } } |
| 52 | 52 | function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck { |
| @@ -44,7 +44,7 @@ test('unknown or inapplicable selectors and pre-onset duration cannot qualify a | ||
| 44 | 44 | expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario') |
| 45 | 45 | expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase') |
| 46 | 46 | 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') | |
| 48 | 48 | for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) { |
| 49 | 49 | const report = runVtolSimulation(scenario, { seconds: 2 }) |
| 50 | 50 | expect(report.status).toBe('duration-exhausted') |
| @@ -141,7 +141,7 @@ for (const electrical of [false, true]) for (const [scenario, reason] of [ | ||
| 141 | 141 | }) |
| 142 | 142 | |
| 143 | 143 | test('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') | |
| 145 | 145 | expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule') |
| 146 | 146 | expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule') |
| 147 | 147 | const first = runVtolSimulation('back-abort', { electrical: true }) |
| @@ -19,6 +19,8 @@ test('CI qualifies nominal and failure full-route commands outside the checkout' | ||
| 19 | 19 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' }, |
| 20 | 20 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical', directory: '$' }, |
| 21 | 21 | { 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: '$' }, | |
| 22 | 24 | ]) |
| 23 | 25 | }) |
| 24 | 26 | |
| @@ -143,7 +145,7 @@ test('native wind selectors retain actual exposure and distinct completed or env | ||
| 143 | 145 | expect(report.board.expiresAtMs).toBeNull() |
| 144 | 146 | expect(report.board.physicalActuationEnabled).toBe(false) |
| 145 | 147 | } |
| 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) | |
| 147 | 149 | expect(invalid.exitCode).toBe(1) |
| 148 | 150 | expect(invalid.stdout).toBe('') |
| 149 | 151 | 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 | ||
| 151 | 153 | expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false) |
| 152 | 154 | }, 90000) |
| 153 | 155 | |
| 156 | test('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 | ||
| 154 | 170 | test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => { |
| 155 | 171 | for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) { |
| 156 | 172 | const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000) |
| @@ -0,0 +1,114 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | import { 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. | |
| 7 | test('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 | ||
| 22 | const winds = ['headwind-2mps', 'tailwind-2mps', 'east-crosswind-limit-2mps', 'west-crosswind-limit-2mps', 'east-crosswind-2mps', 'west-crosswind-2mps'] as const | |
| 23 | const entries = [{ phase: 'front', task: 'front' }, { phase: 'cruise', task: 'cruise-leg' }, { phase: 'back', task: 'back' }, { phase: 'landing', task: 'land' }] as const | |
| 24 | for (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 | ||
| 66 | for (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 | ||
| 79 | test('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 | ||
| 87 | test('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 | ||
| 96 | test('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 | ||
| 107 | test('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 | }) | |
| @@ -69,8 +69,8 @@ for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`$ | ||
| 69 | 69 | expect(report.boardNeutral).toBe(true) |
| 70 | 70 | }) |
| 71 | 71 | |
| 72 | test('wind selection rejects unrelated phase controls and cannot qualify a partial route', () => { | |
| 73 | expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'front' })).toThrow('requires a fault scenario') | |
| 72 | test('wind selection rejects unknown phases and cannot qualify a partial route', () => { | |
| 73 | expect(() => createVtolSimulationCase('tailwind-2mps', { phase: 'unknown' })).toThrow('Unknown VTOL simulation phase') | |
| 74 | 74 | const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run() |
| 75 | 75 | expect(report.status).toBe('duration-exhausted') |
| 76 | 76 | expect(report.windEvidence!.worldMatched).toBe(true) |
| @@ -74,7 +74,7 @@ test('the workspace retains planned wind separately from observed exposure and a | ||
| 74 | 74 | const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true }) |
| 75 | 75 | if (!('vtol' in definition)) throw new Error('Expected the VTOL definition') |
| 76 | 76 | 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']) | |
| 78 | 78 | const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true }) |
| 79 | 79 | if (!('vtol' in run)) throw new Error('Expected the VTOL recording') |
| 80 | 80 | expect(run.status).toBe('blocked') |
| @@ -88,6 +88,21 @@ test('the workspace retains planned wind separately from observed exposure and a | ||
| 88 | 88 | expect(run.physicalActuationEnabled).toBe(false) |
| 89 | 89 | }) |
| 90 | 90 | |
| 91 | test('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 | ||
| 91 | 106 | test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => { |
| 92 | 107 | const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true }) |
| 93 | 108 | const first = selected.run(), second = selected.run() |