also looking at this
feat: model finite cargo station handling and cancellation
#101
16 files
+970
-6
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.
| @@ -109,6 +109,7 @@ const config: BunPressOptions = { | ||
| 109 | 109 | { text: 'Synthetic airborne environmental sampling', link: '/environment' }, |
| 110 | 110 | { text: 'Simulated carried-load characterization', link: '/cargo-load' }, |
| 111 | 111 | { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' }, |
| 112 | { text: 'Modeled cargo station handling', link: '/cargo-station' }, | |
| 112 | 113 | { text: 'Simulated POI camera reframe', link: '/cinema' }, |
| 113 | 114 | ], |
| 114 | 115 | }, |
| @@ -62,6 +62,11 @@ provides the separate [point-load intake prerequisite](cargo-delivery.md#point-l | ||
| 62 | 62 | It integrates real modeled mass/CG changes after one coincident inelastic point capture. |
| 63 | 63 | The caller still owns the parcel, finite station handling and captured fleet custody protocol. |
| 64 | 64 | |
| 65 | `createCargoStationHandler(declaration)` in `cargo/station.ts` owns one existing point | |
| 66 | through ballistic capture, finite rail transport, cancellation braking and a single | |
| 67 | departure. See the [station model and integration limits](cargo-station.md). It does not | |
| 68 | grant vehicle authority or confirm custody. | |
| 69 | ||
| 65 | 70 | ## Target sources |
| 66 | 71 | |
| 67 | 72 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -179,6 +179,15 @@ and limits. This primitive supplies no finite-duration contact forces or impact | ||
| 179 | 179 | release/intake acknowledgement, station handling, authorization or fleet custody handover. |
| 180 | 180 | Those integrations remain open under issue #25. |
| 181 | 181 | |
| 182 | ## Finite station handling prerequisite | |
| 183 | ||
| 184 | The separate [station actor](cargo-station.md) now retains one released point through | |
| 185 | ballistic surface capture, finite powered motion, cancellation braking and one departure | |
| 186 | to a declared receiving joint. Its public integration test uses actual source separation | |
| 187 | and actual receiving-body intake. The world coordinator must still consume the one parcel, | |
| 188 | check the receiver's current authority and produce correlated custody acknowledgement. | |
| 189 | This model alone does not implement a fleet mission or qualify electrical endurance. | |
| 190 | ||
| 182 | 191 | ## BVLOS operations evidence |
| 183 | 192 | |
| 184 | 193 | |
| @@ -0,0 +1,107 @@ | ||
| 1 | # Modeled cargo station handling | |
| 2 | ||
| 3 | `createCargoStationHandler` in `resources/functions/drone/cargo/station.ts` owns one | |
| 4 | existing point-parcel record through free fall, surface capture, a finite powered rail | |
| 5 | and one departure. This is a software simulation prerequisite for | |
| 6 | [distinct-vehicle cargo handover](cargo-delivery.md#remaining-issue-scope). | |
| 7 | Physical outputs remain disabled. | |
| 8 | ||
| 9 | ## Use an actual parcel | |
| 10 | ||
| 11 | Pass the parcel produced by a carrier's actual `separateCarriedLoad()` event, adding | |
| 12 | its declared id. An empty receiving body's `intakePoint()` supplies a possible rail | |
| 13 | endpoint; it does not supply a parcel. The world coordinator must remove the previous | |
| 14 | owner when it creates the station and consume the departure exactly once. Independent | |
| 15 | station instances cannot detect a caller cloning a parcel into multiple worlds. | |
| 16 | ||
| 17 | ```ts | |
| 18 | import { createCargoStationHandler } from '../resources/functions/drone/cargo/station' | |
| 19 | ||
| 20 | const station = createCargoStationHandler({ | |
| 21 | atMs: 4, | |
| 22 | parcel: { id: 'parcel-a', massKg: 0.1, positionM: [0.08, 0, -1], velocityMps: [0, 0, 0] }, | |
| 23 | pickup: { centerM: [0.08, 0, 0], radiusM: 0.1 }, | |
| 24 | destinationM: [1.08, 0, 0], | |
| 25 | maxSpeedMps: 0.5, | |
| 26 | maxAccelerationMps2: 1, | |
| 27 | }) | |
| 28 | ``` | |
| 29 | ||
| 30 | The literal parcel above is an isolated numerical example. The integration test uses | |
| 31 | real source separation and real receiving dynamics rather than manufacturing a parcel | |
| 32 | from the receiving joint. All positions, velocities and forces use fixed-origin LOCAL_NED: | |
| 33 | metres, metres per second and newtons, with down positive. Mass is kilograms and time | |
| 34 | is one modeled local clock in milliseconds. | |
| 35 | ||
| 36 | ## Lifecycle and clock | |
| 37 | ||
| 38 | The constructor captures strict own-data records and dense finite three-element arrays. | |
| 39 | Unknown fields, accessors and invalid declarations throw `cargo-station-constructor`. | |
| 40 | Later caller mutation cannot alter retained state. Bounds are: | |
| 41 | ||
| 42 | - Safe nonnegative initial epoch aligned to 4 ms, leaving 180,000 ms of safe clock range. | |
| 43 | - Parcel id matching `[a-z][a-z0-9-]{0,39}`, mass greater than zero and at most 0.25 kg. | |
| 44 | - Declared coordinates within +/-1,000 m; initial velocity components within +/-5 m/s. | |
| 45 | - Horizontal pickup radius 0.05 to 2 m. The infinite rough capture plane has down in [-1, 0] m; | |
| 46 | the parcel starts on that plane or up to 3 m above it. | |
| 47 | - Destination on or up to 1 m above the plane. Center-to-destination distance plus pickup | |
| 48 | radius is at most 20 m, with worst declared rail duration at most 120,000 ms. | |
| 49 | - Spatial speed 0.05 to 1 m/s and acceleration 0.05 to 2 m/s². | |
| 50 | ||
| 51 | Call `step(previousAtMs + 4)` to advance the actor. Repeated, skipped, backward, fractional | |
| 52 | or exhausted epochs throw `cargo-station-clock` before mutation. The lifetime is at most | |
| 53 | 180,000 ms from construction; the owning world must bound its run accordingly. | |
| 54 | ||
| 55 | Free fall uses exact constant-gravity kinematics and a quadratic within-step contact time. | |
| 56 | The ideal rough plane stops all velocity components. The retained impact contains its | |
| 57 | fractional contact epoch, actual point, incoming velocity, impulse and kinetic loss. | |
| 58 | The snapshot epoch is the enclosing 4 ms step endpoint. No force peak or impact duration | |
| 59 | is invented. A point outside the declared horizontal disc becomes `missed` at its actual | |
| 60 | contact location and cannot start transport. | |
| 61 | ||
| 62 | `start()` accepts only `held`, with enough lifetime for the full rail path. It never | |
| 63 | retries, moves a missed point onto the rail or starts during free fall. Rail motion uses | |
| 64 | a rest-to-rest quintic position law with derived duration respecting both declared spatial | |
| 65 | limits. The parcel follows the whole path; its final analytic endpoint is the declared | |
| 66 | position with zero velocity. A zero-length route still has a finite 504 ms duration. | |
| 67 | ||
| 68 | `cancel()` is one-way. A moving parcel enters `braking`, with constant deceleration at | |
| 69 | the declared acceleration limit until it stops at the physically integrated location. | |
| 70 | A falling parcel continues under gravity until plane contact. These requests return | |
| 71 | `cancelling`; `cancelled` means it was already at rest. Snapshots distinguish the request | |
| 72 | epoch, current phase and fractional stop epoch. Cancellation retains the parcel and | |
| 73 | prevents transfer; it does not restore an aircraft's authority or land a vehicle. | |
| 74 | ||
| 75 | `takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the | |
| 76 | station's owned mass. Further departure attempts are refused. The receiving body's | |
| 77 | `attachParcel()` still independently checks its actual current joint. A stale destination | |
| 78 | can therefore fail intake; the world must retain the departed parcel and an uncertain | |
| 79 | outcome rather than moving or duplicating it to make receipt succeed. | |
| 80 | ||
| 81 | ## Mechanical accounting | |
| 82 | ||
| 83 | Snapshots retain current and departed mass/energy, rail force, impact loss and work. | |
| 84 | Mechanical energy is `0.5 * mass * speed² - mass * gravity * down`. Free fall conserves | |
| 85 | that quantity to binary64 accumulation tolerance. Surface capture dissipates the incoming | |
| 86 | kinetic energy. Rail force includes gravity support; lifting increases potential energy. | |
| 87 | `stationNetWorkJ` accumulates the represented mechanical-energy change during rail motion | |
| 88 | and braking. Positive and negative work fields sum positive and signed negative **sampled | |
| 89 | energy increments**, not a continuous actuator-efficiency or power integration. | |
| 90 | ||
| 91 | The residual is `current + departed + planeDissipation - initial - stationNetWork`. | |
| 92 | It exposes numerical accumulation instead of substituting a declared battery budget. | |
| 93 | This model has no motor, gearbox, battery, measured efficiency, station recoil, parcel | |
| 94 | spin, deformation or physical mechanism qualification. | |
| 95 | ||
| 96 | ## Evidence and remaining integration | |
| 97 | ||
| 98 | `tests/unit/cargo-station.test.ts` exercises actual source/station/receiver mass transfer, | |
| 99 | finite displacement, a worked midpoint, ballistic contact time/impulse/energy, pickup miss, | |
| 100 | cancellation during rising flight and powered motion, spatial speed/acceleration bounds, | |
| 101 | vertical work, strict admission, detached state and lifetime rejection. | |
| 102 | ||
| 103 | This actor does not grant a mechanism lease, authenticate a recipient, produce a custody | |
| 104 | ACK or coordinate two live vehicle mission engines. Those operations must use each | |
| 105 | vehicle's own sensor, estimator and current authority scope on the common clock. The | |
| 106 | material-receive mission operation, correlated acknowledgement, cancellation/uncertainty | |
| 107 | matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25). | |
| @@ -0,0 +1,166 @@ | ||
| 1 | import type { IntakeParcel } from '../controls/physics' | |
| 2 | import type { SensorVector } from '../sensors/types' | |
| 3 | import { multirotorModel } from '../controls/model' | |
| 4 | ||
| 5 | interface StationDeclaration { | |
| 6 | atMs: number | |
| 7 | parcel: IntakeParcel | |
| 8 | pickup: { centerM: SensorVector, radiusM: number } | |
| 9 | destinationM: SensorVector | |
| 10 | maxSpeedMps: number | |
| 11 | maxAccelerationMps2: number | |
| 12 | } | |
| 13 | export interface CargoStationSnapshot { | |
| 14 | evidence: 'modeled-cargo-station-v1' | |
| 15 | physicalActuationEnabled: false | |
| 16 | phase: 'falling' | 'held' | 'missed' | 'moving' | 'braking' | 'cancelled' | 'ready' | 'departed' | |
| 17 | atMs: number | |
| 18 | startedAtMs: number | null | |
| 19 | durationMs: number | null | |
| 20 | cancelRequestedAtMs: number | null | |
| 21 | stoppedAtMs: number | null | |
| 22 | ownedMassKg: number | |
| 23 | parcel: IntakeParcel | null | |
| 24 | forceN: SensorVector | |
| 25 | impact: { atMs: number, positionM: SensorVector, velocityBeforeMps: SensorVector, impulseNs: SensorVector, dissipationJ: number } | null | |
| 26 | energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number } | |
| 27 | } | |
| 28 | function record(input: unknown, names: string[]) { | |
| 29 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== names.length) throw new TypeError() | |
| 30 | const captured: Record<string, unknown> = {} | |
| 31 | for (const name of names) { | |
| 32 | const d = Object.getOwnPropertyDescriptor(input, name) | |
| 33 | if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError() | |
| 34 | captured[name] = d.value | |
| 35 | } | |
| 36 | return captured | |
| 37 | } | |
| 38 | function vector(input: unknown, min: number, max: number): SensorVector { | |
| 39 | if (!Array.isArray(input) || input.length !== 3 || Reflect.ownKeys(input).length !== 4) throw new TypeError() | |
| 40 | const captured: number[] = [] | |
| 41 | for (let i = 0; i < 3; i++) { | |
| 42 | const d = Object.getOwnPropertyDescriptor(input, String(i)) | |
| 43 | if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || d.value < min || d.value > max) throw new TypeError() | |
| 44 | captured.push(d.value) | |
| 45 | } | |
| 46 | return captured as SensorVector | |
| 47 | } | |
| 48 | const within = (v: unknown, min: number, max: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max | |
| 49 | const distance = (a: SensorVector, b: SensorVector) => Math.hypot(...a.map((n, i) => n - b[i]!)) | |
| 50 | function durationMs(lengthM: number, speed: number, acceleration: number) { | |
| 51 | // Peak derivatives of 10u^3 - 15u^4 + 6u^5 are 15/8 and 10/sqrt(3). | |
| 52 | return Math.ceil(1000 * Math.max(.5, 1.875 * lengthM / speed, Math.sqrt(10 / Math.sqrt(3) * lengthM / acceleration)) / 8) * 8 | |
| 53 | } | |
| 54 | function capture(input: unknown): StationDeclaration { | |
| 55 | try { | |
| 56 | const v = record(input, ['atMs', 'parcel', 'pickup', 'destinationM', 'maxSpeedMps', 'maxAccelerationMps2']) | |
| 57 | const p = record(v.parcel, ['id', 'massKg', 'positionM', 'velocityMps']), pickup = record(v.pickup, ['centerM', 'radiusM']) | |
| 58 | if (!Number.isSafeInteger(v.atMs) || !within(v.atMs, 0, Number.MAX_SAFE_INTEGER - 180000) || v.atMs % 4 !== 0 | |
| 59 | || typeof p.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(p.id) || !within(p.massKg, Number.MIN_VALUE, .25) | |
| 60 | || !within(pickup.radiusM, .05, 2) || !within(v.maxSpeedMps, .05, 1) || !within(v.maxAccelerationMps2, .05, 2)) throw new TypeError() | |
| 61 | const positionM = vector(p.positionM, -1000, 1000), velocityMps = vector(p.velocityMps, -5, 5), centerM = vector(pickup.centerM, -1000, 1000), destinationM = vector(v.destinationM, -1000, 1000) | |
| 62 | if (!within(centerM[2], -1, 0) || !within(positionM[2], centerM[2] - 3, centerM[2]) || destinationM[2] < centerM[2] - 1 || destinationM[2] > centerM[2] | |
| 63 | || distance(centerM, destinationM) + pickup.radiusM > 20 | |
| 64 | || durationMs(distance(centerM, destinationM) + pickup.radiusM, v.maxSpeedMps, v.maxAccelerationMps2) > 120000) throw new TypeError() | |
| 65 | return { atMs: v.atMs, parcel: { id: p.id, massKg: p.massKg, positionM, velocityMps }, pickup: { centerM, radiusM: pickup.radiusM }, destinationM, maxSpeedMps: v.maxSpeedMps, maxAccelerationMps2: v.maxAccelerationMps2 } | |
| 66 | } | |
| 67 | catch { throw new TypeError('cargo-station-constructor') } | |
| 68 | } | |
| 69 | ||
| 70 | /** One point parcel on a prescribed, finite powered station rail. No hardware IO or custody ACK. */ | |
| 71 | export function createCargoStationHandler(input: unknown) { | |
| 72 | const config = capture(input), gravity = multirotorModel.gravityMps2 | |
| 73 | let parcel: IntakeParcel | null = config.parcel, phase: CargoStationSnapshot['phase'] = 'falling', nowMs = config.atMs | |
| 74 | let startedAtMs: number | null = null, motionMs: number | null = null, fromM: SensorVector | null = null | |
| 75 | let cancelRequestedAtMs: number | null = null, stoppedAtMs: number | null = null | |
| 76 | let brake: { atMs: number, positionM: SensorVector, velocityMps: SensorVector, speedMps: number } | null = null | |
| 77 | let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null | |
| 78 | const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2] | |
| 79 | const initialJ = mechanical(parcel) | |
| 80 | let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0 | |
| 81 | function capturePlane(atMs: number) { | |
| 82 | const p = parcel!, before = [...p.velocityMps] as SensorVector | |
| 83 | const dissipationJ = .5 * p.massKg * before.reduce((sum, n) => sum + n * n, 0) | |
| 84 | p.positionM[2] = config.pickup.centerM[2] | |
| 85 | impact = { atMs, positionM: [...p.positionM], velocityBeforeMps: before, impulseNs: before.map(n => -p.massKg * n) as SensorVector, dissipationJ } | |
| 86 | planeDissipationJ += dissipationJ | |
| 87 | p.velocityMps = [0, 0, 0]; forceN = [0, 0, -p.massKg * gravity] | |
| 88 | phase = cancelRequestedAtMs !== null ? 'cancelled' : Math.hypot(p.positionM[0] - config.pickup.centerM[0], p.positionM[1] - config.pickup.centerM[1]) <= config.pickup.radiusM ? 'held' : 'missed' | |
| 89 | if (cancelRequestedAtMs !== null) stoppedAtMs = atMs | |
| 90 | } | |
| 91 | if (parcel.positionM[2] === config.pickup.centerM[2] && parcel.velocityMps[2] >= 0) capturePlane(nowMs) | |
| 92 | function retainWork(beforeJ: number) { | |
| 93 | const work = mechanical(parcel!) - beforeJ | |
| 94 | stationNetWorkJ += work; positiveWorkJ += Math.max(0, work); negativeWorkJ += Math.min(0, work) | |
| 95 | } | |
| 96 | function snapshot(): CargoStationSnapshot { | |
| 97 | const currentJ = parcel ? mechanical(parcel) : 0 | |
| 98 | return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel, | |
| 99 | ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ } }) | |
| 100 | } | |
| 101 | return { | |
| 102 | snapshot, | |
| 103 | start(...args: unknown[]): { outcome: 'started', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-held' | 'deadline' } { | |
| 104 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 105 | if (phase !== 'held') return { outcome: 'refused', reason: 'not-held' } | |
| 106 | const duration = durationMs(distance(parcel!.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2) | |
| 107 | if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' } | |
| 108 | fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving' | |
| 109 | return { outcome: 'started', atMs: nowMs, durationMs: duration } | |
| 110 | }, | |
| 111 | cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } { | |
| 112 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 113 | if (phase === 'departed') return { outcome: 'refused', reason: 'departed' } | |
| 114 | if (cancelRequestedAtMs !== null) return { outcome: 'refused', reason: 'already-cancelled' } | |
| 115 | cancelRequestedAtMs = nowMs | |
| 116 | const speedMps = Math.hypot(...parcel!.velocityMps) | |
| 117 | if (phase === 'moving' && speedMps > 0) { | |
| 118 | brake = { atMs: nowMs, positionM: [...parcel!.positionM], velocityMps: [...parcel!.velocityMps], speedMps } | |
| 119 | phase = 'braking' | |
| 120 | } | |
| 121 | else if (phase !== 'falling') { phase = 'cancelled'; stoppedAtMs = nowMs } | |
| 122 | return { outcome: phase === 'cancelled' ? 'cancelled' : 'cancelling', atMs: nowMs } | |
| 123 | }, | |
| 124 | step(atMs: number, ...extra: unknown[]) { | |
| 125 | if (extra.length || !Number.isSafeInteger(atMs) || atMs !== nowMs + 4 || atMs - config.atMs > 180000) throw new TypeError('cargo-station-clock') | |
| 126 | if (phase === 'falling') { | |
| 127 | const p = parcel!, down = p.positionM[2] - config.pickup.centerM[2], vz = p.velocityMps[2] | |
| 128 | const speedAtPlane = Math.sqrt(vz * vz - 2 * gravity * down) | |
| 129 | // Stable positive root for downward motion, including a point already at the plane. | |
| 130 | const hitSeconds = vz >= 0 ? (down === 0 ? 0 : -2 * down / (vz + speedAtPlane)) : (-vz + speedAtPlane) / gravity | |
| 131 | const seconds = Math.min(.004, hitSeconds) | |
| 132 | p.positionM = p.positionM.map((n, i) => n + p.velocityMps[i]! * seconds + (i === 2 ? .5 * gravity * seconds ** 2 : 0)) as SensorVector | |
| 133 | p.velocityMps[2] += gravity * seconds | |
| 134 | if (hitSeconds <= .004) capturePlane(nowMs + hitSeconds * 1000) | |
| 135 | } | |
| 136 | else if (phase === 'braking') { | |
| 137 | const b = brake!, p = parcel!, beforeJ = mechanical(p), stopSeconds = b.speedMps / config.maxAccelerationMps2 | |
| 138 | const seconds = Math.min((atMs - b.atMs) / 1000, stopSeconds), fraction = Math.max(0, 1 - seconds / stopSeconds) | |
| 139 | p.positionM = b.positionM.map((n, i) => n + b.velocityMps[i]! * (seconds - .5 * seconds ** 2 / stopSeconds)) as SensorVector | |
| 140 | p.velocityMps = b.velocityMps.map(n => n * fraction) as SensorVector | |
| 141 | forceN = b.velocityMps.map((n, i) => p.massKg * (-n / stopSeconds - (i === 2 ? gravity : 0))) as SensorVector | |
| 142 | retainWork(beforeJ) | |
| 143 | if (seconds === stopSeconds) { phase = 'cancelled'; stoppedAtMs = b.atMs + stopSeconds * 1000; forceN = [0, 0, -p.massKg * gravity] } | |
| 144 | } | |
| 145 | else if (phase === 'moving') { | |
| 146 | const beforeJ = mechanical(parcel!), seconds = motionMs! / 1000, u = Math.min(1, (atMs - startedAtMs!) / motionMs!) | |
| 147 | const s = 10 * u ** 3 - 15 * u ** 4 + 6 * u ** 5, ds = 30 * u ** 2 * (1 - u) ** 2, dds = 60 * u * (1 - u) * (1 - 2 * u) | |
| 148 | const delta = config.destinationM.map((n, i) => n - fromM![i]!) as SensorVector | |
| 149 | parcel!.positionM = u === 1 ? [...config.destinationM] : fromM!.map((n, i) => n + delta[i]! * s) as SensorVector | |
| 150 | parcel!.velocityMps = delta.map(n => n * ds / seconds) as SensorVector | |
| 151 | forceN = delta.map((n, i) => parcel!.massKg * (n * dds / seconds ** 2 - (i === 2 ? gravity : 0))) as SensorVector | |
| 152 | retainWork(beforeJ) | |
| 153 | if (u === 1) phase = 'ready' | |
| 154 | } | |
| 155 | nowMs = atMs | |
| 156 | return snapshot() | |
| 157 | }, | |
| 158 | takeReadyParcel(...args: unknown[]): { outcome: 'departed', atMs: number, parcel: IntakeParcel } | { outcome: 'refused', reason: 'not-ready' } { | |
| 159 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 160 | if (phase !== 'ready') return { outcome: 'refused', reason: 'not-ready' } | |
| 161 | const outgoing = structuredClone(parcel!) | |
| 162 | departedJ = mechanical(parcel!); parcel = null; phase = 'departed'; forceN = [0, 0, 0] | |
| 163 | return { outcome: 'departed', atMs: nowMs, parcel: outgoing } | |
| 164 | }, | |
| 165 | } | |
| 166 | } | |
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | 26 | "./unit/cargo-intake.test.ts", |
| 27 | "./unit/cargo-station.test.ts", | |
| 27 | 28 | "./unit/cargo-delivery.test.ts", |
| 28 | 29 | "./unit/cargo-delivery-config.test.ts", |
| 29 | 30 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -0,0 +1,194 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createCargoStationHandler } from '../../resources/functions/drone/cargo/station' | |
| 3 | import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics' | |
| 4 | ||
| 5 | // Physical function seam: one real separated parcel crosses a finite station path | |
| 6 | // and enters actual receiving dynamics. This is not vehicle authority or a custody ACK. | |
| 7 | test('one separated point load moves continuously to the receiving joint and leaves the station once', () => { | |
| 8 | const load = { id: 'parcel-a', massKg: .1, forwardM: .08 } | |
| 9 | const source = createRigidBody({ positionM: [0, 0, 0], carriedPointLoad: load }) | |
| 10 | const receiver = createReceivingRigidBody({ positionM: [1, 0, 0], expectedPointLoad: load }) | |
| 11 | source.step([0, 0, 0, 0], .004); receiver.step([0, 0, 0, 0], .004) | |
| 12 | const release = source.separateCarriedLoad() | |
| 13 | if (release.outcome !== 'separated') throw new Error('Expected actual source separation') | |
| 14 | const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...release.parcel }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: receiver.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 15 | expect(station.snapshot().ownedMassKg).toBe(.1) | |
| 16 | expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg + station.snapshot().ownedMassKg).toBe(3.1) | |
| 17 | expect(station.start()).toEqual({ outcome: 'started', atMs: 4, durationMs: 3752 }) | |
| 18 | let previous = station.snapshot().parcel!.positionM | |
| 19 | for (let nowMs = 8; nowMs <= 3756; nowMs += 4) { | |
| 20 | receiver.step([0, 0, 0, 0], .004) | |
| 21 | const state = station.step(nowMs), parcel = state.parcel! | |
| 22 | expect(Math.hypot(...parcel.positionM.map((n, i) => n - previous[i]!))).toBeLessThanOrEqual(.002 + 1e-12) | |
| 23 | if (nowMs === 1880) { expect(parcel.positionM[0]).toBeCloseTo(.58, 14); expect(parcel.velocityMps[0]).toBeCloseTo(.4997334754797441, 13) } | |
| 24 | previous = parcel.positionM | |
| 25 | } | |
| 26 | expect(station.snapshot().phase).toBe('ready') | |
| 27 | const exit = station.takeReadyParcel() | |
| 28 | if (exit.outcome !== 'departed') throw new Error('Expected actual station exit') | |
| 29 | expect(receiver.attachParcel(exit.parcel).outcome).toBe('attached') | |
| 30 | expect(station.snapshot().parcel).toBeNull() | |
| 31 | expect(station.snapshot().ownedMassKg).toBe(0) | |
| 32 | expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg).toBe(3.1) | |
| 33 | expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' }) | |
| 34 | expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12) | |
| 35 | }) | |
| 36 | ||
| 37 | test('the actual released point falls before surface capture and retains its impact energy and epoch', () => { | |
| 38 | const load = { id: 'parcel-a', massKg: .1, forwardM: .08 }, gravity = 9.80665 | |
| 39 | const source = createRigidBody({ positionM: [0, 0, -1], carriedPointLoad: load }) | |
| 40 | source.step([0, 0, 0, 0], .004) | |
| 41 | const release = source.separateCarriedLoad() | |
| 42 | if (release.outcome !== 'separated') throw new Error('Expected airborne source separation') | |
| 43 | const initial = release.parcel, v0 = initial.velocityMps[2], height = -initial.positionM[2] | |
| 44 | const hitAtMs = 4 + 1000 * (Math.sqrt(v0 ** 2 + 2 * gravity * height) - v0) / gravity | |
| 45 | const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...initial }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 46 | expect(station.snapshot().phase).toBe('falling') | |
| 47 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 48 | let nowMs = 4 | |
| 49 | while (station.snapshot().phase === 'falling') { | |
| 50 | nowMs += 4 | |
| 51 | const state = station.step(nowMs), seconds = (nowMs - 4) / 1000 | |
| 52 | expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12) | |
| 53 | if (state.phase === 'falling') expect(state.parcel!.positionM[2]).toBeCloseTo(initial.positionM[2] + v0 * seconds + .5 * gravity * seconds ** 2, 12) | |
| 54 | expect(nowMs).toBeLessThan(1000) | |
| 55 | } | |
| 56 | const landed = station.snapshot() | |
| 57 | expect(landed.phase).toBe('held') | |
| 58 | expect(landed.parcel!.positionM).toEqual([.08, 0, 0]) | |
| 59 | expect(landed.parcel!.velocityMps).toEqual([0, 0, 0]) | |
| 60 | expect(landed.impact!.atMs).toBeCloseTo(hitAtMs, 9) | |
| 61 | expect(landed.impact!.impulseNs[2]).toBeCloseTo(-.1 * Math.sqrt(v0 ** 2 + 2 * gravity * height), 12) | |
| 62 | expect(landed.energy.planeDissipationJ).toBeCloseTo(.05 * v0 ** 2 + .1 * gravity * height, 12) | |
| 63 | expect(landed.energy.stationNetWorkJ).toBe(0) | |
| 64 | expect(station.start().outcome).toBe('started') | |
| 65 | }) | |
| 66 | ||
| 67 | test('cancellation brakes the real moving parcel finitely and retains it instead of transferring it', () => { | |
| 68 | const station = createCargoStationHandler({ atMs: 4, parcel: { id: 'parcel-a', massKg: .1, positionM: [.08, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 69 | station.start() | |
| 70 | for (let atMs = 8; atMs <= 1880; atMs += 4) station.step(atMs) | |
| 71 | const before = station.snapshot(), v0 = before.parcel!.velocityMps[0] | |
| 72 | expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 1880 }) | |
| 73 | expect(station.snapshot().phase).toBe('braking') | |
| 74 | expect(station.snapshot().parcel).toEqual(before.parcel) | |
| 75 | expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' }) | |
| 76 | let atMs = 1880, previous = before.parcel! | |
| 77 | while (station.snapshot().phase === 'braking') { | |
| 78 | atMs += 4 | |
| 79 | const row = station.step(atMs), p = row.parcel! | |
| 80 | expect(p.velocityMps[0]).toBeGreaterThanOrEqual(0) | |
| 81 | expect(p.velocityMps[0]).toBeLessThan(previous.velocityMps[0]) | |
| 82 | expect(Math.abs(p.velocityMps[0] - previous.velocityMps[0])).toBeLessThanOrEqual(.004 + 1e-12) | |
| 83 | expect(Math.hypot(...p.positionM.map((n, i) => n - previous.positionM[i]!))).toBeLessThanOrEqual(.002 + 1e-12) | |
| 84 | expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12) | |
| 85 | expect(atMs).toBeLessThan(2500) | |
| 86 | previous = p | |
| 87 | } | |
| 88 | const stopped = station.snapshot() | |
| 89 | expect(stopped.phase).toBe('cancelled') | |
| 90 | expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0]) | |
| 91 | expect(stopped.parcel!.positionM[0]).toBeCloseTo(.58 + v0 ** 2 / 2, 12) | |
| 92 | expect(stopped.stoppedAtMs).toBeCloseTo(1880 + v0 * 1000, 9) | |
| 93 | expect(stopped.ownedMassKg).toBe(.1) | |
| 94 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 95 | expect(station.cancel()).toEqual({ outcome: 'refused', reason: 'already-cancelled' }) | |
| 96 | expect(station.step(atMs + 4).parcel).toEqual(stopped.parcel) | |
| 97 | }) | |
| 98 | ||
| 99 | test('a drifting parcel misses the declared pickup instead of being pulled onto the rail', () => { | |
| 100 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, -1], velocityMps: [1, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 101 | let atMs = 0 | |
| 102 | while (station.snapshot().phase === 'falling') station.step(atMs += 4) | |
| 103 | const missed = station.snapshot() | |
| 104 | expect(missed.phase).toBe('missed') | |
| 105 | // Independent one-metre drop: flight time sqrt(2/g), with constant 1 m/s north velocity. | |
| 106 | expect(missed.parcel!.positionM[0]).toBeCloseTo(Math.sqrt(2 / 9.80665), 12) | |
| 107 | expect(missed.energy.planeDissipationJ).toBeCloseTo(1.030665, 12) | |
| 108 | expect(missed.impact!.impulseNs[0]).toBe(-.1) | |
| 109 | expect(missed.ownedMassKg).toBe(.1) | |
| 110 | expect(Math.abs(missed.energy.residualJ)).toBeLessThan(1e-12) | |
| 111 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 112 | expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' }) | |
| 113 | expect(station.step(atMs + 4).parcel).toEqual(missed.parcel) | |
| 114 | }) | |
| 115 | ||
| 116 | test('cancelling a rising parcel retains gravity and impact before it can settle', () => { | |
| 117 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, -1] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 118 | expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 0 }) | |
| 119 | expect(station.snapshot().phase).toBe('falling') | |
| 120 | expect(station.snapshot().forceN).toEqual([0, 0, 0]) | |
| 121 | expect(station.step(4).parcel!.positionM[2]).toBeCloseTo(-.0039215468, 12) | |
| 122 | let atMs = 4 | |
| 123 | while (station.snapshot().phase === 'falling') station.step(atMs += 4) | |
| 124 | const stopped = station.snapshot() | |
| 125 | expect(stopped.phase).toBe('cancelled') | |
| 126 | expect(stopped.impact!.atMs).toBeCloseTo(2000 / 9.80665, 9) | |
| 127 | expect(stopped.stoppedAtMs).toBe(stopped.impact!.atMs) | |
| 128 | expect(stopped.energy.planeDissipationJ).toBeCloseTo(.05, 12) | |
| 129 | expect(stopped.energy.stationNetWorkJ).toBe(0) | |
| 130 | expect(Math.abs(stopped.energy.residualJ)).toBeLessThan(1e-12) | |
| 131 | expect(station.start().outcome).toBe('refused') | |
| 132 | expect(station.takeReadyParcel().outcome).toBe('refused') | |
| 133 | }) | |
| 134 | ||
| 135 | test('lifting retains mechanical work and speed/acceleration limits on the full spatial path', () => { | |
| 136 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .2, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 2, -1], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 137 | station.start() | |
| 138 | let atMs = 0, previous = station.snapshot() | |
| 139 | while (station.snapshot().phase === 'moving') { | |
| 140 | const row = station.step(atMs += 4) | |
| 141 | expect(Math.hypot(...row.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12) | |
| 142 | expect(Math.hypot(...row.parcel!.velocityMps.map((n, i) => (n - previous.parcel!.velocityMps[i]!) / .004))).toBeLessThanOrEqual(1 + 1e-12) | |
| 143 | expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12) | |
| 144 | previous = row | |
| 145 | } | |
| 146 | expect(previous.parcel!.positionM).toEqual([1, 2, -1]) | |
| 147 | expect(Math.hypot(...previous.parcel!.velocityMps)).toBe(0) | |
| 148 | expect(previous.energy.stationNetWorkJ).toBeCloseTo(1.96133, 12) | |
| 149 | expect(previous.energy.positiveWorkJ).toBeGreaterThanOrEqual(previous.energy.stationNetWorkJ) | |
| 150 | expect(previous.energy.positiveWorkJ + previous.energy.negativeWorkJ).toBeCloseTo(previous.energy.stationNetWorkJ, 12) | |
| 151 | expect(station.takeReadyParcel().outcome).toBe('departed') | |
| 152 | expect(station.snapshot().energy.departedJ).toBeCloseTo(1.96133, 12) | |
| 153 | expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12) | |
| 154 | }) | |
| 155 | ||
| 156 | test('station admission captures data without invoking caller accessors and rejects invalid declarations privately', () => { | |
| 157 | const input = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 158 | const station = createCargoStationHandler(input), original = station.snapshot() | |
| 159 | input.parcel.positionM[0] = 20; input.parcel.massKg = .2; input.pickup.centerM[0] = 10; input.destinationM[0] = 40 | |
| 160 | const detached = station.snapshot() | |
| 161 | detached.parcel!.positionM[0] = 88; detached.impact!.impulseNs[0] = 19; detached.energy.initialJ = 500 | |
| 162 | expect(station.snapshot()).toEqual(original) | |
| 163 | expect(station.start()).toEqual({ outcome: 'started', atMs: 0, durationMs: 3752 }) | |
| 164 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 165 | const fresh = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 166 | const getter = structuredClone(fresh) | |
| 167 | let invoked = 0 | |
| 168 | Object.defineProperty(getter.parcel, 'positionM', { enumerable: true, get() { invoked++; throw new Error('private-source') } }) | |
| 169 | const revoked = Proxy.revocable({}, {}); revoked.revoke() | |
| 170 | const bad = [null, {}, { ...fresh, extra: true }, { ...fresh, atMs: 3 }, { ...fresh, atMs: -4 }, { ...fresh, atMs: Number.MAX_SAFE_INTEGER }, { ...fresh, maxSpeedMps: 0 }, { ...fresh, maxAccelerationMps2: Infinity }, { ...fresh, parcel: { ...fresh.parcel, massKg: .251 } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, .01] } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, -3.01] } }, { ...fresh, parcel: { ...fresh.parcel, velocityMps: [0, 0, NaN] } }, { ...fresh, destinationM: [50, 0, 0] }, getter, revoked.proxy] | |
| 171 | for (const v of bad) expect(() => createCargoStationHandler(v)).toThrow('cargo-station-constructor') | |
| 172 | expect(invoked).toBe(0) | |
| 173 | }) | |
| 174 | ||
| 175 | test('invalid and exhausted clocks cannot change the parcel, and delayed starts preserve the lifetime bound', () => { | |
| 176 | const config = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 177 | const station = createCargoStationHandler(config), initial = station.snapshot() | |
| 178 | for (const invalid of [0, -4, 1, 8, NaN, Infinity]) expect(() => station.step(invalid)).toThrow('cargo-station-clock') | |
| 179 | expect(() => station.step(4, 'extra')).toThrow('cargo-station-clock') | |
| 180 | expect(() => station.start('extra')).toThrow('cargo-station-arguments') | |
| 181 | expect(() => station.cancel('extra')).toThrow('cargo-station-arguments') | |
| 182 | expect(() => station.takeReadyParcel('extra')).toThrow('cargo-station-arguments') | |
| 183 | expect(station.snapshot()).toEqual(initial) | |
| 184 | for (let atMs = 4; atMs <= 179996; atMs += 4) station.step(atMs) | |
| 185 | const held = station.snapshot() | |
| 186 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'deadline' }) | |
| 187 | expect(station.snapshot()).toEqual(held) | |
| 188 | station.step(180000) | |
| 189 | const end = station.snapshot() | |
| 190 | expect(() => station.step(180004)).toThrow('cargo-station-clock') | |
| 191 | expect(station.snapshot()).toEqual(end) | |
| 192 | expect(station.cancel()).toEqual({ outcome: 'cancelled', atMs: 180000 }) | |
| 193 | expect(station.takeReadyParcel().outcome).toBe('refused') | |
| 194 | }) | |