ReviewOS

also looking at this

stacks/drone

feat: add retained cargo fleet cancellation and recovery

#105
Merged chris wants to merge codex/cargo-fleet-operator-recovery into main
32 files +2496 -27

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.

config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -109,6 +109,7 @@ const config: BunPressOptions = {
109109 { text: 'Synthetic airborne environmental sampling', link: '/environment' },
110110 { text: 'Simulated carried-load characterization', link: '/cargo-load' },
111111 { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' },
112 { text: 'Modeled cargo station handling', link: '/cargo-station' },
112113 { text: 'Simulated POI camera reframe', link: '/cinema' },
113114 ],
114115 },
docs/api.mdmodified+5-0
Changes to docs/api.md
@@ -62,6 +62,11 @@ provides the separate [point-load intake prerequisite](cargo-delivery.md#point-l
6262It integrates real modeled mass/CG changes after one coincident inelastic point capture.
6363The caller still owns the parcel, finite station handling and captured fleet custody protocol.
6464
65`createCargoStationHandler(declaration)` in `cargo/station.ts` owns one existing point
66through ballistic capture, finite rail transport, cancellation braking and a single
67departure. See the [station model and integration limits](cargo-station.md). It does not
68grant vehicle authority or confirm custody.
69
6570## Target sources
6671
6772`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdmodified+9-0
Changes to docs/cargo-delivery.md
@@ -179,6 +179,15 @@ and limits. This primitive supplies no finite-duration contact forces or impact
179179release/intake acknowledgement, station handling, authorization or fleet custody handover.
180180Those integrations remain open under issue #25.
181181
182## Finite station handling prerequisite
183
184The separate [station actor](cargo-station.md) now retains one released point through
185ballistic surface capture, finite powered motion, cancellation braking and one departure
186to a declared receiving joint. Its public integration test uses actual source separation
187and actual receiving-body intake. The world coordinator must still consume the one parcel,
188check the receiver's current authority and produce correlated custody acknowledgement.
189This model alone does not implement a fleet mission or qualify electrical endurance.
190
182191## BVLOS operations evidence
183192
184193
docs/cargo-station.mdadded+107-0
Changes to docs/cargo-station.md
@@ -0,0 +1,107 @@
1# Modeled cargo station handling
2
3`createCargoStationHandler` in `resources/functions/drone/cargo/station.ts` owns one
4existing point-parcel record through free fall, surface capture, a finite powered rail
5and one departure. This is a software simulation prerequisite for
6[distinct-vehicle cargo handover](cargo-delivery.md#remaining-issue-scope).
7Physical outputs remain disabled.
8
9## Use an actual parcel
10
11Pass the parcel produced by a carrier's actual `separateCarriedLoad()` event, adding
12its declared id. An empty receiving body's `intakePoint()` supplies a possible rail
13endpoint; it does not supply a parcel. The world coordinator must remove the previous
14owner when it creates the station and consume the departure exactly once. Independent
15station instances cannot detect a caller cloning a parcel into multiple worlds.
16
17```ts
18import { createCargoStationHandler } from '../resources/functions/drone/cargo/station'
19
20const 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
30The literal parcel above is an isolated numerical example. The integration test uses
31real source separation and real receiving dynamics rather than manufacturing a parcel
32from the receiving joint. All positions, velocities and forces use fixed-origin LOCAL_NED:
33metres, metres per second and newtons, with down positive. Mass is kilograms and time
34is one modeled local clock in milliseconds.
35
36## Lifecycle and clock
37
38The constructor captures strict own-data records and dense finite three-element arrays.
39Unknown fields, accessors and invalid declarations throw `cargo-station-constructor`.
40Later 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
51Call `step(previousAtMs + 4)` to advance the actor. Repeated, skipped, backward, fractional
52or exhausted epochs throw `cargo-station-clock` before mutation. The lifetime is at most
53180,000 ms from construction; the owning world must bound its run accordingly.
54
55Free fall uses exact constant-gravity kinematics and a quadratic within-step contact time.
56The ideal rough plane stops all velocity components. The retained impact contains its
57fractional contact epoch, actual point, incoming velocity, impulse and kinetic loss.
58The snapshot epoch is the enclosing 4 ms step endpoint. No force peak or impact duration
59is invented. A point outside the declared horizontal disc becomes `missed` at its actual
60contact location and cannot start transport.
61
62`start()` accepts only `held`, with enough lifetime for the full rail path. It never
63retries, moves a missed point onto the rail or starts during free fall. Rail motion uses
64a rest-to-rest quintic position law with derived duration respecting both declared spatial
65limits. The parcel follows the whole path; its final analytic endpoint is the declared
66position 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
69the declared acceleration limit until it stops at the physically integrated location.
70A falling parcel continues under gravity until plane contact. These requests return
71`cancelling`; `cancelled` means it was already at rest. Snapshots distinguish the request
72epoch, current phase and fractional stop epoch. Cancellation retains the parcel and
73prevents 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
76station's owned mass. Further departure attempts are refused. The receiving body's
77`attachParcel()` still independently checks its actual current joint. A stale destination
78can therefore fail intake; the world must retain the departed parcel and an uncertain
79outcome rather than moving or duplicating it to make receipt succeed.
80
81## Mechanical accounting
82
83Snapshots retain current and departed mass/energy, rail force, impact loss and work.
84Mechanical energy is `0.5 * mass * speed² - mass * gravity * down`. Free fall conserves
85that quantity to binary64 accumulation tolerance. Surface capture dissipates the incoming
86kinetic energy. Rail force includes gravity support; lifting increases potential energy.
87`stationNetWorkJ` accumulates the represented mechanical-energy change during rail motion
88and braking. Positive and negative work fields sum positive and signed negative **sampled
89energy increments**, not a continuous actuator-efficiency or power integration.
90
91The residual is `current + departed + planeDissipation - initial - stationNetWork`.
92It exposes numerical accumulation instead of substituting a declared battery budget.
93This model has no motor, gearbox, battery, measured efficiency, station recoil, parcel
94spin, 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,
99finite displacement, a worked midpoint, ballistic contact time/impulse/energy, pickup miss,
100cancellation during rising flight and powered motion, spatial speed/acceleration bounds,
101vertical work, strict admission, detached state and lifetime rejection.
102
103This actor does not grant a mechanism lease, authenticate a recipient, produce a custody
104ACK or coordinate two live vehicle mission engines. Those operations must use each
105vehicle's own sensor, estimator and current authority scope on the common clock. The
106material-receive mission operation, correlated acknowledgement, cancellation/uncertainty
107matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25).
resources/functions/drone/cargo/station.tsadded+166-0
Changes to resources/functions/drone/cargo/station.ts
@@ -0,0 +1,166 @@
1import type { IntakeParcel } from '../controls/physics'
2import type { SensorVector } from '../sensors/types'
3import { multirotorModel } from '../controls/model'
4
5interface StationDeclaration {
6 atMs: number
7 parcel: IntakeParcel
8 pickup: { centerM: SensorVector, radiusM: number }
9 destinationM: SensorVector
10 maxSpeedMps: number
11 maxAccelerationMps2: number
12}
13export 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}
28function 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}
38function 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}
48const within = (v: unknown, min: number, max: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max
49const distance = (a: SensorVector, b: SensorVector) => Math.hypot(...a.map((n, i) => n - b[i]!))
50function 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}
54function 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. */
71export 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}
tests/tsconfig.runtime.jsonmodified+1-0
Changes to tests/tsconfig.runtime.json
@@ -24,6 +24,7 @@
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-intake.test.ts",
27 "./unit/cargo-station.test.ts",
2728 "./unit/cargo-delivery.test.ts",
2829 "./unit/cargo-delivery-config.test.ts",
2930 "./unit/cargo-delivery-phase-faults.test.ts",
tests/unit/cargo-station.test.tsadded+194-0
Changes to tests/unit/cargo-station.test.ts
@@ -0,0 +1,194 @@
1import { expect, test } from 'bun:test'
2import { createCargoStationHandler } from '../../resources/functions/drone/cargo/station'
3import { 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.
7test('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
37test('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
67test('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
99test('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
116test('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
135test('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
156test('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
175test('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})