also looking at this
fix: stabilize VTOL lateral flow and hover control
#91
17 files
+556
-39
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -172,6 +172,10 @@ jobs: | ||
| 172 | 172 | working-directory: ${{ runner.temp }} |
| 173 | 173 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical |
| 174 | 174 | working-directory: ${{ runner.temp }} |
| 175 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical | |
| 176 | working-directory: ${{ runner.temp }} | |
| 177 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical | |
| 178 | working-directory: ${{ runner.temp }} | |
| 175 | 179 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 176 | 180 | working-directory: ${{ runner.temp }} |
| 177 | 181 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -84,11 +84,13 @@ 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 18 named | |
| 87 | and measured hybrid energy qualification remain open. The VTOL workspace selects the same 20 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 | The 2 m/s headwind and tailwind cases complete their routes. Separate east/west crosswind plans | |
| 90 | complete with explicitly wind-facing back-hover headings. Their original fixed north-facing limit | |
| 91 | cases retain blocked envelope outcomes, without confirmed hover or landing. Reports and playback | |
| 92 | show the requested heading and distinguish declared world wind from observed exposure checks; | |
| 93 | controller inputs contain no wind oracle. These are bounded fictional plans, not arbitrary-wind support. | |
| 92 | 94 | |
| 93 | 95 | ## Simulated carried load |
| 94 | 96 | |
| @@ -55,19 +55,22 @@ restart forward-flight braking during a reverse position return. | ||
| 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. Four named cases are shared by | |
| 58 | original budgets using the raw sensor/estimator/controller runtime. Six named cases are shared by | |
| 59 | 59 | Buddy, the copied standalone CLI and the mission workspace: |
| 60 | 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 | | |
| 61 | | Scenario | World wind N/E/D, m/s | Back-hover heading from north | Required recorded outcome | | |
| 62 | | --- | --- | --- | --- | | |
| 63 | | `headwind-2mps` | -2, 0, 0 | 0 rad, north | Completed route and contact | | |
| 64 | | `tailwind-2mps` | 2, 0, 0 | 0 rad, north | Completed route and contact | | |
| 65 | | `east-crosswind-limit-2mps` | 0, 2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim | | |
| 66 | | `west-crosswind-limit-2mps` | 0, -2, 0 | 0 rad, north | Blocked back-transition envelope, no hover/landing claim | | |
| 67 | | `east-crosswind-2mps` | 0, 2, 0 | -pi/2 rad, west | Completed route and contact | | |
| 68 | | `west-crosswind-2mps` | 0, -2, 0 | +pi/2 rad, east | Completed route and contact | | |
| 67 | 69 | |
| 68 | 70 | ```sh |
| 69 | 71 | ./buddy vtol:simulate --scenario tailwind-2mps --electrical |
| 70 | 72 | ./buddy vtol:simulate --scenario east-crosswind-limit-2mps --electrical |
| 73 | ./buddy vtol:simulate --scenario east-crosswind-2mps --electrical | |
| 71 | 74 | ./storage/runtime-build/cli/drone vtol:simulate --scenario headwind-2mps --electrical |
| 72 | 75 | ``` |
| 73 | 76 | |
| @@ -89,12 +92,30 @@ profile.faults = (_scenario, atMs) => ({ windMps: atMs >= 1000 ? [2, 0, 0] : [0, | ||
| 89 | 92 | const result = runVehicleSimulation(profile, { seconds: 160 }) |
| 90 | 93 | ``` |
| 91 | 94 | |
| 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 | |
| 96 | [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or physical | |
| 97 | flight acceptance is claimed. | |
| 95 | The original fixed north-facing east/west crosswind plans still reach the back-transition | |
| 96 | envelope guard without confirmed hover or landing. During the east case's reverse return at 38,000 ms, | |
| 97 | the modeled aerodynamic yaw moment is approximately -0.729 Nm while the fully allocated opposing | |
| 98 | rotor yaw moment is only +0.416 Nm, with the rudder at its +0.4 rad bound. The qualified airflow capture | |
| 99 | is 60 ms old, attitude 20 ms and position 100 ms, within the unchanged source guards. This is an | |
| 100 | observed authority shortage, not evidence of a mismatched capture frame. | |
| 101 | ||
| 102 | A separate level-hover calculation with the same 2 m/s crosswind and full opposing rudder leaves | |
| 103 | -0.736 Nm of aerodynamic yaw moment. Lift rotors preserving the fictional 24.517 N weight support | |
| 104 | can oppose at most +0.470 Nm. This worked example does not establish a full steady-flight feasibility | |
| 105 | envelope, but changing gains cannot supply the missing torque in that condition. Neutral outputs | |
| 106 | at the original airborne failure do not establish a safe landing. | |
| 107 | ||
| 108 | The two wind-facing plans change only the declared back-hover heading and plan identity. They use | |
| 109 | the existing `vtol-mode.headingRad` mission contract and unchanged controller, model, reserve and | |
| 110 | finite budgets. The front heading remains north. These plans complete the source-qualified modeled hover | |
| 111 | confirmation and contact route in the declared wind. Their extra `backHeadingRad` declaration and | |
| 112 | `backHeadingMatched` check bind qualification to the actual recorded plan; a completed route under | |
| 113 | a different heading remains unmatched. No automatic heading substitution or retry is performed. | |
| 114 | The original `*-limit-2mps` cases keep their prior plans and blocked outcomes. | |
| 115 | ||
| 116 | Wider wind directions, onset phases, a general feasibility planner and full-angle model qualification | |
| 117 | remain in [#87](https://github.com/stacksjs/drone/issues/87). No arbitrary-wind, measured-airframe or | |
| 118 | physical flight acceptance is claimed. | |
| 98 | 119 | |
| 99 | 120 | The hover demonstration commands the pusher and three surfaces to zero. Calibrated modeled raw |
| 100 | 121 | IMU/GNSS samples feed the owned estimator. The controller receives estimated pose, a current qualified |
| @@ -400,7 +421,7 @@ it does not model a seized motor's electrical current. | ||
| 400 | 421 | |
| 401 | 422 | ## Mission workspace |
| 402 | 423 | |
| 403 | Open `/missions`, choose `vtol` and select one of the same 18 named cases as `vtol:simulate`. | |
| 424 | Open `/missions`, choose `vtol` and select one of the same 20 named cases as `vtol:simulate`. | |
| 404 | 425 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection |
| 405 | 426 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, |
| 406 | 427 | scenario, fault phase or electrical selection clears the previous recording. |
| @@ -411,9 +432,11 @@ position, operator actions, request-bound controller observations and their capt | ||
| 411 | 432 | table retains changes and confirmations. The last mode observation includes its age at the cursor; |
| 412 | 433 | it does not assert a continuously measured current phase. |
| 413 | 434 | |
| 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. | |
| 435 | Task playback shows each requested VTOL heading in radians from north. Wind playback shows | |
| 436 | the declared vector/onset, the actual world exposure check and the expected completed-route or | |
| 437 | envelope-stop outcome. The explicit wind-facing plans also show their back heading and recorded-plan | |
| 438 | binding. The wind is a fixture input, not a measured wind estimate. The separate fixed-heading | |
| 439 | crosswind limit recordings retain their blocked status and unsupported-envelope disclosure. | |
| 417 | 440 | |
| 418 | 441 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. |
| 419 | 442 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining |
| @@ -88,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 88 | 88 | <div><dt class="text-neutral-400">Energy reserve checked</dt><dd class="mt-1">{{ definition().budget.requiredWh.toFixed(2) }} / {{ definition().budget.usableWh.toFixed(2) }} Wh usable</dd></div> |
| 89 | 89 | </dl> |
| 90 | 90 | <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3> |
| 91 | <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div> | |
| 91 | <div class="overflow-x-auto mt-3 max-h-96"><table class="w-full text-left text-xs"><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Task</th><th scope="col" class="py-3">Goal / next</th></tr></thead><tbody><tr :for="task in definition().tasks" class="border-b border-neutral-800"><td class="pr-3 py-3 align-top"><span class="font-mono">{{ task.id }}</span><span class="block mt-1 text-neutral-400">{{ task.kind }}</span></td><td class="py-3 break-words"><span :if="task.targetM" class="font-mono">N/E/D {{ vectorLabel(task.targetM) }} m</span><span :if="task.operation" class="block font-mono text-emerald-200">{{ task.operation }} / {{ task.containerId || 'no container' }}</span><span :if="task.mode" class="block font-mono text-emerald-200">Requested mode: {{ task.mode }}</span><span :if="task.headingRad !== undefined && task.headingRad !== null" class="block font-mono text-neutral-400">Requested heading: {{ task.headingRad?.toFixed(3) }} rad from north</span><span :if="task.failureEdge" class="block mt-1 text-amber-200">Counted failure route: {{ task.failureEdge }}</span><span class="block mt-1 text-neutral-400">Next: {{ task.edges.join(' / ') || 'end' }}</span></td></tr></tbody></table></div> | |
| 92 | 92 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p> |
| 93 | 93 | </aside> |
| 94 | 94 | <div class="min-w-0"> |
| @@ -126,7 +126,8 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 126 | 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 | 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 | 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> | |
| 129 | <p :if="vtolResult()?.windEvidence?.backHeadingRad !== undefined" class="mt-2 text-neutral-400 text-xs" data-vtol-wind-heading>Back hover heading: {{ vtolResult()?.windEvidence?.backHeadingRad?.toFixed(3) }} rad from north. Plan heading check: {{ vtolResult()?.windEvidence?.backHeadingMatched ? 'matched' : 'unmatched' }}. This separately declared plan keeps the fixed north-facing limit cases unchanged.</p> | |
| 130 | <p :if="vtolResult()?.windEvidence?.expectedOutcome === 'envelope-stop'" class="mt-2 leading-relaxed text-amber-200 text-xs" data-vtol-wind-limit>This plan with a fixed north heading remains outside the supported route envelope. A matched stop does not certify hover, contact or a safe landing.</p> | |
| 130 | 131 | </div> |
| 131 | 132 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> |
| 132 | 133 | <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> |
| @@ -19,6 +19,8 @@ const windCases = { | ||
| 19 | 19 | 'tailwind-2mps': { windMps: [2, 0, 0], expectedOutcome: 'completed-route' }, |
| 20 | 20 | 'east-crosswind-limit-2mps': { windMps: [0, 2, 0], expectedOutcome: 'envelope-stop' }, |
| 21 | 21 | 'west-crosswind-limit-2mps': { windMps: [0, -2, 0], expectedOutcome: 'envelope-stop' }, |
| 22 | 'east-crosswind-2mps': { windMps: [0, 2, 0], expectedOutcome: 'completed-route', backHeadingRad: -Math.PI / 2 }, | |
| 23 | 'west-crosswind-2mps': { windMps: [0, -2, 0], expectedOutcome: 'completed-route', backHeadingRad: Math.PI / 2 }, | |
| 22 | 24 | } as const |
| 23 | 25 | const windExposure = () => ({ calmSteps: 0, appliedSteps: 0, mismatchSteps: 0, firstAppliedAtMs: null as number | null, lastAppliedAtMs: null as number | null }) |
| 24 | 26 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases), ...Object.keys(windCases)]) |
| @@ -44,7 +46,14 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 44 | 46 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) |
| 45 | 47 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined |
| 46 | 48 | const windCase = Object.hasOwn(windCases, scenario) ? windCases[scenario as keyof typeof windCases] : null |
| 47 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome } : null | |
| 49 | const backHeadingRad = windCase && 'backHeadingRad' in windCase ? windCase.backHeadingRad : null | |
| 50 | if (backHeadingRad !== null) { | |
| 51 | const back = profile.mission.tasks.find(task => task.id === 'back') | |
| 52 | if (!back || back.kind !== 'vtol-mode') throw new TypeError('Missing VTOL back heading task') | |
| 53 | back.headingRad = backHeadingRad | |
| 54 | profile.mission.id = `vtol-${scenario}` | |
| 55 | } | |
| 56 | const wind = windCase ? { frame: 'LOCAL_NED' as const, windMps: [...windCase.windMps] as SensorVector, beginsAtMs: 1000, expectedOutcome: windCase.expectedOutcome, ...(backHeadingRad === null ? {} : { backHeadingRad }) } : null | |
| 48 | 57 | let exposure = windExposure() |
| 49 | 58 | const actions = operator?.actions |
| 50 | 59 | if (operator?.recovery) { |
| @@ -157,11 +166,13 @@ export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimu | ||
| 157 | 166 | 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' |
| 158 | 167 | 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) |
| 159 | 168 | const worldMatched = !!wind && exposure.calmSteps > 0 && exposure.appliedSteps > 0 && exposure.firstAppliedAtMs === wind.beginsAtMs && exposure.lastAppliedAtMs === lastAtMs && exposure.mismatchSteps === 0 |
| 169 | const backTask = trace.plan.tasks.find(task => task.id === 'back') | |
| 170 | const backHeadingMatched = backHeadingRad === null || backTask?.kind === 'vtol-mode' && backTask.mode === 'hover' && backTask.headingRad === backHeadingRad | |
| 160 | 171 | const windOutcomeMatched = !!wind && (wind.expectedOutcome === 'completed-route' ? completedRoute : result.status === 'blocked' && result.fault === 'vtol-transition-envelope' && result.mission.taskId === 'back' && result.mission.state === 'blocked' && result.mission.targetM === null && result.mission.transition === null && confirmed(['initial-hover', 'front']) && observedFlight && !landingContact && boardNeutral) |
| 161 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 172 | const demonstrationMatched = wind ? worldMatched && windOutcomeMatched && backHeadingMatched : scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 162 | 173 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, |
| 163 | 174 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), |
| 164 | ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched } } : {}), | |
| 175 | ...(wind ? { windEvidence: { ...structuredClone(wind), ...exposure, worldMatched, outcomeMatched: windOutcomeMatched, ...(backHeadingRad === null ? {} : { backHeadingMatched }) } } : {}), | |
| 165 | 176 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } |
| 166 | 177 | return { result, report: summary } |
| 167 | 178 | } } |
| @@ -17,7 +17,7 @@ import { observeVehicleBattery } from './measurements' | ||
| 17 | 17 | import { createVtolSimulationCase, vtolSimulationPhases, 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 | export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: string | null, failureEdge?: string | null, mode?: string | null } | |
| 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 } | |
| 21 | 21 | export interface WorkspacePayload { eventId: string, taskId: string, operation: string, containerId: string | null, evidence: 'simulated-event' | 'modeled-request', issuedAtMs: number, expiresAtMs: number | null, reference: string | null } |
| 22 | 22 | export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean } |
| 23 | 23 | export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null, vtol?: { action: string | null, request: VehicleVtolTransitionCommand | null, observation: VehicleVtolTransitionObservation | null, battery: VehicleBatteryObservation | null } } |
| @@ -45,7 +45,7 @@ export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = | ||
| 45 | 45 | } |
| 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 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, | |
| 48 | return { profile, scenario, version: 2, name: p.name, planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence }, rallyM: [...plan.rallyM], budget: { worstCaseMs, requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: vector(vehicleTaskTarget(plan, t)), edges: t.kind === 'branch' ? [t.then, t.otherwise] : 'next' in t && t.next ? [t.next] : [], operation: t.kind === 'payload' ? t.operation : null, containerId: t.kind === 'payload' ? t.containerId : null, ...(profile === 'vtol' ? { failureEdge: t.kind === 'vtol-mode' ? t.onFailure ?? null : null, mode: t.kind === 'vtol-mode' ? t.mode : null, headingRad: t.kind === 'vtol-mode' ? t.headingRad : null } : {}) } satisfies WorkspaceTask)), assumptions: vtolCase ? 'Fictional lift-and-cruise model. Captured mode observations and optional battery readings are modeled evidence. No physical transition or recovery qualification.' : vehicleProfiles[profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>].assumptions, | |
| 49 | 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' } } |
| @@ -0,0 +1,69 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | import { vtolAerodynamics, vtolModel } from '../../resources/functions/drone/vehicles/vtol/plant' | |
| 4 | import { quaternionEuler } from '../../resources/functions/drone/estimation/frames' | |
| 5 | import { allocateVtolLift } from '../../resources/functions/drone/vehicles/vtol/allocation' | |
| 6 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 7 | ||
| 8 | // Existing actual-runtime seam: explicit mission heading, sustained world wind, raw | |
| 9 | // sensors, estimator, controller and independent board. No physical qualification. | |
| 10 | test('an explicitly west-facing back hover completes the east crosswind route within the original budget', () => { | |
| 11 | const selected = createVtolSimulationCase('east-crosswind-2mps', { electrical: true }) | |
| 12 | const back = selected.profile.mission.tasks.find(task => task.id === 'back') | |
| 13 | expect(back?.kind === 'vtol-mode' && back.headingRad).toBe(-Math.PI / 2) | |
| 14 | const { result, report } = selected.run() | |
| 15 | expect(report.status).toBe('completed') | |
| 16 | expect(report.demonstrationMatched).toBe(true) | |
| 17 | expect(report.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, expectedOutcome: 'completed-route', worldMatched: true, outcomeMatched: true, backHeadingMatched: true }) | |
| 18 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 19 | expect(report.transitionEvidence.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']]) | |
| 20 | const backCapture = result.path.find(row => row.atMs === report.transitionEvidence.at(-1)!.capturedAtMs)!.measurementPose! | |
| 21 | const yaw = quaternionEuler(backCapture.quaternionWxyz)[2] | |
| 22 | expect(Math.abs(Math.atan2(Math.sin(-Math.PI / 2 - yaw), Math.cos(-Math.PI / 2 - yaw)))).toBeLessThan(0.1) | |
| 23 | expect(backCapture.capturedAtMs).toBeLessThanOrEqual(report.transitionEvidence.at(-1)!.capturedAtMs) | |
| 24 | expect(result.mission.reason).toBe('landed') | |
| 25 | expect(report.landingContact).toBe(true) | |
| 26 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 27 | expect(report.board.expiresAtMs).toBeNull() | |
| 28 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 29 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 30 | }) | |
| 31 | ||
| 32 | test('an explicitly east-facing back hover completes the west crosswind route without changing its deadline', () => { | |
| 33 | const selected = createVtolSimulationCase('west-crosswind-2mps', { electrical: true }) | |
| 34 | const back = selected.profile.mission.tasks.find(task => task.id === 'back') | |
| 35 | expect(back?.kind === 'vtol-mode' && back.headingRad).toBe(Math.PI / 2) | |
| 36 | const { result, report } = selected.run() | |
| 37 | expect(report.status).toBe('completed') | |
| 38 | expect(report.windEvidence).toMatchObject({ windMps: [0, -2, 0], backHeadingRad: Math.PI / 2, worldMatched: true, outcomeMatched: true, backHeadingMatched: true }) | |
| 39 | expect(report.demonstrationMatched).toBe(true) | |
| 40 | expect(report.budget).toEqual({ worstCaseMs: 131520, requiredWh: 43.84 }) | |
| 41 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 42 | expect(report.landingContact).toBe(true) | |
| 43 | expect(report.boardNeutral).toBe(true) | |
| 44 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 45 | expect(replayVehicleMission(result.trace).snapshot()).toEqual(result.mission) | |
| 46 | }) | |
| 47 | ||
| 48 | test('a completed route under a different back heading cannot qualify the named declared plan', () => { | |
| 49 | const selected = createVtolSimulationCase('east-crosswind-2mps', { electrical: true }) | |
| 50 | const back = selected.profile.mission.tasks.find(task => task.id === 'back') | |
| 51 | if (!back || back.kind !== 'vtol-mode') throw new Error('Missing back task') | |
| 52 | back.headingRad = -1.5 | |
| 53 | const { report } = selected.run() | |
| 54 | expect(report.status).toBe('completed') | |
| 55 | expect(report.windEvidence!.worldMatched).toBe(true) | |
| 56 | expect(report.windEvidence!.backHeadingMatched).toBe(false) | |
| 57 | expect(report.demonstrationMatched).toBe(false) | |
| 58 | }) | |
| 59 | ||
| 60 | test('the retained north-facing level-hover example lacks yaw authority even at full rudder and rotor allocation', () => { | |
| 61 | const aero = vtolAerodynamics([0, -2, 0], [0, 0, 0], [0, 0, 0, 0.4, 0]) | |
| 62 | const allocation = allocateVtolLift({ thrustN: vtolModel.massKg * vtolModel.gravityMps2, torqueNm: [0, 0, -aero.torqueNm[2]] }) | |
| 63 | // Worked fictional-model example: -0.73615511 Nm with full opposing rudder, | |
| 64 | // +0.4696675 Nm available rotor yaw while preserving 24.516625 N support. | |
| 65 | expect(allocation.limited).toBe(true) | |
| 66 | expect(allocation.achieved.thrustN).toBeCloseTo(24.516625, 7) | |
| 67 | expect(aero.torqueNm[2] + allocation.achieved.torqueNm[2]).toBeCloseTo(-0.2664876105491832, 7) | |
| 68 | expect(allocation.rotorsN.every(value => value >= 0 && value <= 12)).toBe(true) | |
| 69 | }) | |
| @@ -17,6 +17,8 @@ test('CI qualifies nominal and failure full-route commands outside the checkout' | ||
| 17 | 17 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario tailwind-2mps --electrical', directory: '$' }, |
| 18 | 18 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-limit-2mps --electrical', directory: '$' }, |
| 19 | 19 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-limit-2mps --electrical', directory: '$' }, |
| 20 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario east-crosswind-2mps --electrical', directory: '$' }, | |
| 21 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario west-crosswind-2mps --electrical', directory: '$' }, | |
| 20 | 22 | ]) |
| 21 | 23 | }) |
| 22 | 24 | |
| @@ -147,3 +149,19 @@ test('native wind selectors retain actual exposure and distinct completed or env | ||
| 147 | 149 | expect(short.exitCode).toBe(1) |
| 148 | 150 | expect(JSON.parse(short.stdout).windEvidence.outcomeMatched).toBe(false) |
| 149 | 151 | }, 90000) |
| 152 | ||
| 153 | test('native wind-aligned plans retain their explicit headings, fresh hover and completed contact', async () => { | |
| 154 | for (const [scenario, heading] of [['east-crosswind-2mps', -Math.PI / 2], ['west-crosswind-2mps', Math.PI / 2]] as const) { | |
| 155 | const result = await command(['vtol:simulate', '--scenario', scenario, '--electrical']).withTimeout(60000) | |
| 156 | expect(result.exitCode).toBe(0) | |
| 157 | const report = JSON.parse(result.stdout) | |
| 158 | expect(report.status).toBe('completed') | |
| 159 | expect(report.demonstrationMatched).toBe(true) | |
| 160 | expect(report.windEvidence).toMatchObject({ backHeadingRad: heading, backHeadingMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 161 | expect(report.transitionEvidence.map((row: { taskId: string }) => row.taskId)).toEqual(['initial-hover', 'front', 'back']) | |
| 162 | expect(report.transitionEvidence.at(-1).eventId).toStartWith(`vtol-${scenario}:`) | |
| 163 | expect(report.landingContact).toBe(true) | |
| 164 | expect(report.boardNeutral).toBe(true) | |
| 165 | expect(report.board.physicalActuationEnabled).toBe(false) | |
| 166 | } | |
| 167 | }, 90000) | |
| @@ -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(18) | |
| 7 | expect(definition.scenarios).toHaveLength(20) | |
| 8 | 8 | const run = runMissionWorkspace('vtol') |
| 9 | 9 | expect(run.status).toBe('completed') |
| 10 | 10 | expect(run.physicalActuationEnabled).toBe(false) |
| @@ -100,3 +100,16 @@ test('the shared VTOL case factory runs isolated sessions and retains the CLI re | ||
| 100 | 100 | first.result.path[0]!.actuators[0] = 999 |
| 101 | 101 | expect(second.result.path[0]!.actuators[0]).not.toBe(999) |
| 102 | 102 | }, 60000) |
| 103 | ||
| 104 | test('the workspace exposes the explicit crosswind back heading with its completed recording', () => { | |
| 105 | const definition = getMissionWorkspaceDefinition('vtol', 'east-crosswind-2mps', { electrical: true }) | |
| 106 | expect(definition.planId).toBe('vtol-east-crosswind-2mps') | |
| 107 | expect(definition.tasks.find(task => task.id === 'back')?.headingRad).toBe(-Math.PI / 2) | |
| 108 | const run = runMissionWorkspace('vtol', 'east-crosswind-2mps', { electrical: true }) | |
| 109 | if (!('vtol' in run)) throw new Error('Expected VTOL recording') | |
| 110 | expect(run.status).toBe('completed') | |
| 111 | expect(run.vtol!.windEvidence).toMatchObject({ windMps: [0, 2, 0], backHeadingRad: -Math.PI / 2, backHeadingMatched: true, worldMatched: true, outcomeMatched: true }) | |
| 112 | expect(run.vtol!.landingContact).toBe(true) | |
| 113 | expect(run.rows.at(-1)!.state).toBe('completed') | |
| 114 | expect(run.boardNeutral).toBe(true) | |
| 115 | }) | |