also looking at this
feat: expose bounded VTOL wind scenarios
#89
20 files
+671
-33
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.
| @@ -164,6 +164,14 @@ jobs: | ||
| 164 | 164 | working-directory: ${{ runner.temp }} |
| 165 | 165 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical |
| 166 | 166 | working-directory: ${{ runner.temp }} |
| 167 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical | |
| 168 | working-directory: ${{ runner.temp }} | |
| 169 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical | |
| 170 | working-directory: ${{ runner.temp }} | |
| 171 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical | |
| 172 | working-directory: ${{ runner.temp }} | |
| 173 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical | |
| 174 | working-directory: ${{ runner.temp }} | |
| 167 | 175 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 168 | 176 | working-directory: ${{ runner.temp }} |
| 169 | 177 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -84,8 +84,11 @@ mode observations. Named scenarios expose phase-bound sensor/manual/host loss, c | ||
| 84 | 84 | declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry. |
| 85 | 85 | Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. |
| 86 | 86 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 14 named | |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 18 named | |
| 88 | 88 | cases, fault phases and optional electrical model, with captured mode and battery playback. |
| 89 | The 2 m/s headwind and tailwind cases complete their routes. East/west crosswind limit cases retain | |
| 90 | their blocked envelope outcome, without confirmed hover or landing. Reports and playback distinguish | |
| 91 | declared world wind from observed exposure checks; controller inputs contain no wind oracle. | |
| 89 | 92 | |
| 90 | 93 | ## Simulated carried load |
| 91 | 94 | |
| @@ -51,12 +51,34 @@ thrust while supporting the requested vertical force; high-speed candidate incid | ||
| 51 | 51 | apply. Replacing a correlated back request preserves rotary capture once reached, so recovery cannot |
| 52 | 52 | restart forward-flight braking during a reverse position return. |
| 53 | 53 | |
| 54 | ### Bounded library wind experiment | |
| 54 | ### Bounded wind cases | |
| 55 | 55 | |
| 56 | 56 | The library campaign applies sustained 2 m/s headwind or tailwind to the actual world model from |
| 57 | 57 | 1000 ms, including takeoff, transitions, cruise and landing. Both routes complete within their |
| 58 | original budgets using the raw sensor/estimator/controller runtime. This does not add wind selectors | |
| 59 | to the 14 named CLI/workspace cases below. A tailwind library run can be constructed explicitly: | |
| 58 | original budgets using the raw sensor/estimator/controller runtime. Four named cases are shared by | |
| 59 | Buddy, the copied standalone CLI and the mission workspace: | |
| 60 | ||
| 61 | | Scenario | World wind N/E/D, m/s | Required recorded outcome | | |
| 62 | | --- | --- | --- | | |
| 63 | | `headwind-2mps` | -2, 0, 0 | Completed route and contact | | |
| 64 | | `tailwind-2mps` | 2, 0, 0 | Completed route and contact | | |
| 65 | | `east-crosswind-limit-2mps` | 0, 2, 0 | Blocked back-transition envelope, no hover/landing claim | | |
| 66 | | `west-crosswind-limit-2mps` | 0, -2, 0 | Blocked back-transition envelope, no hover/landing claim | | |
| 67 | ||
| 68 | ```sh | |
| 69 | ./buddy vtol:simulate --scenario tailwind-2mps --electrical | |
| 70 | ./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical | |
| 71 | ./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical | |
| 72 | ``` | |
| 73 | ||
| 74 | `windEvidence` retains the declared LOCAL_NED vector and onset, observed calm/applied plant steps, | |
| 75 | first/last matching application epochs, mismatch count and separate world/outcome checks. Actual | |
| 76 | world inputs are observed after plant integration, outside controller inputs. An absent, wrong or | |
| 77 | mistimed wind cannot pass under the scenario name. A partial route remains unmatched. Wind cases | |
| 78 | reject `--phase`; their declared onset is 1000 ms. Exit 0 means the selected demonstration matched, | |
| 79 | so a crosswind limit report still has `status: 'blocked'`, no confirmed back hover and no contact. | |
| 80 | ||
| 81 | A tailwind library run can also be constructed explicitly from a script under `scripts/`: | |
| 60 | 82 | |
| 61 | 83 | ```ts |
| 62 | 84 | import { createVtolTransitionProfile } from '../resources/functions/drone/vehicles/vtol/transition-profile' |
| @@ -67,10 +89,10 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, | ||
| 67 | 89 | const result = runVehicleSimulation(profile, { seconds: 160 }) |
| 68 | 90 | ``` |
| 69 | 91 | |
| 70 | The same `[0, 2, 0]` m/s crosswind experiment still reaches the back-transition envelope guard | |
| 71 | without confirmed hover or landing. Its yaw excursion and eventual pitch limit remain an unsupported | |
| 72 | case. Neutral outputs at that airborne failure do not establish a safe landing. Wider wind directions, | |
| 73 | onset phases, planning/heading feasibility and named native/workspace cases remain in | |
| 92 | The east/west crosswind experiments still reach the back-transition envelope guard | |
| 93 | without confirmed hover or landing. Their yaw excursions and eventual pitch limits remain unsupported | |
| 94 | outcomes. Neutral outputs at those airborne failures do not establish a safe landing. Wider wind directions, | |
| 95 | onset phases and planning/heading feasibility remain in | |
| 74 | 96 | [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical |
| 75 | 97 | flight acceptance is claimed. |
| 76 | 98 | |
| @@ -378,7 +400,7 @@ it does not model a seized motor's electrical current. | ||
| 378 | 400 | |
| 379 | 401 | ## Mission workspace |
| 380 | 402 | |
| 381 | Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`. | |
| 403 | Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`. | |
| 382 | 404 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection |
| 383 | 405 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, |
| 384 | 406 | scenario, fault phase or electrical selection clears the previous recording. |
| @@ -389,6 +411,10 @@ position, operator actions, request-bound controller observations and their capt | ||
| 389 | 411 | table retains changes and confirmations. The last mode observation includes its age at the cursor; |
| 390 | 412 | it does not assert a continuously measured current phase. |
| 391 | 413 | |
| 414 | Wind playback shows the declared vector/onset, the actual world exposure check and the expected | |
| 415 | completed-route or envelope-stop outcome. The wind is a fixture input, not a measured wind estimate. | |
| 416 | Crosswind limit recordings retain their blocked status and unsupported-envelope disclosure. | |
| 417 | ||
| 392 | 418 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. |
| 393 | 419 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining |
| 394 | 420 | lower bound are displayed with capture and observation times. A missing capture stays missing. |
| @@ -121,6 +121,13 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 121 | 121 | <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3> |
| 122 | 122 | <p class="mt-2 text-neutral-400 text-xs" data-vtol-demonstration>Named outcome check: {{ vtolResult()?.demonstrationMatched ? 'matched' : 'unmatched' }}. Final qualified landing contact: {{ vtolResult()?.landingContact ? 'observed' : 'not observed' }}.</p> |
| 123 | 123 | <p class="mt-2 leading-relaxed text-neutral-400 text-xs">A matched case can be blocked, aborted or host-lost. These are recorded model observations, not physical flight qualification.</p> |
| 124 | <div :if="vtolResult()?.windEvidence" class="mt-5" data-vtol-wind> | |
| 125 | <h4 class="font-semibold text-sm">Declared world wind</h4> | |
| 126 | <p class="mt-2 break-words font-mono text-neutral-400 text-xs">LOCAL_NED {{ vectorLabel(vtolResult()?.windEvidence?.windMps) }} m/s, from {{ vtolResult()?.windEvidence?.beginsAtMs }} ms.</p> | |
| 127 | <p class="mt-2 text-neutral-400 text-xs">World exposure check: {{ vtolResult()?.windEvidence?.worldMatched ? 'matched' : 'unmatched' }}. Expected result: {{ vtolResult()?.windEvidence?.expectedOutcome === 'completed-route' ? 'completed route' : 'back-transition envelope stop' }}.</p> | |
| 128 | <p class="mt-2 text-neutral-400 text-xs">The wind is a model input. It is not supplied to the controller or reported as a measured wind estimate.</p> | |
| 129 | <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This crosswind remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p> | |
| 130 | </div> | |
| 124 | 131 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> |
| 125 | 132 | <p :if="modeObservation()" class="mt-2 break-words font-mono text-neutral-400 text-xs">Last mode observation captured {{ modeObservation()?.capturedAtMs }} ms, sensor capture {{ modeObservation()?.measurementCapturedAtMs }} ms. Age at cursor: {{ playbackTime() - (modeObservation()?.capturedAtMs || 0) }} ms.</p> |
| 126 | 133 | <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p> |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { createVtolTransitionProfile } from './transition-profile' |
| 2 | 2 | import { runVehicleSimulation } from '../runtime' |
| 3 | 3 | import type { VehicleMissionAction } from '../types' |
| 4 | import type { SensorVector } from '../../sensors/types' | |
| 4 | 5 | |
| 5 | 6 | const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const |
| 6 | 7 | interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number } |
| @@ -13,7 +14,14 @@ const operatorCases = { | ||
| 13 | 14 | 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true }, |
| 14 | 15 | 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 }, |
| 15 | 16 | } as const satisfies Record<string, OperatorCase> |
| 16 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)]) | |
| 17 | const windCases = { | |
| 18 | 'headwind-2mps': { windMps: [-2, 0, 0], expectedOutcome: 'completed-route' }, | |
| 19 | 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' }, | |
| 20 | 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' }, | |
| 21 | 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' }, | |
| 22 | } as const | |
| 23 | const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null }) | |
| 24 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)]) | |
| 17 | 25 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) |
| 18 | 26 | const phases = [ |
| 19 | 27 | { phase: 'front', atMs: 13000, taskId: 'front' }, |
| @@ -35,6 +43,9 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 35 | 43 | if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase') |
| 36 | 44 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) |
| 37 | 45 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined |
| 46 | const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null | |
| 47 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome } : null | |
| 48 | let exposure = windExposure() | |
| 38 | 49 | const actions = operator?.actions |
| 39 | 50 | if (operator?.recovery) { |
| 40 | 51 | const source = profile.mission.tasks.find(task => task.id === operator.recovery) |
| @@ -76,9 +87,33 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 76 | 87 | } } |
| 77 | 88 | } |
| 78 | 89 | } |
| 90 | if (windCase) { | |
| 91 | const factory = profile.createSession | |
| 92 | profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [...windCase.windMps] as SensorVector : [0, 0, 0] }) | |
| 93 | profile.createSession = () => { | |
| 94 | const session = factory() | |
| 95 | let appliedWind: SensorVector | null = null | |
| 96 | return { ...session, createPlant() { | |
| 97 | const plant = session.createPlant() | |
| 98 | return { snapshot: () => plant.snapshot(), step(actuators, dtS, world) { | |
| 99 | const state = plant.step(actuators, dtS, world) | |
| 100 | appliedWind = [...world.windMps] | |
| 101 | return state | |
| 102 | } } | |
| 103 | }, observeIndependent(state, atMs, actuators, hostAvailable) { | |
| 104 | session.observeIndependent?.(state, atMs, actuators, hostAvailable) | |
| 105 | if (!appliedWind || atMs <= 600) return | |
| 106 | const matches = appliedWind.every((value, index) => value === (atMs >= 1000 ? windCase.windMps[index] : 0)) | |
| 107 | if (!matches) exposure.mismatchSteps++ | |
| 108 | else if (atMs < 1000) exposure.calmSteps++ | |
| 109 | else { exposure.appliedSteps++; exposure.firstAppliedAtMs ??= atMs; exposure.lastAppliedAtMs = atMs } | |
| 110 | } } | |
| 111 | } | |
| 112 | } | |
| 79 | 113 | const seconds = options.seconds ?? operator?.seconds ?? 160 |
| 80 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() { | |
| 114 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, wind: wind ? structuredClone(wind) : null, run() { | |
| 81 | 115 | sources.last = null |
| 116 | exposure = windExposure() | |
| 82 | 117 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) |
| 83 | 118 | const { trace, path, ...report } = result |
| 84 | 119 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) |
| @@ -121,9 +156,12 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 121 | 156 | 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 |
| 122 | 157 | 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' |
| 123 | 158 | const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved) |
| 124 | const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 159 | const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0 | |
| 160 | const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral) | |
| 161 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 125 | 162 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, |
| 126 | 163 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), |
| 164 | ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched } } : {}), | |
| 127 | 165 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } |
| 128 | 166 | return { result, report: summary } |
| 129 | 167 | } } |
| @@ -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 } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds } } : {}) } | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds, ...(vtolCase.wind ? { wind: structuredClone(vtolCase.wind) } : {}) } } : {}) } | |
| 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 { |
| @@ -83,7 +83,7 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 83 | 83 | } |
| 84 | 84 | const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null |
| 85 | 85 | return { definition, status: r.status, fault: r.fault, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: r.status === 'host-lost', boardNeutral: r.board.actuators.every((n, i) => n === p.actuators[i]!.neutral), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey, inspection, navigationEvidence: r.navigationEvidence, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600, |
| 86 | ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null } } : {}) } | |
| 86 | ...(vtolRun ? { vtol: { demonstrationMatched: vtolRun.report.demonstrationMatched, landingContact: vtolRun.report.landingContact, operatorEvidence: vtolRun.report.operatorEvidence ?? null, scenarioEvidence: vtolRun.report.scenarioEvidence ?? null, ...(vtolRun.report.windEvidence ? { windEvidence: structuredClone(vtolRun.report.windEvidence) } : {}) } } : {}) } | |
| 87 | 87 | } |
| 88 | 88 | export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace> |
| 89 | 89 | /** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */ |
| @@ -13,6 +13,10 @@ test('CI qualifies nominal and failure full-route commands outside the checkout' | ||
| 13 | 13 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' }, |
| 14 | 14 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' }, |
| 15 | 15 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' }, |
| 16 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical', directory: '$' }, | |
| 17 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' }, | |
| 18 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' }, | |
| 19 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' }, | |
| 16 | 20 | ]) |
| 17 | 21 | }) |
| 18 | 22 | |
| @@ -120,3 +124,26 @@ test('native scenario selectors cannot silently become a nominal demonstration', | ||
| 120 | 124 | expect(report.demonstrationMatched).toBe(false) |
| 121 | 125 | expect(report.scenarioEvidence.faultObserved).toBe(false) |
| 122 | 126 | }, 90000) |
| 127 | ||
| 128 | test('native wind selectors retain actual exposure and distinct completed or envelope-stop outcomes', async () => { | |
| 129 | for (const [scenario, status] of [['headwind-2mps', 'completed'], ['tailwind-2mps', 'completed'], ['east-crosswind-limit-2mps', 'blocked'], ['west-crosswind-limit-2mps', 'blocked']] as const) { | |
| 130 | const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000) | |
| 131 | expect(result.exitCode).toBe(0) | |
| 132 | const report = JSON.parse(result.stdout) | |
| 133 | expect(report.scenario).toBe(scenario) | |
| 134 | expect(report.status).toBe(status) | |
| 135 | expect(report.demonstrationMatched).toBe(true) | |
| 136 | expect(report.windEvidence.worldMatched).toBe(true) | |
| 137 | expect(report.windEvidence.outcomeMatched).toBe(true) | |
| 138 | expect(report.windEvidence.firstAppliedAtMs).toBe(1000) | |
| 139 | expect(report.landingContact).toBe(status === 'completed') | |
| 140 | expect(report.board.expiresAtMs).toBeNull() | |
| 141 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 142 | } | |
| 143 | const invalid = await command(['vtol:simulate', '--scenario', 'headwind-2mps', '--phase', 'back']).withTimeout(60000) | |
| 144 | expect(invalid.exitCode).toBe(1) | |
| 145 | expect(invalid.stdout).toBe('') | |
| 146 | const short = await command(['vtol:simulate', '--scenario', 'tailwind-2mps', '--seconds', '2']).withTimeout(60000) | |
| 147 | expect(short.exitCode).toBe(1) | |
| 148 | expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false) | |
| 149 | }, 90000) | |
| @@ -0,0 +1,89 @@ | ||
| 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: actual plant wind, raw sensors, estimator, mission, | |
| 6 | // authority and board loop. These observations do not qualify a measured airframe. | |
| 7 | test('the named headwind case observes actual world exposure and completes the original finite route', () => { | |
| 8 | const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true }) | |
| 9 | const { result, report } = scenario.run() | |
| 10 | expect(report.status).toBe('completed') | |
| 11 | expect(report.demonstrationMatched).toBe(true) | |
| 12 | expect(report.windEvidence).toMatchObject({ frame: 'LOCAL_NED', windMps: [-2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route', firstAppliedAtMs: 1000, mismatchSteps: 0, worldMatched: true, outcomeMatched: true }) | |
| 13 | expect(report.windEvidence!.appliedSteps).toBeGreaterThan(100) | |
| 14 | expect(report.windEvidence!.lastAppliedAtMs).toBe(result.trace.rows.at(-1)!.observation.nowMs) | |
| 15 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 16 | expect(report.landingContact).toBe(true) | |
| 17 | expect(report.boardNeutral).toBe(true) | |
| 18 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 19 | expect(result.trace.rows.at(-1)!.result.reason).toBe('landed') | |
| 20 | }) | |
| 21 | ||
| 22 | test('the named tailwind completes captured transitions and replay without extending its reserve or deadline', () => { | |
| 23 | const { result, report } = createVtolSimulationCase('tailwind-2mps', { electrical: true }).run() | |
| 24 | expect(report.status).toBe('completed') | |
| 25 | expect(report.demonstrationMatched).toBe(true) | |
| 26 | expect(report.windEvidence).toMatchObject({ windMps: [2, 0, 0], beginsAtMs: 1000, worldMatched: true, outcomeMatched: true }) | |
| 27 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 28 | expect(report.transitionEvidence.map(row => row.phase)).toEqual(['hover', 'cruise', 'hover']) | |
| 29 | expect(report.landingContact).toBe(true) | |
| 30 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 31 | expect(report.board.expiresAtMs).toBeNull() | |
| 32 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 33 | }) | |
| 34 | ||
| 35 | test('the east crosswind limit records the actual back-envelope stop without certifying hover or landing', () => { | |
| 36 | const { result, report } = createVtolSimulationCase('east-crosswind-limit-2mps', { electrical: true }).run() | |
| 37 | expect(report.status).toBe('blocked') | |
| 38 | expect(report.fault).toBe('vtol-transition-envelope') | |
| 39 | expect(report.mission.taskId).toBe('back') | |
| 40 | expect(report.mission.state).toBe('blocked') | |
| 41 | expect(report.demonstrationMatched).toBe(true) | |
| 42 | expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true, mismatchSteps: 0 }) | |
| 43 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front']) | |
| 44 | expect(report.completeTransitions).toBe(false) | |
| 45 | expect(report.landingContact).toBe(false) | |
| 46 | expect(report.boardNeutral).toBe(true) | |
| 47 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 48 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 49 | }) | |
| 50 | ||
| 51 | test('the west crosswind limit keeps its own signed world vector and blocked outcome', () => { | |
| 52 | const { report } = createVtolSimulationCase('west-crosswind-limit-2mps').run() | |
| 53 | expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true }) | |
| 54 | expect(report.status).toBe('blocked') | |
| 55 | expect(report.fault).toBe('vtol-transition-envelope') | |
| 56 | expect(report.demonstrationMatched).toBe(true) | |
| 57 | expect(report.landingContact).toBe(false) | |
| 58 | expect(report.completeTransitions).toBe(false) | |
| 59 | expect(report.boardNeutral).toBe(true) | |
| 60 | }) | |
| 61 | ||
| 62 | for (const mutation of ['absent', 'early', 'late', 'opposite'] as const) test(`${mutation} world wind cannot qualify under the declared headwind name`, () => { | |
| 63 | const scenario = createVtolSimulationCase('headwind-2mps') | |
| 64 | scenario.profile.faults = (_name, atMs) => ({ windMps: mutation === 'absent' ? [0, 0, 0] : atMs >= (mutation === 'early' ? 800 : mutation === 'late' ? 1200 : 1000) ? [mutation === 'opposite' ? 2 : -2, 0, 0] : [0, 0, 0] }) | |
| 65 | const { report } = scenario.run() | |
| 66 | expect(report.windEvidence!.worldMatched).toBe(false) | |
| 67 | expect(report.windEvidence!.mismatchSteps).toBeGreaterThan(0) | |
| 68 | expect(report.demonstrationMatched).toBe(false) | |
| 69 | expect(report.boardNeutral).toBe(true) | |
| 70 | }) | |
| 71 | ||
| 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') | |
| 74 | const { report } = createVtolSimulationCase('tailwind-2mps', { seconds: 2 }).run() | |
| 75 | expect(report.status).toBe('duration-exhausted') | |
| 76 | expect(report.windEvidence!.worldMatched).toBe(true) | |
| 77 | expect(report.windEvidence!.outcomeMatched).toBe(false) | |
| 78 | expect(report.demonstrationMatched).toBe(false) | |
| 79 | expect(report.landingContact).toBe(false) | |
| 80 | }) | |
| 81 | ||
| 82 | test('each wind run owns its declaration, exposure and mutable vehicle session', () => { | |
| 83 | const scenario = createVtolSimulationCase('headwind-2mps', { electrical: true }) | |
| 84 | const first = scenario.run().report, original = structuredClone(first) | |
| 85 | first.windEvidence!.windMps[0] = 99 | |
| 86 | first.windEvidence!.appliedSteps = 0 | |
| 87 | scenario.wind!.windMps[0] = 999 | |
| 88 | expect(scenario.run().report).toEqual(original) | |
| 89 | }) | |
| @@ -4,7 +4,7 @@ import { createVtolSimulationCase } from '../../resources/functions/drone/vehicl | ||
| 4 | 4 | |
| 5 | 5 | test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => { |
| 6 | 6 | const definition = getMissionWorkspaceDefinition('vtol') |
| 7 | expect(definition.scenarios).toHaveLength(14) | |
| 7 | expect(definition.scenarios).toHaveLength(18) | |
| 8 | 8 | const run = runMissionWorkspace('vtol') |
| 9 | 9 | expect(run.status).toBe('completed') |
| 10 | 10 | expect(run.physicalActuationEnabled).toBe(false) |
| @@ -70,6 +70,24 @@ test('VTOL choices reject irrelevant phases and do not apply electrical flags to | ||
| 70 | 70 | expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow() |
| 71 | 71 | }) |
| 72 | 72 | |
| 73 | test('the workspace retains planned wind separately from observed exposure and a blocked crosswind recording', () => { | |
| 74 | const definition = getMissionWorkspaceDefinition('vtol', 'tailwind-2mps', { electrical: true }) | |
| 75 | if (!('vtol' in definition)) throw new Error('Expected the VTOL definition') | |
| 76 | expect(definition.vtol!.wind).toEqual({ frame: 'LOCAL_NED', windMps: [2, 0, 0], beginsAtMs: 1000, expectedOutcome: 'completed-route' }) | |
| 77 | expect(definition.vtol!.phases).toEqual([]) | |
| 78 | const run = runMissionWorkspace('vtol', 'west-crosswind-limit-2mps', { electrical: true }) | |
| 79 | if (!('vtol' in run)) throw new Error('Expected the VTOL recording') | |
| 80 | expect(run.status).toBe('blocked') | |
| 81 | expect(run.fault).toBe('vtol-transition-envelope') | |
| 82 | expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, -2, 0], expectedOutcome: 'envelope-stop', worldMatched: true, outcomeMatched: true }) | |
| 83 | expect(run.vtol!.demonstrationMatched).toBe(true) | |
| 84 | expect(run.vtol!.landingContact).toBe(false) | |
| 85 | expect(run.rows.at(-1)!.taskId).toBe('back') | |
| 86 | expect(run.rows.at(-1)!.state).toBe('blocked') | |
| 87 | expect(run.boardNeutral).toBe(true) | |
| 88 | expect(run.physicalActuationEnabled).toBe(false) | |
| 89 | }) | |
| 90 | ||
| 73 | 91 | test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => { |
| 74 | 92 | const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true }) |
| 75 | 93 | const first = selected.run(), second = selected.run() |