also looking at this
test: qualify execution visibility across native renewal
#85
19 files
+755
-78
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.
| @@ -61,7 +61,7 @@ See [public-safety collection and remaining scope](docs/public-safety.md). Physi | ||
| 61 | 61 | |
| 62 | 62 | ## Owned vehicle missions |
| 63 | 63 | |
| 64 | Open `/missions` to select quadcopter, fixed-wing, ground, surface or subsea simulation. One workspace shows | |
| 64 | Open `/missions` to select quadcopter, fixed-wing, VTOL, ground, surface or subsea simulation. One workspace shows | |
| 65 | 65 | plans, energy budgets, estimated routes, decision playback, payload requests and correlated modeled |
| 66 | 66 | acknowledgements. Host loss shows the last host snapshot separately from independent board expiry. |
| 67 | 67 | |
| @@ -83,8 +83,9 @@ includes a bounded modeled front/cruise/back experiment with stable-phase pause | ||
| 83 | 83 | mode observations. Named scenarios expose phase-bound sensor/manual/host loss, cruise pause/resume, |
| 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 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes, | |
| 87 | measured hybrid energy qualification and a selectable VTOL mission workspace remain open. | |
| 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 | |
| 88 | cases, fault phases and optional electrical model, with captured mode and battery playback. | |
| 88 | 89 | |
| 89 | 90 | ## Simulated carried load |
| 90 | 91 | |
| @@ -51,7 +51,7 @@ profile remains neutral-only. The owned [sensor pipeline](sensors.md), | ||
| 51 | 51 | journals verified metadata and prepares owned image candidates. Physical board |
| 52 | 52 | installation, persistent boot selection and recovery require separate qualification. |
| 53 | 53 | The [library API](api.md) describes behavior extensions. The |
| 54 | [shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, ground, | |
| 54 | [shared vehicle contract](vehicles.md) covers quadcopter, fixed-wing, VTOL, ground, | |
| 55 | 55 | surface and subsea profiles. [Optional external drivers](drivers.md) have |
| 56 | 56 | [acknowledged protocol services](driver-protocols.md); scripted peers are distinct |
| 57 | 57 | from [firmware SITL](sitl.md) and physical testing. |
| @@ -60,8 +60,9 @@ The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only an | ||
| 60 | 60 | named fault/operator scenarios, and an |
| 61 | 61 | experimental TypeScript library profile for modeled front transition, cruise, back transition and |
| 62 | 62 | vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery |
| 63 | routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission | |
| 64 | workspace remain open. | |
| 63 | routes. The common `/missions` workspace selects the same named cases and displays captured mode | |
| 64 | observations and optional battery readings. Complete recovery/abort envelopes and measured hybrid | |
| 65 | energy qualification remain open. | |
| 65 | 66 | |
| 66 | 67 | ## Explore a workflow |
| 67 | 68 | |
| @@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 16 | 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | Named parameter read/write with bounded readback in the SITL adapter; no log/FTP services, full-table synchronization or reboot persistence qualification | [#9](https://github.com/stacksjs/drone/issues/9) | |
| 17 | 17 | | Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | Bounded SITL waypoint upload/download and acknowledged item selection/resume; no arming, automatic flight-mode selection or owned-plan translation | [#10](https://github.com/stacksjs/drone/issues/10) | |
| 18 | 18 | | Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) | |
| 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and full-route VTOL commands with named phase-bound source/manual/host failures, pause/resume, declared abort/recovery, open-circuit timeout and endurance cases; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and full-route VTOL commands with named phase-bound source/manual/host failures, pause/resume, declared abort/recovery, open-circuit timeout and endurance cases, plus shared workspace selection and captured mode/battery playback; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 20 | 20 | | Rovers, mowing, logistics and materials handling | Owned differential traction/braking, encoder/GNSS odometry, guarded mowing coverage and scoped material-drop simulation; hardware and broader chassis/implements pending | Not exposed | [#13](https://github.com/stacksjs/drone/issues/13) | |
| 21 | 21 | | Surface vessels, bathymetry, water sampling and harbours | Owned planar hull/current/wind, collision inhibition, corrected single-beam export, scoped sample ACK and energy-return simulation; physical survey/sampling and broader hulls pending | Not exposed | [#14](https://github.com/stacksjs/drone/issues/14) | |
| 22 | 22 | | Subsea ROV, depth hold, tether and leak handling | Owned eight-thruster 6DOF physics, pressure/DVL local estimation, depth hold, tether/leak supervision and scoped synthetic camera/sonar evidence; hardware, full hydrodynamics and physical inspection pending | Not exposed | [#15](https://github.com/stacksjs/drone/issues/15) | |
| @@ -4,8 +4,8 @@ The owned lift-and-cruise model can execute a **hover-only** mission through the | ||
| 4 | 4 | estimator, mission, authority and independent output-lease runtime. It takes off vertically, reaches |
| 5 | 5 | a nearby hover waypoint and lands against fresh modeled contact evidence. Wing transitions and |
| 6 | 6 | cruise control are not implemented in this hover profile. A separate experimental library controller |
| 7 | is described below. This development profile is separate from the selectable | |
| 8 | profiles in the `/missions` workspace. | |
| 7 | is described below. The `/missions` workspace selects the full-route experiment and its named | |
| 8 | cases; the hover-only command remains a separate experiment. | |
| 9 | 9 | |
| 10 | 10 | ```sh |
| 11 | 11 | ./buddy vtol:hover |
| @@ -75,7 +75,7 @@ is a separate development experiment on the same owned runtime. Its nominal miss | ||
| 75 | 75 | hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back |
| 76 | 76 | transition, returns to the back request's capture point, observes settled hover, and lands vertically. |
| 77 | 77 | A turning leg also has an actual completed model campaign. The native `vtol:simulate` command runs |
| 78 | the nominal route and named cases below. This profile is not a selectable workspace profile. | |
| 78 | the nominal route and named cases below. The same cases are selectable in `/missions`. | |
| 79 | 79 | |
| 80 | 80 | ```sh |
| 81 | 81 | ./buddy vtol:simulate |
| @@ -218,7 +218,7 @@ Task execution time stops during pause. Global endurance and the declared energy | ||
| 218 | 218 | so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal. |
| 219 | 219 | Operator actions never extend the original planning allowance or rearm revoked authority. The |
| 220 | 220 | named presets above expose specific schedules through `vtol:simulate`. Custom schedules remain |
| 221 | library inputs, and the common workspace does not select this VTOL profile. | |
| 221 | library inputs. The common workspace selects the same named presets without adding live mission control. | |
| 222 | 222 | |
| 223 | 223 | ## Declared transition recovery routes |
| 224 | 224 | |
| @@ -336,12 +336,40 @@ ratio, voltage-dependent thrust, cell temperature/degradation, current-sensor ca | ||
| 336 | 336 | capacity remain unmodeled or unqualified. The open-circuit fixture removes pusher demand and thrust; |
| 337 | 337 | it does not model a seized motor's electrical current. |
| 338 | 338 | |
| 339 | ## Mission workspace | |
| 340 | ||
| 341 | Open `/missions`, choose `vtol` and select one of the same 14 named cases as `vtol:simulate`. | |
| 342 | The six source/manual/host faults also expose `front`, `cruise`, `back` and `landing` injection | |
| 343 | phases. The optional electrical checkbox selects the fictional 100 Wh model. Changing the vehicle, | |
| 344 | scenario, fault phase or electrical selection clears the previous recording. | |
| 345 | ||
| 346 | The workspace shows the validated worst-case time and energy allowance, requested modes and counted | |
| 347 | `onFailure` routes before running. Record a case, then move the time cursor to inspect estimated | |
| 348 | position, operator actions, request-bound controller observations and their capture epochs. The mode | |
| 349 | table retains changes and confirmations. The last mode observation includes its age at the cursor; | |
| 350 | it does not assert a continuously measured current phase. | |
| 351 | ||
| 352 | With the electrical model enabled, each row uses the runtime's already qualified sensor capture. | |
| 353 | Used chemical energy, source/load power, current, terminal voltage and the age-charged remaining | |
| 354 | lower bound are displayed with capture and observation times. A missing capture stays missing. | |
| 355 | Host loss freezes host readings while the independent modeled board expires its output lease. | |
| 356 | ||
| 357 | The final outcome and mission state retain their actual meanings. A matched abort case remains | |
| 358 | `blocked` / `aborted`, and the held recovery remains `blocked` by global endurance without a landing | |
| 359 | contact. A named outcome match only checks the modeled demonstration. It does not authorize flight. | |
| 360 | ||
| 361 | Both callers construct the case through `createVtolSimulationCase()` in | |
| 362 | `resources/functions/drone/vehicles/vtol/simulation.ts`. Its `run()` returns the recording and the | |
| 363 | same condensed report used by `runVtolSimulation()`. The presentation adapter exposes estimated | |
| 364 | positions and qualified captures, with no plant-position truth shortcut. Playback does not mutate | |
| 365 | the recorded mission, persist a checkpoint or add a physical control endpoint. | |
| 366 | ||
| 339 | 367 | ## Remaining implementation |
| 340 | 368 | |
| 341 | 369 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal |
| 342 | 370 | controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort |
| 343 | envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior and | |
| 344 | the common mission workspace still require implementation and qualification. Wind campaigns and | |
| 371 | envelopes, fresh-process restoration and identified hybrid power/voltage/cell behavior still | |
| 372 | require implementation and qualification. Wind campaigns and | |
| 345 | 373 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, |
| 346 | 374 | helicopters and other airframe families remain separate work. Native board control, measured airframe |
| 347 | 375 | identification, HIL and physical flight require their own evidence. |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Owned vehicle runtime |
| 2 | 2 | |
| 3 | The fixed-wing, ground, surface and subsea profiles execute through one owned raw sensor, estimator, | |
| 3 | The fixed-wing, VTOL, ground, surface and subsea profiles execute through one owned raw sensor, estimator, | |
| 4 | 4 | mission, authority and independent modeled board loop. The existing v1 quadcopter mission remains |
| 5 | 5 | compatible. The new v2 graph explicitly declares a vehicle, arrival speed and settling conditions, |
| 6 | 6 | contact-gated land/stop, scoped payload commands and longest-path energy/time budgets. |
| @@ -65,8 +65,8 @@ covers the hover-only command and an experimental lift-and-cruise library profil | ||
| 65 | 65 | `vtol:simulate` command for nominal and named fault/operator scenarios. The latter |
| 66 | 66 | executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and |
| 67 | 67 | resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort |
| 68 | envelopes, measured hybrid energy qualification, | |
| 69 | a VTOL selection in the common workspace remain open. Helicopter, | |
| 68 | envelopes and measured hybrid energy qualification remain open. The common workspace now includes | |
| 69 | the same VTOL cases, phase selection, counted recovery edges and captured mode/battery playback. Helicopter, | |
| 70 | 70 | soaring, blimp and other airframe families remain explicit roadmap work. |
| 71 | 71 | |
| 72 | 72 | Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md), |
| @@ -3,19 +3,36 @@ 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') | |
| 7 | const electrical = state(false) | |
| 6 | 8 | const result = state<MissionWorkspaceRecording | null>(null) |
| 7 | const recording = derived(() => result()?.definition.profile === profile() && result()?.definition.scenario === scenario() ? result() : null) | |
| 8 | 9 | const offset = state(600) |
| 9 | 10 | const busy = state(false) |
| 10 | 11 | const error = state('') |
| 11 | 12 | const definition = derived(() => { |
| 12 | 13 | const base = getMissionWorkspaceDefinition(profile(), 'nominal') |
| 13 | return base.scenarios.includes(scenario()) ? getMissionWorkspaceDefinition(profile(), scenario()) : base | |
| 14 | const selected = base.scenarios.includes(scenario()) ? scenario() : 'nominal' | |
| 15 | if (profile() !== 'vtol') return getMissionWorkspaceDefinition(profile(), selected) | |
| 16 | const initial = getMissionWorkspaceDefinition('vtol', selected, { electrical: electrical() }) | |
| 17 | return 'vtol' in initial && initial.vtol?.phase ? getMissionWorkspaceDefinition('vtol', selected, { phase: phase(), electrical: electrical() }) : initial | |
| 14 | 18 | }) |
| 19 | const vtolDefinition = derived(() => { const d = definition(); return 'vtol' in d ? d.vtol ?? null : null }) | |
| 20 | const recording = derived(() => { | |
| 21 | const r = result(), d = definition() | |
| 22 | const options = r && 'vtol' in r.definition ? r.definition.vtol : null | |
| 23 | return r && r.definition.profile === d.profile && r.definition.scenario === d.scenario && (options?.phase ?? null) === (vtolDefinition()?.phase ?? null) && (options?.electrical ?? false) === (vtolDefinition()?.electrical ?? false) ? r : null | |
| 24 | }) | |
| 25 | const vtolResult = derived(() => { const r = recording(); return r && 'vtol' in r ? r.vtol ?? null : null }) | |
| 15 | 26 | const validTime = derived(() => !recording() || Number.isFinite(offset()) && Number.isInteger(offset()) && offset() >= recording()!.minimumTimeMs && offset() <= recording()!.maximumTimeMs && (offset() - recording()!.minimumTimeMs) % 20 === 0) |
| 16 | 27 | const playbackTime = derived(() => validTime() ? offset() : recording()?.minimumTimeMs || 600) |
| 17 | 28 | const rows = derived(() => recording()?.rows.filter(row => row.atMs <= playbackTime()) ?? []) |
| 18 | 29 | const current = derived(() => rows().at(-1) ?? null) |
| 30 | const modeObservation = derived(() => rows().findLast(row => row.vtol?.observation)?.vtol?.observation ?? null) | |
| 31 | const modeEvents = derived(() => rows().filter((row, index, all) => { | |
| 32 | const current = row.vtol?.observation, previous = all[index - 1]?.vtol?.observation | |
| 33 | return current && (!previous || current.eventId !== previous.eventId || current.phase !== previous.phase || current.outcome !== previous.outcome) | |
| 34 | }).map(row => ({ atMs: row.atMs, observation: row.vtol!.observation! }))) | |
| 35 | const operatorActions = derived(() => rows().filter(row => row.vtol?.action && row.vtol.action !== 'start')) | |
| 19 | 36 | const events = derived(() => recording()?.transitions.filter(row => row.atMs <= playbackTime()) ?? []) |
| 20 | 37 | const requests = derived(() => recording()?.payloads.filter(row => row.issuedAtMs <= playbackTime()) ?? []) |
| 21 | 38 | const acknowledgements = derived(() => rows().filter(row => row.payloadAck).map(row => row.payloadAck!)) |
| @@ -23,7 +40,7 @@ const inspectionFrames = derived(() => recording()?.inspection?.frames.filter(fr | ||
| 23 | 40 | const plot = derived(() => missionWorkspacePlot(definition(), rows())) |
| 24 | 41 | const computation = useTimeout(() => { |
| 25 | 42 | try { |
| 26 | const completed = runMissionWorkspace(profile(), scenario()) | |
| 43 | const completed = runMissionWorkspace(profile(), definition().scenario, vtolDefinition() ? { electrical: electrical(), ...(vtolDefinition()?.phase ? { phase: phase() } : {}) } : {}) | |
| 27 | 44 | result.set(completed) |
| 28 | 45 | // Mount the playback controls and their dynamic bounds before assigning the value. |
| 29 | 46 | void nextTick(() => { if (result() === completed) offset.set(completed.maximumTimeMs) }) |
| @@ -35,6 +52,7 @@ computation.stop() | ||
| 35 | 52 | onDestroy(() => computation.stop()) |
| 36 | 53 | effect(() => { |
| 37 | 54 | const selectedProfile = profile(), selectedScenario = scenario() |
| 55 | phase(); electrical() | |
| 38 | 56 | computation.stop(); busy.set(false); result.set(null); error.set(''); offset.set(600) |
| 39 | 57 | if (!getMissionWorkspaceDefinition(selectedProfile, 'nominal').scenarios.includes(selectedScenario)) scenario.set('nominal') |
| 40 | 58 | }) |
| @@ -50,6 +68,8 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 50 | 68 | <div class="flex flex-wrap gap-4 items-end mt-8"> |
| 51 | 69 | <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> |
| 52 | 70 | <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> | |
| 72 | <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> | |
| 53 | 73 | <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> |
| 54 | 74 | <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> |
| 55 | 75 | </div> |
| @@ -68,7 +88,7 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 68 | 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> |
| 69 | 89 | </dl> |
| 70 | 90 | <h3 class="mt-7 font-semibold text-sm">Tasks and graph edges</h3> |
| 71 | <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 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.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> | |
| 72 | 92 | <p class="mt-4 leading-relaxed text-neutral-400 text-xs">{{ definition().assumptions }}</p> |
| 73 | 93 | </aside> |
| 74 | 94 | <div class="min-w-0"> |
| @@ -97,6 +117,30 @@ function vectorLabel(value: number[] | null | undefined) { return value ? value. | ||
| 97 | 117 | <div class="flex flex-wrap gap-3 items-end mt-3"><div><label for="mission-time-number" class="block mb-1 text-neutral-400 text-xs">Recorded time / ms</label><input id="mission-time-number" type="number" :min="recording()?.minimumTimeMs || 600" :max="recording()?.maximumTimeMs || 600" step="20" x-model.number="offset" class="px-3 py-2 w-36 bg-neutral-900 border border-neutral-600 rounded focus-visible:outline-emerald-200"></div><button type="button" @click="offset.set(recording()?.minimumTimeMs || 600)" class="px-3 py-2 text-sm border border-neutral-600 rounded focus-visible:outline-emerald-200">Beginning</button><button type="button" @click="offset.set(recording()?.maximumTimeMs || 600)" class="px-3 py-2 text-sm border border-neutral-600 rounded focus-visible:outline-emerald-200">End of recording</button></div> |
| 98 | 118 | <p :if="!validTime()" role="alert" class="mt-3 text-amber-200 text-sm">Enter a recorded time from {{ recording()?.minimumTimeMs }} to {{ recording()?.maximumTimeMs }} ms in 20 ms steps. Showing the beginning until the time is valid.</p> |
| 99 | 119 | <p class="mt-5 break-words font-mono text-neutral-400 text-xs">Estimated N/E/D: {{ vectorLabel(current()?.positionM) }} m</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Estimated velocity N/E/D: {{ vectorLabel(current()?.velocityMps) }} m/s</p><p class="mt-2 break-words font-mono text-neutral-400 text-xs">Target N/E/D: {{ vectorLabel(current()?.targetM) }}</p> |
| 120 | <section :if="profile() === 'vtol'" aria-labelledby="vtol-evidence-title" class="mt-7 pt-5 border-neutral-700 border-t"> | |
| 121 | <h3 id="vtol-evidence-title" class="font-semibold text-sm">Recorded VTOL evidence</h3> | |
| 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 | <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 | <p :if="modeObservation()" class="mt-4 break-words font-mono text-sm" data-vtol-mode>{{ modeObservation()?.phase }} / {{ modeObservation()?.outcome }} / {{ modeObservation()?.taskId }}</p> | |
| 125 | <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 | <p :if="!modeObservation()" class="mt-3 text-neutral-400 text-xs">No recorded mode observation at this time.</p> | |
| 127 | <p :if="current()?.vtol?.request" class="mt-3 break-all font-mono text-neutral-400 text-xs">Mode request {{ current()?.vtol?.request?.mode }} / {{ current()?.vtol?.request?.eventId }} / expires {{ current()?.vtol?.request?.expiresAtMs }} ms</p> | |
| 128 | <div :if="vtolDefinition()?.electrical" class="mt-5"> | |
| 129 | <h4 class="font-semibold text-sm">Captured battery reading</h4> | |
| 130 | <dl :if="current()?.vtol?.battery" class="grid gap-4 grid-cols-2 mt-3 text-xs" data-vtol-battery> | |
| 131 | <div><dt class="text-neutral-400">Chemical energy used</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.energyUsedWh.toFixed(3) }} Wh</dd></div> | |
| 132 | <div><dt class="text-neutral-400">Remaining lower bound</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.remainingWhLowerBound.toFixed(3) }} Wh / {{ current()?.vtol?.battery?.remainingPercentLowerBound.toFixed(2) }}%</dd></div> | |
| 133 | <div><dt class="text-neutral-400">Source / load</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.sourcePowerW.toFixed(2) }} / {{ current()?.vtol?.battery?.loadPowerW.toFixed(2) }} W</dd></div> | |
| 134 | <div><dt class="text-neutral-400">Terminal / current</dt><dd class="mt-1 font-mono">{{ current()?.vtol?.battery?.terminalV.toFixed(3) }} V / {{ current()?.vtol?.battery?.currentA.toFixed(3) }} A</dd></div> | |
| 135 | </dl> | |
| 136 | <p :if="current()?.vtol?.battery" class="mt-3 break-words font-mono text-neutral-400 text-xs">Capture {{ current()?.vtol?.battery?.capturedAtMs }} ms / observed {{ current()?.vtol?.battery?.observedAtMs }} ms / sequence {{ current()?.vtol?.battery?.sequence }} / cutoff {{ current()?.vtol?.battery?.cutoff ? 'latched' : 'clear' }}</p> | |
| 137 | <p :if="!current()?.vtol?.battery" class="mt-3 text-amber-200 text-xs" data-vtol-battery-missing>No qualified battery capture at this recorded time.</p> | |
| 138 | <p class="mt-3 leading-relaxed text-neutral-400 text-xs">Fictional 100 Wh pack. The remaining lower bound includes worst-case consumption since capture. Host loss freezes host readings. No measured cell or motor calibration.</p> | |
| 139 | </div> | |
| 140 | <p :if="!vtolDefinition()?.electrical" class="mt-4 text-neutral-400 text-xs">Electrical measurements are off. The mission still checks its declared planning power ceiling and reserve.</p> | |
| 141 | <div :for="row in operatorActions()" class="mt-3 break-words font-mono text-neutral-400 text-xs">Operator {{ row.vtol?.action }} at {{ row.atMs }} ms / {{ row.state }} / {{ row.taskId }}</div> | |
| 142 | <div class="overflow-x-auto mt-4"><table class="w-full text-left text-xs"><caption class="py-2 text-left text-neutral-400">Mode changes and request-bound acknowledgements</caption><thead class="text-neutral-400"><tr><th scope="col" class="pr-3 py-3">Time / ms</th><th scope="col" class="pr-3 py-3">Request task</th><th scope="col" class="py-3">Phase / outcome</th></tr></thead><tbody><tr :for="row in modeEvents()" class="border-b border-neutral-800" data-vtol-mode-event><td class="pr-3 py-3 font-mono">{{ row.atMs }}</td><td class="pr-3 py-3 break-words font-mono">{{ row.observation.taskId }}</td><td class="py-3 break-words font-mono">{{ row.observation.phase }} / {{ row.observation.outcome }}</td></tr></tbody></table></div> | |
| 143 | </section> | |
| 100 | 144 | <h3 class="mt-7 font-semibold text-sm">Payload events <span class="font-mono text-neutral-400" data-mission-payloads>{{ requests().length }}</span></h3> |
| 101 | 145 | <p :if="!requests().length" class="mt-2 text-neutral-400 text-xs">No payload request at this time.</p> |
| 102 | 146 | <div :for="payload in requests()" class="mt-3 py-3 border-neutral-800 border-t"><p class="break-words font-mono text-xs">{{ payload.operation }} / {{ payload.containerId || 'no container' }} / {{ payload.taskId }}</p><p class="mt-1 break-all text-neutral-400 text-xs">{{ payload.eventId }} / {{ payload.evidence }}</p></div> |
| @@ -23,8 +23,10 @@ const phases = [ | ||
| 23 | 23 | ] as const |
| 24 | 24 | interface SourceEpochs { atMs: number, attitudeCapturedAtMs: number | null, positionCapturedAtMs: number | null, measurementCapturedAtMs: number | null } |
| 25 | 25 | |
| 26 | /** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */ | |
| 27 | export function runVtolSimulation(scenario = 'nominal', options: { seconds?: number, electrical?: boolean, phase?: string } = {}) { | |
| 26 | export interface VtolSimulationOptions { seconds?: number, electrical?: boolean, phase?: string } | |
| 27 | ||
| 28 | /** One case construction shared by CLI reports and the recorded workspace. No session is allocated until run. */ | |
| 29 | export function createVtolSimulationCase(scenario = 'nominal', options: VtolSimulationOptions = {}) { | |
| 28 | 30 | if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario') |
| 29 | 31 | if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase') |
| 30 | 32 | const faultCase = Object.hasOwn(faults, scenario) |
| @@ -74,48 +76,60 @@ export function runVtolSimulation(scenario = 'nominal', options: { seconds?: num | ||
| 74 | 76 | } } |
| 75 | 77 | } |
| 76 | 78 | } |
| 77 | const result = runVehicleSimulation(profile, { seconds: options.seconds ?? operator?.seconds ?? 160, missionActions: actions }) | |
| 78 | const { trace, path, ...report } = result | |
| 79 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 80 | const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover')) | |
| 81 | const completeTransitions = confirmed(['initial-hover', 'front', 'back']) | |
| 82 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 83 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 84 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 85 | const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined | |
| 86 | let faultObserved = false | |
| 87 | if (phase && before?.mission.taskId === phase.taskId) { | |
| 88 | if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs | |
| 89 | else if (result.status === 'blocked' && sources.last && sources.last.atMs >= phase.atMs && sources.last.atMs <= phase.atMs + 500) { | |
| 90 | const last = sources.last | |
| 91 | const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs | |
| 92 | if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs) | |
| 93 | if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs) | |
| 94 | if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs) | |
| 95 | if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual')) | |
| 79 | const seconds = options.seconds ?? operator?.seconds ?? 160 | |
| 80 | return { profile, seconds, phase: phase?.phase ?? null, electrical: options.electrical ?? false, run() { | |
| 81 | sources.last = null | |
| 82 | const result = runVehicleSimulation(profile, { seconds, missionActions: actions }) | |
| 83 | const { trace, path, ...report } = result | |
| 84 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 85 | const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover')) | |
| 86 | const completeTransitions = confirmed(['initial-hover', 'front', 'back']) | |
| 87 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 88 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 89 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 90 | const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined | |
| 91 | // The controller callback captures epochs while the synchronous runtime runs. | |
| 92 | const capturedSources = sources.last as SourceEpochs | null | |
| 93 | let faultObserved = false | |
| 94 | if (phase && before?.mission.taskId === phase.taskId) { | |
| 95 | if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs | |
| 96 | else if (result.status === 'blocked' && capturedSources && capturedSources.atMs >= phase.atMs && capturedSources.atMs <= phase.atMs + 500) { | |
| 97 | const last = capturedSources | |
| 98 | const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs | |
| 99 | if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs) | |
| 100 | if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs) | |
| 101 | if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs) | |
| 102 | if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual')) | |
| 103 | } | |
| 96 | 104 | } |
| 97 | } | |
| 98 | const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 99 | const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : []) | |
| 100 | const tasksBeforeActions = actions?.map(action => { | |
| 101 | const previous = path.find(row => row.atMs === action.atMs - 20)?.mission | |
| 102 | return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null } | |
| 103 | }) ?? [] | |
| 104 | const observedPaused = trace.rows.some(row => row.result.state === 'paused') | |
| 105 | const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition | |
| 106 | const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition | |
| 107 | const recovery = result.mission.recovery | |
| 108 | const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover') | |
| 109 | const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed' | |
| 110 | const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral | |
| 111 | const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1 | |
| 112 | const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0 | |
| 113 | const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running') | |
| 114 | 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 | |
| 115 | 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' | |
| 116 | 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) | |
| 117 | const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 118 | return { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, | |
| 119 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: sources.last } } : {}), | |
| 120 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } | |
| 105 | const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 106 | const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : []) | |
| 107 | const tasksBeforeActions = actions?.map(action => { | |
| 108 | const previous = path.find(row => row.atMs === action.atMs - 20)?.mission | |
| 109 | return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null } | |
| 110 | }) ?? [] | |
| 111 | const observedPaused = trace.rows.some(row => row.result.state === 'paused') | |
| 112 | const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition | |
| 113 | const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition | |
| 114 | const recovery = result.mission.recovery | |
| 115 | const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover') | |
| 116 | const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed' | |
| 117 | const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral | |
| 118 | const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1 | |
| 119 | const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0 | |
| 120 | const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running') | |
| 121 | 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 | 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 | 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 | |
| 125 | const summary = { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, | |
| 126 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: capturedSources } } : {}), | |
| 127 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } | |
| 128 | return { result, report: summary } | |
| 129 | } } | |
| 130 | } | |
| 131 | ||
| 132 | /** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */ | |
| 133 | export function runVtolSimulation(scenario = 'nominal', options: VtolSimulationOptions = {}) { | |
| 134 | return createVtolSimulationCase(scenario, options).run().report | |
| 121 | 135 | } |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { SensorVector } from '../sensors/types' |
| 2 | import type { VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile } from './types' | |
| 2 | import type { VehicleBatteryObservation, VehiclePayloadAck, VehiclePayloadCommand, VehicleProfile, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from './types' | |
| 3 | 3 | import { exampleMission, missionEdges, missionTaskTarget, validateMission } from '../missions/plan' |
| 4 | 4 | import { missionScenarios, simulateMission } from '../missions/simulation' |
| 5 | 5 | import type { MissionScenario } from '../missions/simulation' |
| @@ -13,34 +13,40 @@ import { createSubseaProfile, subseaScenarios } from './subsea' | ||
| 13 | 13 | import type { SubseaScenario } from './subsea' |
| 14 | 14 | import { reduceSubseaInspection } from './subsea/inspection' |
| 15 | 15 | import { runVehicleSimulation } from './runtime' |
| 16 | export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'ground', 'surface', 'subsea'] as const | |
| 16 | import { observeVehicleBattery } from './measurements' | |
| 17 | import { createVtolSimulationCase, vtolSimulationPhases, vtolSimulationScenarios } from './vtol/simulation' | |
| 18 | export const missionWorkspaceProfiles = ['quadcopter', 'fixed-wing', 'vtol', 'ground', 'surface', 'subsea'] as const | |
| 17 | 19 | export type MissionWorkspaceProfile = typeof missionWorkspaceProfiles[number] |
| 18 | export interface WorkspaceTask { id: string, kind: string, targetM: SensorVector | null, edges: string[], operation: string | null, containerId: 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 } | |
| 19 | 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 } |
| 20 | 22 | export interface WorkspaceAck extends VehiclePayloadAck { correlated: boolean } |
| 21 | export interface WorkspaceRow { atMs: number, state: string, taskId: string, reason: string, positionM: SensorVector, velocityMps: SensorVector, targetM: SensorVector | null, payloadRequest: WorkspacePayload | null, payloadAck: WorkspaceAck | null } | |
| 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 } } | |
| 24 | export interface MissionWorkspaceOptions { phase?: string, electrical?: boolean } | |
| 22 | 25 | const vector = (input: number[] | null): SensorVector | null => input ? [...input] as SensorVector : null |
| 23 | 26 | const vehicleProfiles = { |
| 24 | 27 | 'fixed-wing': { create: createAirProfile, scenarios: airScenarios, assumptions: 'Fictional trainer, level runway and modeled aerodynamic coefficients. VTOL and physical airworthiness remain unqualified.' }, |
| 25 | 28 | ground: { create: createGroundProfile, scenarios: groundScenarios, assumptions: 'Modeled tire traction, people/range coverage and guarded implements. No physical mowing or materials handling.' }, |
| 26 | 29 | surface: { create: (scenario: string) => createSurfaceProfile(scenario as SurfaceScenario), scenarios: surfaceScenarios, assumptions: 'Level water model, single vertical beam and modeled sampling bottle. No certified bathymetry or physical sampling.' }, |
| 27 | 30 | subsea: { create: (scenario: string) => createSubseaProfile(scenario as SubseaScenario), scenarios: subseaScenarios, assumptions: 'Pressure depth and bottom-locked DVL dead reckoning from a declared surface alignment. Relative yaw assumes initial north. GPS is absent underwater. Camera and sonar records are modeled fixtures.' }, |
| 28 | } satisfies Record<Exclude<MissionWorkspaceProfile, 'quadcopter'>, { create: (scenario: string) => VehicleProfile, scenarios: readonly string[], assumptions: string }> | |
| 31 | } satisfies Record<Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, { create: (scenario: string) => VehicleProfile, scenarios: readonly string[], assumptions: string }> | |
| 29 | 32 | function selected(profile: MissionWorkspaceProfile, scenario: string) { |
| 30 | 33 | if (!missionWorkspaceProfiles.includes(profile)) throw new RangeError('Unknown mission workspace profile') |
| 31 | const scenarios: readonly string[] = profile === 'quadcopter' ? missionScenarios : vehicleProfiles[profile].scenarios | |
| 34 | const scenarios: readonly string[] = profile === 'quadcopter' ? missionScenarios : profile === 'vtol' ? vtolSimulationScenarios : vehicleProfiles[profile].scenarios | |
| 32 | 35 | if (!scenarios.includes(scenario)) throw new RangeError('Unknown mission workspace scenario') |
| 33 | 36 | return scenarios |
| 34 | 37 | } |
| 35 | function vehicleProfile(profile: Exclude<MissionWorkspaceProfile, 'quadcopter'>, scenario: string) { return vehicleProfiles[profile].create(scenario) } | |
| 36 | export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal') { | |
| 38 | function vehicleProfile(profile: Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario: string) { return vehicleProfiles[profile].create(scenario) } | |
| 39 | export function getMissionWorkspaceDefinition(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal', options: MissionWorkspaceOptions = {}) { | |
| 37 | 40 | const scenarios = selected(profile, scenario) |
| 41 | if (profile !== 'vtol' && (options.phase !== undefined || options.electrical !== undefined)) throw new RangeError('VTOL options require the VTOL workspace profile') | |
| 38 | 42 | if (profile === 'quadcopter') { |
| 39 | 43 | const plan = exampleMission(), budget = validateMission(plan) |
| 40 | return { profile, scenario, version: 1, name: 'Owned quadcopter', planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence, maxDownM: 0 }, rallyM: [...plan.rallyM], budget: { worstCaseMs: budget.worstCaseMs, requiredWh: budget.requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map(t => ({ id: t.id, kind: t.kind, targetM: missionTaskTarget(plan, t), edges: missionEdges(t), operation: t.kind === 'payload' ? t.channel : null, containerId: null } satisfies WorkspaceTask)), assumptions: 'Flat origin plane and modeled quad landing gear. Camera events do not acquire images.' } | |
| 44 | return { profile, scenario, version: 1, name: 'Owned quadcopter', planId: plan.id, scenarios: [...scenarios], frame: plan.frame, origin: { ...plan.origin }, fence: { ...plan.fence, maxDownM: 0 }, rallyM: [...plan.rallyM], budget: { worstCaseMs: budget.worstCaseMs, requiredWh: budget.requiredWh, usableWh: plan.power.availableWh * (1 - plan.power.reservePercent / 100) }, tasks: plan.tasks.map((t): WorkspaceTask => ({ id: t.id, kind: t.kind, targetM: missionTaskTarget(plan, t), edges: missionEdges(t), operation: t.kind === 'payload' ? t.channel : null, containerId: null } satisfies WorkspaceTask)), assumptions: 'Flat origin plane and modeled quad landing gear. Camera events do not acquire images.' } | |
| 41 | 45 | } |
| 42 | const p = vehicleProfile(profile, scenario), { plan, worstCaseMs, requiredWh } = validateVehicleMission(p.mission) | |
| 43 | 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 => ({ 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 } satisfies WorkspaceTask)), assumptions: vehicleProfiles[profile].assumptions } | |
| 46 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null | |
| 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, | |
| 49 | ...(vtolCase ? { vtol: { phase: vtolCase.phase, phases: vtolCase.phase ? [...vtolSimulationPhases] : [], electrical: vtolCase.electrical, seconds: vtolCase.seconds } } : {}) } | |
| 44 | 50 | } |
| 45 | 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' } } |
| 46 | 52 | function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): WorkspaceAck { |
| @@ -49,8 +55,8 @@ function ackFor(ack: VehiclePayloadAck, seen: Map<string, WorkspacePayload>): Wo | ||
| 49 | 55 | return { ...structuredClone(ack), correlated } |
| 50 | 56 | } |
| 51 | 57 | /** Presentation adapter only. It does not provide policy inputs, authority or physical endpoints. */ |
| 52 | export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal') { | |
| 53 | const definition = getMissionWorkspaceDefinition(profile, scenario), requests = new Map<string, WorkspacePayload>() | |
| 58 | export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopter', scenario = 'nominal', options: MissionWorkspaceOptions = {}) { | |
| 59 | const definition = getMissionWorkspaceDefinition(profile, scenario, options), requests = new Map<string, WorkspacePayload>() | |
| 54 | 60 | if (profile === 'quadcopter') { |
| 55 | 61 | const r = simulateMission({ scenario: scenario as MissionScenario, seconds: 120 }) |
| 56 | 62 | const rows: WorkspaceRow[] = r.trace.rows.map(row => { |
| @@ -60,11 +66,15 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 60 | 66 | }) |
| 61 | 67 | return { definition, status: r.mission.state === 'completed' ? 'completed' : r.mission.state === 'running' || r.mission.state === 'paused' ? 'duration-exhausted' : 'blocked', fault: r.mission.state === 'completed' ? null : r.mission.reason, rows, transitions: structuredClone(r.transitions), payloads: [...requests.values()], authorityState: r.authority.state, snapshotsStale: false, boardNeutral: r.board.motorsN.every(n => n === 0), boardFault: r.board.fault, physicalActuationEnabled: false as const, survey: null, inspection: null, navigationEvidence: null, minimumTimeMs: rows[0]?.atMs ?? 600, maximumTimeMs: rows.at(-1)?.atMs ?? 600 } |
| 62 | 68 | } |
| 63 | const p = vehicleProfile(profile, scenario), r = runVehicleSimulation(p, { scenario, seconds: 180 }) | |
| 64 | const rows: WorkspaceRow[] = r.trace.rows.map(row => { | |
| 69 | const vtolCase = profile === 'vtol' ? createVtolSimulationCase(scenario, options) : null | |
| 70 | const vtolRun = vtolCase?.run() | |
| 71 | const p = vtolCase ? vtolCase.profile : vehicleProfile(profile as Exclude<MissionWorkspaceProfile, 'quadcopter' | 'vtol'>, scenario), r = vtolRun ? vtolRun.result : runVehicleSimulation(p, { scenario, seconds: 180 }) | |
| 72 | const rows: WorkspaceRow[] = r.trace.rows.map((row, index) => { | |
| 65 | 73 | const payloadRequest = row.result.payload ? request(row.result.payload) : null |
| 66 | 74 | if (payloadRequest) requests.set(payloadRequest.eventId, payloadRequest) |
| 67 | return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: [...row.observation.positionM], velocityMps: [...row.observation.velocityMps], targetM: vector(row.result.targetM), payloadRequest, payloadAck: row.observation.payloadAck ? ackFor(row.observation.payloadAck, requests) : null } | |
| 75 | const captured = r.path[index] | |
| 76 | return { atMs: row.observation.nowMs, state: row.result.state, taskId: row.result.taskId, reason: row.result.reason, positionM: [...row.observation.positionM], velocityMps: [...row.observation.velocityMps], targetM: vector(row.result.targetM), payloadRequest, payloadAck: row.observation.payloadAck ? ackFor(row.observation.payloadAck, requests) : null, | |
| 77 | ...(vtolCase ? { vtol: { action: row.action ?? null, request: row.result.transition ? structuredClone(row.result.transition) : null, observation: row.observation.transition ? structuredClone(row.observation.transition) : null, battery: p.batteryModel && captured?.atMs === row.observation.nowMs && captured.measurement ? observeVehicleBattery(captured.measurement, p.batteryModel, row.observation.nowMs) : null } } : {}) } | |
| 68 | 78 | }) |
| 69 | 79 | let survey: { count: number, minimumDepthM: number, maximumDepthM: number, maximumVerticalSigmaM: number, maximumHorizontalSigmaM: number, datum: string, assumptions: string } | null = null |
| 70 | 80 | if (profile === 'surface' && r.path.some(row => row.measurement && row.measurementPose)) { |
| @@ -72,7 +82,8 @@ export function runMissionWorkspace(profile: MissionWorkspaceProfile = 'quadcopt | ||
| 72 | 82 | survey = { count: reduced.soundings.length, minimumDepthM: Math.min(...reduced.soundings.map(s => s.depthBelowDatumM)), maximumDepthM: Math.max(...reduced.soundings.map(s => s.depthBelowDatumM)), maximumVerticalSigmaM: Math.max(...reduced.soundings.map(s => s.verticalSigmaM)), maximumHorizontalSigmaM: Math.max(...reduced.soundings.map(s => s.horizontalSigmaM)), datum: reduced.metadata.verticalDatum, assumptions: 'Modeled single beam only. User-declared model sigmas: horizontal position 1 m, clock 2 ms, attitude 0.002 rad. One standard deviation, independent terms; no charting certification.' } |
| 73 | 83 | } |
| 74 | 84 | const inspection = profile === 'subsea' && rows.some(row => row.payloadAck?.operation === 'inspection-capture' && row.payloadAck.outcome === 'confirmed') ? reduceSubseaInspection(r.path, r.trace) : null |
| 75 | 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 } | |
| 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 } } : {}) } | |
| 76 | 87 | } |
| 77 | 88 | export type MissionWorkspaceRecording = ReturnType<typeof runMissionWorkspace> |
| 78 | 89 | /** North-up route projection. Both planned goals and estimated recordings share a square metre scale. */ |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | @nolayout |
| 2 | 2 | <!DOCTYPE html> |
| 3 | 3 | <html lang="en"> |
| 4 | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Inspect owned quadcopter, fixed-wing, ground and surface missions using Stacks."><title>Mission engine / Stacks Drone</title></head> | |
| 4 | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Inspect owned quadcopter, fixed-wing, VTOL, ground, surface and subsea missions using Stacks."><title>Mission engine / Stacks Drone</title></head> | |
| 5 | 5 | <body class="min-h-[100dvh] antialiased text-neutral-100 bg-neutral-950"><div class="mx-auto px-5 sm:px-8 max-w-6xl"><header class="py-6 border-b border-neutral-700"><nav aria-label="Main navigation" class="flex flex-wrap gap-6 text-sm"><a href="/">stacks / drone</a><a href="/camera">Camera tracking</a><a href="/vision">Target fusion</a><a href="/diagnostics">Diagnostics</a><a href="/missions" aria-current="page" class="text-emerald-200">Missions</a><a href="https://github.com/stacksjs/drone/issues/30">Roadmap</a></nav></header><main class="py-10 sm:py-14"><MissionWorkspace /></main></div></body> |
| 6 | 6 | </html> |
| 7 | 7 | <style>body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }</style> |
| @@ -79,6 +79,7 @@ | ||
| 79 | 79 | "./unit/vtol-cli.test.ts", |
| 80 | 80 | "./unit/vtol-transition-cli.test.ts", |
| 81 | 81 | "./unit/vtol-scenarios.test.ts", |
| 82 | "./unit/vtol-workspace.test.ts", | |
| 82 | 83 | "./unit/vtol-mode-mission.test.ts", |
| 83 | 84 | "./unit/vtol-transition.test.ts", |
| 84 | 85 | "./unit/ground.test.ts", |
| @@ -0,0 +1,84 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { getMissionWorkspaceDefinition, runMissionWorkspace } from '../../resources/functions/drone/vehicles/workspace' | |
| 3 | import { createVtolSimulationCase } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 4 | ||
| 5 | test('VTOL workspace records the complete modeled route with capture-bound mode acknowledgements', () => { | |
| 6 | const definition = getMissionWorkspaceDefinition('vtol') | |
| 7 | expect(definition.scenarios).toHaveLength(14) | |
| 8 | const run = runMissionWorkspace('vtol') | |
| 9 | expect(run.status).toBe('completed') | |
| 10 | expect(run.physicalActuationEnabled).toBe(false) | |
| 11 | expect(run.boardNeutral).toBe(true) | |
| 12 | expect(run.rows.at(-1)!.taskId).toBe('land') | |
| 13 | expect(run.rows.some(row => row.positionM[2] < -1)).toBe(true) | |
| 14 | const confirmations = run.rows.flatMap(row => row.vtol?.observation?.outcome === 'confirmed' ? [row.vtol.observation] : []) | |
| 15 | expect(confirmations.map(row => [row.taskId, row.phase])).toEqual([['initial-hover', 'hover'], ['front', 'cruise'], ['back', 'hover']]) | |
| 16 | for (const observation of confirmations) { | |
| 17 | const request = run.rows.find(row => row.vtol?.request?.eventId === observation.eventId)!.vtol!.request! | |
| 18 | expect(request.taskId).toBe(observation.taskId) | |
| 19 | expect(request.reference).toBe(observation.reference) | |
| 20 | expect(observation.measurementCapturedAtMs).toBeLessThanOrEqual(observation.capturedAtMs) | |
| 21 | expect(observation.capturedAtMs).toBeLessThan(request.expiresAtMs) | |
| 22 | } | |
| 23 | }, 60000) | |
| 24 | ||
| 25 | test('VTOL recovery graph includes the counted failure edge and preserves an aborted landing outcome', () => { | |
| 26 | const definition = getMissionWorkspaceDefinition('vtol', 'back-abort') | |
| 27 | expect(definition.tasks.find(task => task.id === 'back')?.failureEdge).toBe('recover-hover') | |
| 28 | expect(definition.budget.worstCaseMs).toBe(176520) | |
| 29 | const run = runMissionWorkspace('vtol', 'back-abort') | |
| 30 | expect(run.status).toBe('blocked') | |
| 31 | expect(run.rows.at(-1)!.state).toBe('aborted') | |
| 32 | expect(run.rows.at(-1)!.reason).toBe('vtol-abort-landed') | |
| 33 | expect('vtol' in run && run.vtol?.landingContact).toBe(true) | |
| 34 | expect(run.rows.some(row => row.vtol?.action === 'abort' && row.atMs === 30000)).toBe(true) | |
| 35 | }, 60000) | |
| 36 | ||
| 37 | test('VTOL energy timeline uses qualified capture epochs and never fills a missing reading from plant truth', () => { | |
| 38 | const run = runMissionWorkspace('vtol', 'vehicle-sensor-loss', { phase: 'landing', electrical: true }) | |
| 39 | expect(run.status).toBe('blocked') | |
| 40 | const sampled = run.rows.filter(row => row.vtol?.battery) | |
| 41 | expect(sampled.length).toBeGreaterThan(3000) | |
| 42 | const battery = sampled.at(-1)!.vtol!.battery! | |
| 43 | expect(battery.observedAtMs - battery.capturedAtMs).toBe(120) | |
| 44 | expect(battery.energyUsedWh).toBeGreaterThan(1) | |
| 45 | expect(battery.remainingWhLowerBound).toBeLessThan(100 - battery.energyUsedWh) | |
| 46 | expect(run.rows.at(-1)!.vtol?.battery).toBeNull() | |
| 47 | expect('truthPositionM' in run.rows[0]!).toBe(false) | |
| 48 | expect('finalTruthPositionM' in run).toBe(false) | |
| 49 | }, 60000) | |
| 50 | ||
| 51 | test('VTOL host loss retains stale host evidence and held recovery never becomes a completed landing', () => { | |
| 52 | const lost = runMissionWorkspace('vtol', 'host-loss', { phase: 'cruise', electrical: true }) | |
| 53 | expect(lost.status).toBe('host-lost') | |
| 54 | expect(lost.maximumTimeMs).toBe(19980) | |
| 55 | expect(lost.snapshotsStale).toBe(true) | |
| 56 | expect(lost.boardFault).toBe('output-lease-expired') | |
| 57 | expect(lost.boardNeutral).toBe(true) | |
| 58 | const held = runMissionWorkspace('vtol', 'back-recovery-hold') | |
| 59 | expect(held.status).toBe('blocked') | |
| 60 | expect(held.fault).toBe('mission-endurance-exhausted') | |
| 61 | expect(held.rows.some(row => row.taskId === 'recover-land' && row.state === 'paused')).toBe(true) | |
| 62 | expect(held.rows.some(row => row.taskId === 'recover-land' && row.state === 'running')).toBe(false) | |
| 63 | expect('vtol' in held && held.vtol?.landingContact).toBe(false) | |
| 64 | }, 60000) | |
| 65 | ||
| 66 | test('VTOL choices reject irrelevant phases and do not apply electrical flags to other profiles', () => { | |
| 67 | expect(() => getMissionWorkspaceDefinition('vtol', 'nominal', { phase: 'front' })).toThrow() | |
| 68 | expect(() => getMissionWorkspaceDefinition('vtol', 'imu-loss', { phase: 'unknown' })).toThrow() | |
| 69 | expect(() => getMissionWorkspaceDefinition('ground', 'nominal', { electrical: true })).toThrow() | |
| 70 | expect(() => getMissionWorkspaceDefinition('vtol', 'physical')).toThrow() | |
| 71 | }) | |
| 72 | ||
| 73 | test('the shared VTOL case factory runs isolated sessions and retains the CLI report from the same recording', () => { | |
| 74 | const selected = createVtolSimulationCase('imu-loss', { phase: 'back', electrical: true }) | |
| 75 | const first = selected.run(), second = selected.run() | |
| 76 | expect(first.report).toEqual(second.report) | |
| 77 | expect(first.result.trace.rows.length).toBe(first.report.recordedMissionRows) | |
| 78 | expect(first.result.path.length).toBe(first.report.evaluatedPathRows) | |
| 79 | expect(first.report.scenarioEvidence?.phase).toBe('back') | |
| 80 | expect(first.report.scenarioEvidence?.sources?.atMs).toBeGreaterThanOrEqual(30000) | |
| 81 | expect(first.report.demonstrationMatched).toBe(true) | |
| 82 | first.result.path[0]!.actuators[0] = 999 | |
| 83 | expect(second.result.path[0]!.actuators[0]).not.toBe(999) | |
| 84 | }, 60000) | |