also looking at this
test: retain owned browser startup process evidence
#100
21 files
+1516
-155
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.
| @@ -0,0 +1,53 @@ | ||
| 1 | # ADR 0036: One point-load intake for a receiving vehicle | |
| 2 | ||
| 3 | Status: accepted for software simulation. | |
| 4 | ||
| 5 | ## Context | |
| 6 | ||
| 7 | Cargo fleet handover needs a physical parcel to leave one carrier, undergo finite modeled | |
| 8 | station handling and enter another carrier's actual dynamics. A custody-label change or | |
| 9 | an initially loaded second vehicle would bypass that mass transfer. The rigid body already | |
| 10 | supports an attached point load and one zero-ejection separation, but had no empty joint. | |
| 11 | ||
| 12 | ## Decision | |
| 13 | ||
| 14 | Add `createReceivingRigidBody` over the same integrator, with a captured `expectedPointLoad`. | |
| 15 | Its lifecycle is `awaiting -> attached -> separated`. The initial body has base mass/inertia; | |
| 16 | the planned load is not an actual parcel. A successful intake consumes this one opportunity. | |
| 17 | The old `createRigidBody` constructor still starts attached and exposes no intake method or | |
| 18 | awaiting phase. Its default/no-load numerical paths and result projections remain unchanged. | |
| 19 | ||
| 20 | The receiving joint reports its position and velocity. The incoming parcel record contains | |
| 21 | its own id, mass, position and velocity. Match id/mass exactly and require exact coincident | |
| 22 | binary64 position components at invocation. This is an ideal programmatic contact condition, | |
| 23 | not a sensor tolerance or claim of physical positioning precision. Captured records and | |
| 24 | finite tuple elements reject accessors. Valid refusal or malformed input does not mutate | |
| 25 | the body or consume its intake opportunity. | |
| 26 | ||
| 27 | Let base mass be `m`, point mass `p`, body-forward joint vector `r`, and combined mass `M=m+p`. | |
| 28 | The new body-frame CG is `c=p*r/M`. Combined center velocity is `(m*vBody+p*vParcel)/M`. | |
| 29 | About that center, body-frame angular momentum is base `I*omega` plus | |
| 30 | `m*p/M * cross(r, inverseRotate(vParcel-vBody))`. Dividing by the combined principal inertia | |
| 31 | gives the new angular rate. Reconstruct datum velocity from center velocity and `omega cross c`. | |
| 32 | The joint is on the body x axis, so the existing diagonal parallel-axis inertia is sufficient. | |
| 33 | ||
| 34 | The base and incoming point jointly determine conserved linear/angular momentum. Structural | |
| 35 | position, quaternion and actual motor state remain unchanged. Kinetic energy may decrease; | |
| 36 | retain before/after energy and raw dissipation. Reject non-finite candidates or a gain beyond | |
| 37 | `64 * Number.EPSILON * max(1, kineticBeforeJ, kineticAfterJ)` before committing state. Keep the | |
| 38 | raw represented loss, including tolerated tiny negatives. NED potential is `-mass*g*down`. | |
| 39 | The event reports both potential values separately. | |
| 40 | ||
| 41 | ## Consequences | |
| 42 | ||
| 43 | The primitive models an instantaneous inelastic impulse, not contact duration, force peaks, | |
| 44 | parcel spin/deformation, latch actuation or restitution. The retained acceleration is the last | |
| 45 | continuous integration sample, not an invented impact acceleration. A later physics step uses | |
| 46 | the new mass/CG/inertia without resetting the controller, estimator, pose or motor lag. | |
| 47 | ||
| 48 | World/policy code must own and consume the single parcel, admit the physical operation, model | |
| 49 | finite transport to the joint and capture mechanism/vehicle acknowledgement with current | |
| 50 | authority and source epochs. This primitive does not globally prevent a caller from supplying | |
| 51 | the same record to two independent worlds. A joint pose is not a source parcel or proof of custody. | |
| 52 | Station handling and the actual two-vehicle protocol remain unimplemented at this checkpoint. | |
| 53 | No physical output, authenticated identity, battery endurance or fleet completion is claimed. | |
| @@ -57,6 +57,11 @@ The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite l | ||
| 57 | 57 | one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining |
| 58 | 58 | fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements. |
| 59 | 59 | |
| 60 | `createReceivingRigidBody({ expectedPointLoad, ...bodyOptions })` in `controls/physics.ts` | |
| 61 | provides the separate [point-load intake prerequisite](cargo-delivery.md#point-load-intake-prerequisite). | |
| 62 | It integrates real modeled mass/CG changes after one coincident inelastic point capture. | |
| 63 | The caller still owns the parcel, finite station handling and captured fleet custody protocol. | |
| 64 | ||
| 60 | 65 | ## Target sources |
| 61 | 66 | |
| 62 | 67 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -149,8 +149,39 @@ temperature limit, voltage-sag prediction or Pi performance result. Changing mas | ||
| 149 | 149 | the actual modeled dynamics; it does not identify an electrical load law. A successful |
| 150 | 150 | budget is conditional on that ceiling and is not measured endurance qualification. |
| 151 | 151 | |
| 152 | ## Point-load intake prerequisite | |
| 153 | ||
| 154 | `createReceivingRigidBody` in `controls/physics.ts` adds an empty declared joint for a | |
| 155 | future parcel. Supply `expectedPointLoad: { id, massKg, forwardM }`; the mass and offset | |
| 156 | bounds match the carried-load model. Its `awaiting` phase has the base body's actual | |
| 157 | mass/inertia. `intakePoint()` returns only joint position and velocity, not a parcel or | |
| 158 | custody evidence. | |
| 159 | ||
| 160 | `attachParcel({ id, massKg, positionM, velocityMps })` accepts an existing modeled point | |
| 161 | parcel only at the current joint position, equal component by component. Identity and | |
| 162 | mass must match the declaration. The caller must move its one physical-world parcel | |
| 163 | there; this primitive does not transport, copy or globally own it. Malformed own-data | |
| 164 | records throw without changing the body. Valid foreign or noncoincident parcels are | |
| 165 | refused without consuming the joint. | |
| 166 | ||
| 167 | Capture conserves total linear/angular momentum and applies a perfectly inelastic point | |
| 168 | joint. Structural position, orientation and actual motors remain continuous; a non-co-moving | |
| 169 | parcel can impart a genuine velocity/angular-rate impulse. Captured mass changes the actual | |
| 170 | subsequent dynamics, CG and inertia. The event retains detached before/after state, incoming | |
| 171 | parcel and raw kinetic/potential energy accounting. Dissipation is mechanical energy, | |
| 172 | not battery Wh or an identified mechanism efficiency. Tiny binary64 negative loss within | |
| 173 | the reported numerical tolerance is retained rather than clamped. | |
| 174 | ||
| 175 | The joint accepts once, can later separate through the existing zero-ejection model and | |
| 176 | cannot refill after separation. The legacy `createRigidBody` carried-load interface keeps | |
| 177 | its original phase/method contract. [ADR 0036](adr/0036-point-load-intake.md) gives the model | |
| 178 | and limits. This primitive supplies no finite-duration contact forces or impact IMU sample, | |
| 179 | release/intake acknowledgement, station handling, authorization or fleet custody handover. | |
| 180 | Those integrations remain open under issue #25. | |
| 181 | ||
| 152 | 182 | ## BVLOS operations evidence |
| 153 | 183 | |
| 184 | ||
| 154 | 185 | Country and intended operation must be established separately from library support. |
| 155 | 186 | Sources below were checked October 24, 2026; inspect their current requirements for the |
| 156 | 187 | actual operation. This software issues no authorization. |
| @@ -8,53 +8,79 @@ export { rotateBodyToNed, rotateNedToBody } from '../estimation/frames' | ||
| 8 | 8 | interface RigidBodyInput { positionM?: SensorVector, motorsN?: Motors, groundDownM?: number, groundGradient?: [number, number] } |
| 9 | 9 | interface CarriedPointLoad { id: string, massKg: number, forwardM: number } |
| 10 | 10 | interface BodySnapshot { positionM: SensorVector, velocityMps: SensorVector, angularRateRadS: SensorVector, accelerationMps2: SensorVector, quaternionWxyz: Quaternion, motorsN: Motors, groundContact: boolean } |
| 11 | interface LoadProperties { id: string, phase: 'attached' | 'separated', pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector } | |
| 12 | interface LoadSnapshot { properties: LoadProperties, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 13 | interface SeparationSide { properties: LoadProperties, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 11 | type PointLoadPhase = 'awaiting' | 'attached' | 'separated' | |
| 12 | interface LoadProperties<P extends PointLoadPhase = 'attached' | 'separated'> { id: string, phase: P, pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector } | |
| 13 | interface LoadSnapshot<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 14 | interface SeparationSide<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector } | |
| 14 | 15 | type LoadSeparation = { outcome: 'separated', reason: 'prescribed-zero-ejection', before: SeparationSide, after: SeparationSide, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector } } | { outcome: 'refused', reason: 'already-separated' } |
| 15 | 16 | interface RigidBody { snapshot(): BodySnapshot, step(command: Motors, dtS: number, disturbance?: { windMps?: SensorVector, forceN?: SensorVector, torqueNm?: SensorVector }): BodySnapshot } |
| 16 | 17 | interface LoadedRigidBody extends RigidBody { loadSnapshot(): LoadSnapshot, separateCarriedLoad(): LoadSeparation } |
| 18 | export interface IntakeParcel { id: string, massKg: number, positionM: SensorVector, velocityMps: SensorVector } | |
| 19 | export type PointLoadAttachment = { | |
| 20 | outcome: 'attached' | |
| 21 | reason: 'coincident-inelastic-point-capture' | |
| 22 | before: SeparationSide<'awaiting'> | |
| 23 | after: SeparationSide<'attached'> | |
| 24 | parcel: IntakeParcel | |
| 25 | energy: { kineticBeforeJ: number, kineticAfterJ: number, dissipatedJ: number, numericalToleranceJ: number, potentialBeforeJ: number, potentialAfterJ: number } | |
| 26 | } | { outcome: 'refused', reason: 'intake-consumed' | 'unexpected-parcel' | 'mount-not-coincident' } | |
| 27 | export interface ReceivingRigidBody extends RigidBody { | |
| 28 | loadSnapshot(): LoadSnapshot<PointLoadPhase> | |
| 29 | intakePoint(): { positionM: SensorVector, velocityMps: SensorVector } | |
| 30 | attachParcel(input: unknown): PointLoadAttachment | |
| 31 | separateCarriedLoad(): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' } | |
| 32 | } | |
| 17 | 33 | function cargoCross(a: SensorVector, b: SensorVector): SensorVector { |
| 18 | 34 | return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] |
| 19 | 35 | } |
| 36 | function captureOwnRecord(value: unknown, allowed: string[], required: string[]) { | |
| 37 | if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError() | |
| 38 | const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value) | |
| 39 | if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError() | |
| 40 | const captured: Record<string, unknown> = {} | |
| 41 | for (const name of names as string[]) { | |
| 42 | const descriptor = descriptors[name]! | |
| 43 | if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError() | |
| 44 | captured[name] = descriptor.value | |
| 45 | } | |
| 46 | return captured | |
| 47 | } | |
| 48 | function captureFiniteTuple(value: unknown, length: number, min: number, max: number) { | |
| 49 | if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError() | |
| 50 | const captured: number[] = [] | |
| 51 | for (let i = 0; i < length; i++) { | |
| 52 | const descriptor = Object.getOwnPropertyDescriptor(value, String(i)) | |
| 53 | if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError() | |
| 54 | captured.push(descriptor.value) | |
| 55 | } | |
| 56 | return captured | |
| 57 | } | |
| 58 | function captureIntakeParcel(input: unknown): IntakeParcel { | |
| 59 | try { | |
| 60 | const values = captureOwnRecord(input, ['id', 'massKg', 'positionM', 'velocityMps'], ['id', 'massKg', 'positionM', 'velocityMps']) | |
| 61 | values.positionM = captureFiniteTuple(values.positionM, 3, -1e6, 1e6) | |
| 62 | values.velocityMps = captureFiniteTuple(values.velocityMps, 3, -1e6, 1e6) | |
| 63 | if (typeof values.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(values.id) || typeof values.massKg !== 'number' || !Number.isFinite(values.massKg) || values.massKg <= 0 || values.massKg > .25) throw new TypeError() | |
| 64 | return values as unknown as IntakeParcel | |
| 65 | } | |
| 66 | catch { throw new TypeError('cargo-intake-parcel') } | |
| 67 | } | |
| 20 | 68 | /** New opt-in records are captured as exact own data. The absent-own-member path is deliberately legacy. */ |
| 21 | function captureCarriedPointLoad(input: unknown): { input: RigidBodyInput, load: CarriedPointLoad } | null { | |
| 69 | function captureCarriedPointLoad(input: unknown, member: 'carriedPointLoad' | 'expectedPointLoad' = 'carriedPointLoad'): { input: RigidBodyInput, load: CarriedPointLoad } | null { | |
| 22 | 70 | try { |
| 23 | if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, 'carriedPointLoad')) return null | |
| 24 | function record(value: unknown, allowed: string[], required: string[]) { | |
| 25 | if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError() | |
| 26 | const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value) | |
| 27 | if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError() | |
| 28 | const captured: Record<string, unknown> = {} | |
| 29 | for (const name of names as string[]) { | |
| 30 | const descriptor = descriptors[name]! | |
| 31 | if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError() | |
| 32 | captured[name] = descriptor.value | |
| 33 | } | |
| 34 | return captured | |
| 35 | } | |
| 36 | function tuple(value: unknown, length: number, min: number, max: number) { | |
| 37 | if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError() | |
| 38 | const captured: number[] = [] | |
| 39 | for (let i = 0; i < length; i++) { | |
| 40 | const descriptor = Object.getOwnPropertyDescriptor(value, String(i)) | |
| 41 | if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError() | |
| 42 | captured.push(descriptor.value) | |
| 43 | } | |
| 44 | return captured | |
| 45 | } | |
| 46 | const captured = record(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', 'carriedPointLoad'], ['carriedPointLoad']) | |
| 47 | const load = record(captured.carriedPointLoad, ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM']) | |
| 71 | if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, member)) return null | |
| 72 | const captured = captureOwnRecord(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', member], [member]) | |
| 73 | const load = captureOwnRecord(captured[member], ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM']) | |
| 48 | 74 | if (typeof load.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(load.id) || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > 0.25 || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -0.1 || load.forwardM > 0.1) throw new TypeError() |
| 49 | 75 | const body: RigidBodyInput = {} |
| 50 | if (Object.hasOwn(captured, 'positionM')) body.positionM = tuple(captured.positionM, 3, -1e6, 1e6) as SensorVector | |
| 51 | if (Object.hasOwn(captured, 'motorsN')) body.motorsN = tuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors | |
| 76 | if (Object.hasOwn(captured, 'positionM')) body.positionM = captureFiniteTuple(captured.positionM, 3, -1e6, 1e6) as SensorVector | |
| 77 | if (Object.hasOwn(captured, 'motorsN')) body.motorsN = captureFiniteTuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors | |
| 52 | 78 | if (Object.hasOwn(captured, 'groundDownM')) { |
| 53 | 79 | if (typeof captured.groundDownM !== 'number' || !Number.isFinite(captured.groundDownM) || captured.groundDownM < -1 || captured.groundDownM > 0) throw new TypeError() |
| 54 | 80 | body.groundDownM = captured.groundDownM |
| 55 | 81 | } |
| 56 | 82 | if (Object.hasOwn(captured, 'groundGradient')) { |
| 57 | body.groundGradient = tuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number] | |
| 83 | body.groundGradient = captureFiniteTuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number] | |
| 58 | 84 | if (Math.hypot(...body.groundGradient) > 0.1) throw new TypeError() |
| 59 | 85 | } |
| 60 | 86 | return { input: body, load: { id: load.id, massKg: load.massKg, forwardM: load.forwardM } } |
| @@ -66,6 +92,18 @@ export function createRigidBody(input?: RigidBodyInput): RigidBody | ||
| 66 | 92 | export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: CarriedPointLoad } = {}): RigidBody | LoadedRigidBody { |
| 67 | 93 | const configured = captureCarriedPointLoad(input) |
| 68 | 94 | if (configured) input = configured.input |
| 95 | // This factory never exposes the awaiting phase or the intake operation. | |
| 96 | return createBody(input, configured, false) as RigidBody | LoadedRigidBody | |
| 97 | } | |
| 98 | ||
| 99 | /** An empty declared point joint. Physical world/policy ownership of the parcel belongs to the caller. */ | |
| 100 | export function createReceivingRigidBody(input: RigidBodyInput & { expectedPointLoad: CarriedPointLoad }): ReceivingRigidBody { | |
| 101 | const configured = captureCarriedPointLoad(input, 'expectedPointLoad') | |
| 102 | if (!configured) throw new TypeError('cargo-receiver-constructor') | |
| 103 | return createBody(configured.input, configured, true) as ReceivingRigidBody | |
| 104 | } | |
| 105 | ||
| 106 | function createBody(input: RigidBodyInput, configured: ReturnType<typeof captureCarriedPointLoad>, receiving: boolean): RigidBody | LoadedRigidBody | ReceivingRigidBody { | |
| 69 | 107 | |
| 70 | 108 | const groundDownM = input.groundDownM ?? 0 |
| 71 | 109 | const groundGradient = [...(input.groundGradient ?? [0, 0])] |
| @@ -78,11 +116,12 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car | ||
| 78 | 116 | let velocityMps: SensorVector = [0, 0, 0], angularRateRadS: SensorVector = [0, 0, 0], accelerationMps2: SensorVector = [0, 0, 0] |
| 79 | 117 | let quaternionWxyz: Quaternion = [1, 0, 0, 0], groundContact = false |
| 80 | 118 | function snapshot() { return structuredClone({ positionM, velocityMps, angularRateRadS, accelerationMps2, quaternionWxyz, motorsN, groundContact }) } |
| 81 | let attached = true | |
| 82 | function properties(): LoadProperties { | |
| 119 | let phase: PointLoadPhase = receiving ? 'awaiting' : 'attached' | |
| 120 | function properties(selectedPhase: PointLoadPhase = phase): LoadProperties<PointLoadPhase> { | |
| 121 | const attached = selectedPhase === 'attached' | |
| 83 | 122 | const load = configured!.load, totalMassKg = attached ? model.massKg + load.massKg : model.massKg |
| 84 | 123 | const shift = attached ? model.massKg * load.massKg / totalMassKg * load.forwardM ** 2 : 0 |
| 85 | return { id: load.id, phase: attached ? 'attached' : 'separated', pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] } | |
| 124 | return { id: load.id, phase: selectedPhase, pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] } | |
| 86 | 125 | } |
| 87 | 126 | let centerPositionM: SensorVector | null = configured ? positionM.map((n, i) => n + properties().cgBodyM[i]!) as SensorVector : null |
| 88 | 127 | let centerVelocityMps: SensorVector | null = configured ? [...velocityMps] : null |
| @@ -140,19 +179,50 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car | ||
| 140 | 179 | }, |
| 141 | 180 | } |
| 142 | 181 | if (!configured) return body |
| 143 | function loadSnapshot(): LoadSnapshot { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } } | |
| 144 | function side(): SeparationSide { | |
| 182 | function loadSnapshot(): LoadSnapshot<PointLoadPhase> { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } } | |
| 183 | function side(): SeparationSide<PointLoadPhase> { | |
| 145 | 184 | return { properties: properties(), bodyPositionM: [...positionM], bodyVelocityMps: [...velocityMps], quaternionWxyz: [...quaternionWxyz], angularRateRadS: [...angularRateRadS], actualMotorsN: [...motorsN], centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } |
| 146 | 185 | } |
| 147 | function separateCarriedLoad(...args: unknown[]): LoadSeparation { | |
| 186 | function separateCarriedLoad(...args: unknown[]): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' } { | |
| 148 | 187 | if (args.length !== 0) throw new TypeError('cargo-load-separation-arguments') |
| 149 | if (!attached) return Object.freeze({ outcome: 'refused', reason: 'already-separated' }) | |
| 188 | if (phase === 'awaiting') return Object.freeze({ outcome: 'refused', reason: 'awaiting-load' }) | |
| 189 | if (phase === 'separated') return Object.freeze({ outcome: 'refused', reason: 'already-separated' }) | |
| 150 | 190 | const before = side(), r: SensorVector = [configured!.load.forwardM, 0, 0] |
| 151 | 191 | const positionLever = rotateBodyToNed(quaternionWxyz, r), velocityLever = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r)) |
| 152 | 192 | const parcel = { massKg: configured!.load.massKg, positionM: positionM.map((n, i) => n + positionLever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + velocityLever[i]!) as SensorVector } |
| 153 | attached = false; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps] | |
| 154 | return { outcome: 'separated', reason: 'prescribed-zero-ejection', before, after: side(), parcel } | |
| 193 | phase = 'separated'; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps] | |
| 194 | return { outcome: 'separated', reason: 'prescribed-zero-ejection', before: before as SeparationSide, after: side() as SeparationSide, parcel } | |
| 195 | } | |
| 196 | if (!receiving) return { ...body, loadSnapshot, separateCarriedLoad } as LoadedRigidBody | |
| 197 | function intakePoint(): { positionM: SensorVector, velocityMps: SensorVector } { | |
| 198 | const r: SensorVector = [configured!.load.forwardM, 0, 0], lever = rotateBodyToNed(quaternionWxyz, r), rate = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r)) | |
| 199 | return { positionM: positionM.map((n, i) => n + lever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + rate[i]!) as SensorVector } | |
| 200 | } | |
| 201 | function attachParcel(input: unknown, ...extra: unknown[]): PointLoadAttachment { | |
| 202 | if (extra.length) throw new TypeError('cargo-intake-parcel') | |
| 203 | const parcel = captureIntakeParcel(input) | |
| 204 | if (phase !== 'awaiting') return { outcome: 'refused', reason: 'intake-consumed' } | |
| 205 | const mount = intakePoint() | |
| 206 | if (parcel.id !== configured!.load.id || parcel.massKg !== configured!.load.massKg) return { outcome: 'refused', reason: 'unexpected-parcel' } | |
| 207 | if (!parcel.positionM.every((n, i) => n === mount.positionM[i])) return { outcome: 'refused', reason: 'mount-not-coincident' } | |
| 208 | const before = side() as SeparationSide<'awaiting'>, afterProperties = properties('attached'), total = afterProperties.totalMassKg | |
| 209 | const c = afterProperties.cgBodyM, inertia = afterProperties.inertiaDiagonalKgM2, reduced = model.massKg * parcel.massKg / total | |
| 210 | const relative = rotateNedToBody(quaternionWxyz, parcel.velocityMps.map((n, i) => n - velocityMps[i]!) as SensorVector) | |
| 211 | const transfer = cargoCross([configured!.load.forwardM, 0, 0], relative) | |
| 212 | const nextRate = angularRateRadS.map((n, i) => (model.inertiaKgM2[i]! * n + reduced * transfer[i]!) / inertia[i]!) as SensorVector | |
| 213 | const nextCenterVelocity = velocityMps.map((n, i) => (model.massKg * n + parcel.massKg * parcel.velocityMps[i]!) / total) as SensorVector | |
| 214 | const lever = rotateBodyToNed(quaternionWxyz, c), rateLever = rotateBodyToNed(quaternionWxyz, cargoCross(nextRate, c)) | |
| 215 | const nextCenterPosition = positionM.map((n, i) => n + lever[i]!) as SensorVector | |
| 216 | const nextVelocity = nextCenterVelocity.map((n, i) => n - rateLever[i]!) as SensorVector | |
| 217 | const square = (v: SensorVector) => v.reduce((sum, n) => sum + n * n, 0) | |
| 218 | const kineticBeforeJ = .5 * model.massKg * square(velocityMps) + .5 * angularRateRadS.reduce((sum, n, i) => sum + model.inertiaKgM2[i]! * n * n, 0) + .5 * parcel.massKg * square(parcel.velocityMps) | |
| 219 | const kineticAfterJ = .5 * total * square(nextCenterVelocity) + .5 * nextRate.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0) | |
| 220 | const dissipatedJ = kineticBeforeJ - kineticAfterJ, numericalToleranceJ = 64 * Number.EPSILON * Math.max(1, kineticBeforeJ, kineticAfterJ) | |
| 221 | const potentialBeforeJ = -model.gravityMps2 * (model.massKg * positionM[2] + parcel.massKg * parcel.positionM[2]), potentialAfterJ = -model.gravityMps2 * total * nextCenterPosition[2] | |
| 222 | if (![...nextRate, ...nextCenterVelocity, ...nextCenterPosition, ...nextVelocity, kineticBeforeJ, kineticAfterJ, dissipatedJ, potentialBeforeJ, potentialAfterJ].every(Number.isFinite) || dissipatedJ < -numericalToleranceJ) throw new RangeError('cargo-intake-numerical-state') | |
| 223 | phase = 'attached'; angularRateRadS = nextRate; velocityMps = nextVelocity; centerPositionM = nextCenterPosition; centerVelocityMps = nextCenterVelocity | |
| 224 | return { outcome: 'attached', reason: 'coincident-inelastic-point-capture', before, after: side() as SeparationSide<'attached'>, parcel, energy: { kineticBeforeJ, kineticAfterJ, dissipatedJ, numericalToleranceJ, potentialBeforeJ, potentialAfterJ } } | |
| 155 | 225 | } |
| 156 | return { ...body, loadSnapshot, separateCarriedLoad } | |
| 226 | return { ...body, loadSnapshot, separateCarriedLoad, intakePoint, attachParcel } | |
| 157 | 227 | } |
| 158 | 228 | export type RigidBodyState = ReturnType<ReturnType<typeof createRigidBody>['snapshot']> |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | "./unit/controls.test.ts", |
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | "./unit/cargo-intake.test.ts", | |
| 26 | 27 | "./unit/cargo-delivery.test.ts", |
| 27 | 28 | "./unit/cargo-delivery-config.test.ts", |
| 28 | 29 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -0,0 +1,167 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics' | |
| 3 | import type { SensorVector } from '../../resources/functions/drone/sensors/types' | |
| 4 | import type { Quaternion } from '../../resources/functions/drone/estimation/frames' | |
| 5 | ||
| 6 | const plus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n + b[i]!) as SensorVector | |
| 7 | const minus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n - b[i]!) as SensorVector | |
| 8 | const scale = (a: SensorVector, n: number) => a.map(v => v * n) as SensorVector | |
| 9 | const cross = (a: SensorVector, b: SensorVector): SensorVector => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] | |
| 10 | const dot = (a: SensorVector, b: SensorVector) => a.reduce((sum, n, i) => sum + n * b[i]!, 0) | |
| 11 | function rotate([w, x, y, z]: Quaternion, v: SensorVector): SensorVector { | |
| 12 | return [[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]].map(row => row.reduce((sum, n, i) => sum + n * v[i]!, 0)) as SensorVector | |
| 13 | } | |
| 14 | function closeVector(actual: SensorVector, expected: SensorVector) { expect(Math.max(...minus(actual, expected).map(Math.abs))).toBeLessThanOrEqual(2e-12) } | |
| 15 | function mechanical(state: ReturnType<ReturnType<typeof createReceivingRigidBody>['snapshot']>, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector }) { | |
| 16 | const origin: SensorVector = [7, -3, 5], inertia: SensorVector = [.03, .03, .05] | |
| 17 | const baseP = scale(state.velocityMps, 1.5), parcelP = scale(parcel.velocityMps, parcel.massKg) | |
| 18 | const spin = rotate(state.quaternionWxyz, state.angularRateRadS.map((n, i) => n * inertia[i]!) as SensorVector) | |
| 19 | return { momentum: plus(baseP, parcelP), angular: plus(spin, plus(cross(minus(state.positionM, origin), baseP), cross(minus(parcel.positionM, origin), parcelP))), | |
| 20 | kinetic: .75 * dot(state.velocityMps, state.velocityMps) + .5 * state.angularRateRadS.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0) + .5 * parcel.massKg * dot(parcel.velocityMps, parcel.velocityMps), | |
| 21 | potential: -9.80665 * (1.5 * state.positionM[2] + parcel.massKg * parcel.positionM[2]) } | |
| 22 | } | |
| 23 | ||
| 24 | // Actual physics seam: proves point-load capture and state continuity, not a | |
| 25 | // mechanism acknowledgement, vehicle authority or fleet custody protocol. | |
| 26 | test('a receiving body captures one coincident point mass with the worked inelastic momentum result', () => { | |
| 27 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 28 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 29 | expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5) | |
| 30 | const before = body.snapshot(), point = body.intakePoint() | |
| 31 | expect(point.positionM).toEqual([.08, 0, -1]) | |
| 32 | const event = body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] }) | |
| 33 | expect(event.outcome).toBe('attached') | |
| 34 | if (event.outcome !== 'attached') throw new Error('Expected actual modeled capture') | |
| 35 | expect(event.before.properties.phase).toBe('awaiting') | |
| 36 | expect(event.after.properties.phase).toBe('attached') | |
| 37 | expect(event.after.properties.totalMassKg).toBe(1.6) | |
| 38 | expect(event.after.properties.cgBodyM).toEqual([.005, 0, 0]) | |
| 39 | // Rational expected values allow the binary64 accumulation error, not a model change. | |
| 40 | for (const [i, expected] of [.03, .0306, .0506].entries()) expect(event.after.properties.inertiaDiagonalKgM2[i]!).toBeCloseTo(expected, 14) | |
| 41 | expect(body.snapshot().angularRateRadS[2]).toBeCloseTo(.1482213438735178, 13) | |
| 42 | expect(body.snapshot().velocityMps[1]).toBeCloseTo(.06175889328063241, 13) | |
| 43 | expect(event.energy.kineticBeforeJ).toBeCloseTo(.05, 14) | |
| 44 | expect(event.energy.kineticAfterJ).toBeCloseTo(.003680830039525692, 14) | |
| 45 | expect(event.energy.dissipatedJ).toBeCloseTo(.04631916996047431, 14) | |
| 46 | expect(body.snapshot().positionM).toEqual(before.positionM) | |
| 47 | expect(body.snapshot().quaternionWxyz).toEqual(before.quaternionWxyz) | |
| 48 | expect(body.snapshot().motorsN).toEqual(before.motorsN) | |
| 49 | expect(body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] })).toEqual({ outcome: 'refused', reason: 'intake-consumed' }) | |
| 50 | const separated = body.separateCarriedLoad() | |
| 51 | expect(separated.outcome).toBe('separated') | |
| 52 | if (separated.outcome !== 'separated') throw new Error('Expected actual subsequent release') | |
| 53 | expect(mechanical(body.snapshot(), separated.parcel).kinetic).toBeCloseTo(.003680830039525692, 14) | |
| 54 | }) | |
| 55 | ||
| 56 | test('rotated captures conserve world momentum about an independent origin and dissipate relative motion', () => { | |
| 57 | for (const massKg of [.01, .1, .25]) for (const forwardM of [-.1, 0, .1]) { | |
| 58 | const body = createReceivingRigidBody({ positionM: [1, -2, -1], motorsN: [2, 2, 2, 2], expectedPointLoad: { id: 'parcel-a', massKg, forwardM } }) | |
| 59 | for (let i = 0; i < 40; i++) body.step([3, 2.9, 3.1, 3], .004, { windMps: [.2, -.1, 0], torqueNm: [.02, -.01, .03] }) | |
| 60 | const before = body.snapshot(), point = body.intakePoint() | |
| 61 | const parcel = { id: 'parcel-a', massKg, ...point, velocityMps: plus(point.velocityMps, [.2, -.3, .1]) } | |
| 62 | closeVector(point.positionM, plus(before.positionM, rotate(before.quaternionWxyz, [forwardM, 0, 0]))) | |
| 63 | const initial = mechanical(before, parcel), event = body.attachParcel(parcel) | |
| 64 | expect(event.outcome).toBe('attached') | |
| 65 | if (event.outcome !== 'attached') throw new Error('Expected rotated capture') | |
| 66 | const after = body.snapshot(), afterPoint = { massKg, positionM: plus(after.positionM, rotate(after.quaternionWxyz, [forwardM, 0, 0])), velocityMps: plus(after.velocityMps, rotate(after.quaternionWxyz, cross(after.angularRateRadS, [forwardM, 0, 0]))) } | |
| 67 | const final = mechanical(after, afterPoint) | |
| 68 | closeVector(final.momentum, initial.momentum) | |
| 69 | closeVector(final.angular, initial.angular) | |
| 70 | expect(Math.abs(final.potential - initial.potential)).toBeLessThanOrEqual(2e-12) | |
| 71 | expect(initial.kinetic - final.kinetic).toBeGreaterThan(0) | |
| 72 | expect(event.energy.kineticBeforeJ).toBeCloseTo(initial.kinetic, 12) | |
| 73 | expect(event.energy.kineticAfterJ).toBeCloseTo(final.kinetic, 12) | |
| 74 | expect(event.energy.dissipatedJ).toBeCloseTo(initial.kinetic - final.kinetic, 12) | |
| 75 | expect(event.energy.potentialBeforeJ).toBeCloseTo(initial.potential, 12) | |
| 76 | expect(event.energy.potentialAfterJ).toBeCloseTo(final.potential, 12) | |
| 77 | expect(after.positionM).toEqual(before.positionM) | |
| 78 | expect(after.quaternionWxyz).toEqual(before.quaternionWxyz) | |
| 79 | expect(after.motorsN).toEqual(before.motorsN) | |
| 80 | event.after.bodyVelocityMps[0] = 999; event.parcel.positionM[0] = 999 | |
| 81 | expect(body.snapshot()).toEqual(after) | |
| 82 | } | |
| 83 | }) | |
| 84 | ||
| 85 | test('co-moving intake preserves body motion through a later separation', () => { | |
| 86 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: -.08 } }) | |
| 87 | for (let i = 0; i < 30; i++) body.step([4, 3.8, 4.1, 4], .004, { torqueNm: [.01, -.02, .03] }) | |
| 88 | const before = body.snapshot(), parcel = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }, event = body.attachParcel(parcel) | |
| 89 | expect(event.outcome).toBe('attached') | |
| 90 | if (event.outcome !== 'attached') throw new Error('Expected co-moving capture') | |
| 91 | closeVector(body.snapshot().velocityMps, before.velocityMps) | |
| 92 | closeVector(body.snapshot().angularRateRadS, before.angularRateRadS) | |
| 93 | expect(Math.abs(event.energy.dissipatedJ)).toBeLessThanOrEqual(2e-12) | |
| 94 | const separated = body.separateCarriedLoad() | |
| 95 | expect(separated.outcome).toBe('separated') | |
| 96 | if (separated.outcome !== 'separated') throw new Error('Expected subsequent separation') | |
| 97 | closeVector(separated.parcel.positionM, parcel.positionM) | |
| 98 | closeVector(separated.parcel.velocityMps, parcel.velocityMps) | |
| 99 | closeVector(body.snapshot().velocityMps, before.velocityMps) | |
| 100 | closeVector(body.snapshot().angularRateRadS, before.angularRateRadS) | |
| 101 | expect(body.loadSnapshot().properties.phase).toBe('separated') | |
| 102 | expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5) | |
| 103 | expect(body.attachParcel(parcel)).toEqual({ outcome: 'refused', reason: 'intake-consumed' }) | |
| 104 | expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'already-separated' }) | |
| 105 | }) | |
| 106 | ||
| 107 | test('the actual subsequent acceleration uses the received mass and the empty receiver has no phantom load', () => { | |
| 108 | for (const attached of [false, true]) { | |
| 109 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } }) | |
| 110 | if (attached) expect(body.attachParcel({ id: 'parcel-a', massKg: .1, ...body.intakePoint() }).outcome).toBe('attached') | |
| 111 | const state = body.step([4, 4, 4, 4], .004), mass = attached ? 1.6 : 1.5 | |
| 112 | const thrust = 16 * (1 - Math.exp(-.004 / .04)) | |
| 113 | expect(state.accelerationMps2[2]).toBeCloseTo(9.80665 - thrust / mass, 12) | |
| 114 | expect(body.loadSnapshot().properties.totalMassKg).toBe(mass) | |
| 115 | } | |
| 116 | }) | |
| 117 | ||
| 118 | test('intake refuses foreign or noncoincident parcels without consuming or moving the body', () => { | |
| 119 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 120 | const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 121 | expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'awaiting-load' }) | |
| 122 | for (const candidate of [{ ...point, id: 'parcel-b' }, { ...point, massKg: .2 }]) expect(body.attachParcel(candidate)).toEqual({ outcome: 'refused', reason: 'unexpected-parcel' }) | |
| 123 | expect(body.attachParcel({ ...point, positionM: [.081, 0, -1] })).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' }) | |
| 124 | expect(body.snapshot()).toEqual(before) | |
| 125 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 126 | expect(body.attachParcel(point).outcome).toBe('attached') | |
| 127 | }) | |
| 128 | ||
| 129 | test('intake captures exact own data and malformed input cannot evaluate getters or mutate state', () => { | |
| 130 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } }) | |
| 131 | const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 132 | let called = false | |
| 133 | const accessor = { ...point, get velocityMps() { called = true; return [0, 0, 0] } } | |
| 134 | const tuple = [0, 0, 0]; Object.defineProperty(tuple, '0', { enumerable: true, get() { called = true; return 0 } }) | |
| 135 | for (const value of [null, { ...point, extra: true }, { ...point, massKg: NaN }, { ...point, velocityMps: [0, Infinity, 0] }, accessor, { ...point, velocityMps: tuple }, { ...point, positionM: [, 0, -1] }]) expect(() => body.attachParcel(value)).toThrow('cargo-intake-parcel') | |
| 136 | const proxy = new Proxy(point, { ownKeys() { throw new Error('private-caller-sentinel') } }) | |
| 137 | expect(() => body.attachParcel(proxy)).toThrow(/^cargo-intake-parcel$/) | |
| 138 | expect(() => Reflect.apply(body.attachParcel, body, [point, true])).toThrow('cargo-intake-parcel') | |
| 139 | expect(called).toBe(false) | |
| 140 | expect(body.snapshot()).toEqual(before) | |
| 141 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 142 | expect(() => createReceivingRigidBody({ expectedPointLoad: { get id() { called = true; return 'parcel-a' }, massKg: .1, forwardM: 0 } })).toThrow() | |
| 143 | expect(called).toBe(false) | |
| 144 | expect(body.attachParcel(point).outcome).toBe('attached') | |
| 145 | }) | |
| 146 | ||
| 147 | test('joint contact is current physical state and the planned identity is captured once', () => { | |
| 148 | const expected = { id: 'parcel-a', massKg: .1, forwardM: .08 } | |
| 149 | const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: expected }) | |
| 150 | const stale = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 151 | expected.id = 'parcel-b'; expected.massKg = .2; expected.forwardM = -.08 | |
| 152 | body.step([0, 0, 0, 0], .004) | |
| 153 | expect(body.attachParcel(stale)).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' }) | |
| 154 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 155 | const current = { id: 'parcel-a', massKg: .1, ...body.intakePoint() } | |
| 156 | expect(body.attachParcel(current).outcome).toBe('attached') | |
| 157 | expect(body.loadSnapshot().properties.forwardM).toBe(.08) | |
| 158 | }) | |
| 159 | ||
| 160 | test('legacy body factories keep their original methods and attached/separated phases', () => { | |
| 161 | expect(Object.keys(createRigidBody())).toEqual(['snapshot', 'step']) | |
| 162 | const legacy = createRigidBody({ carriedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 163 | expect(Object.keys(legacy)).toEqual(['snapshot', 'step', 'loadSnapshot', 'separateCarriedLoad']) | |
| 164 | expect(legacy.loadSnapshot().properties.phase).toBe('attached') | |
| 165 | expect(legacy.separateCarriedLoad().outcome).toBe('separated') | |
| 166 | expect(legacy.loadSnapshot().properties.phase).toBe('separated') | |
| 167 | }) | |