From 6bea4abd85e71201413e7ec040684bc7db2adb4a Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:36:04 +0000 Subject: [PATCH 1/4] feat(res68): close explosive test family authority --- ...68-DR-001-explosive-test-family-closure.md | 419 +++++++ docs/decisions/RES68-RECEIPT.json | 84 ++ .../measurement/bench_press_throw/__init__.py | 20 + .../bench_press_throw/comparability.py | 369 ++++++ .../measurement/bench_press_throw/identity.py | 616 ++++++++++ .../measurement/bench_press_throw/metrics.py | 696 +++++++++++ .../bench_press_throw/qualification.py | 767 ++++++++++++ .../measurement/bench_press_throw/registry.py | 187 +++ .../measurement/drop_jump/__init__.py | 23 + .../measurement/drop_jump/comparability.py | 387 ++++++ src/dynamislm/measurement/drop_jump/events.py | 501 ++++++++ .../measurement/drop_jump/identity.py | 472 ++++++++ .../measurement/drop_jump/metrics.py | 1004 ++++++++++++++++ .../measurement/drop_jump/qualification.py | 426 +++++++ .../measurement/drop_jump/registry.py | 193 +++ .../medicine_ball_throw/__init__.py | 20 + .../medicine_ball_throw/comparability.py | 370 ++++++ .../medicine_ball_throw/identity.py | 506 ++++++++ .../medicine_ball_throw/metrics.py | 581 +++++++++ .../medicine_ball_throw/qualification.py | 768 ++++++++++++ .../medicine_ball_throw/registry.py | 139 +++ tests/test_explosive_test_families.py | 1057 +++++++++++++++++ 22 files changed, 9605 insertions(+) create mode 100644 docs/decisions/RES68-DR-001-explosive-test-family-closure.md create mode 100644 docs/decisions/RES68-RECEIPT.json create mode 100644 src/dynamislm/measurement/bench_press_throw/__init__.py create mode 100644 src/dynamislm/measurement/bench_press_throw/comparability.py create mode 100644 src/dynamislm/measurement/bench_press_throw/identity.py create mode 100644 src/dynamislm/measurement/bench_press_throw/metrics.py create mode 100644 src/dynamislm/measurement/bench_press_throw/qualification.py create mode 100644 src/dynamislm/measurement/bench_press_throw/registry.py create mode 100644 src/dynamislm/measurement/drop_jump/__init__.py create mode 100644 src/dynamislm/measurement/drop_jump/comparability.py create mode 100644 src/dynamislm/measurement/drop_jump/events.py create mode 100644 src/dynamislm/measurement/drop_jump/identity.py create mode 100644 src/dynamislm/measurement/drop_jump/metrics.py create mode 100644 src/dynamislm/measurement/drop_jump/qualification.py create mode 100644 src/dynamislm/measurement/drop_jump/registry.py create mode 100644 src/dynamislm/measurement/medicine_ball_throw/__init__.py create mode 100644 src/dynamislm/measurement/medicine_ball_throw/comparability.py create mode 100644 src/dynamislm/measurement/medicine_ball_throw/identity.py create mode 100644 src/dynamislm/measurement/medicine_ball_throw/metrics.py create mode 100644 src/dynamislm/measurement/medicine_ball_throw/qualification.py create mode 100644 src/dynamislm/measurement/medicine_ball_throw/registry.py create mode 100644 tests/test_explosive_test_families.py diff --git a/docs/decisions/RES68-DR-001-explosive-test-family-closure.md b/docs/decisions/RES68-DR-001-explosive-test-family-closure.md new file mode 100644 index 0000000..d7c23a0 --- /dev/null +++ b/docs/decisions/RES68-DR-001-explosive-test-family-closure.md @@ -0,0 +1,419 @@ +# RES68-DR-001 — Remaining explosive-test family closure + +DECISION_ID=RES68-DR-001 +STATUS=DESIGN_FIX_PASS +MISSION=RES-68-DESIGN-FIX-AND-IMPLEMENTATION-001 +PHASE_A_DESIGN_FIX=PASS +PHASE_B_IMPLEMENTATION=GATED_AFTER_THIS_RECORD +SERIALIZATION_VERSION=3 +IMPLEMENTATION_STARTED=NO_AT_FREEZE + +## Exact mission boundary + +This decision freezes the additive P1 scientific authority for three separate +families: + +1. Drop Jump (DJ): ground-contact time, rebound flight time, flight-time + jump-height estimate, JH/CT reactive strength index, and flight-time/contact + time ratio. +2. Bench Press Throw (BPT): sampled maximum bar velocity and a DynamisLM + time-weighted mean bar velocity over exact registered support; provider MV, + Vmax and MPV representation boundaries; and power deferral. +3. Medicine-Ball Throw (MBT): exact protocol-scoped throw distance and + instrumented release velocity from qualified trajectory evidence. + +This decision does not create a shared `ExplosiveTestProtocol`, +`ExplosiveTestEvent`, `ExplosiveTestSeries`, or `ExplosivenessScore`. The three +families reuse generic kernel contracts only. DJ is not a CMJ subtype, BPT is +not a `VBTMeasurementIdentity`, and MBT is neither external-load nor field-test +aliasing. + +No readiness, fatigue, injury, training-prescription, normative-score, causal, +model-training, GPU, or generic “explosiveness” authority is created. + +## A. Corrected evidence matrix + +| Evidence | Actual protocol/population/method inspected | Direct or supporting attribution | Frozen consequence | Limit | +| --- | --- | --- | --- | --- | +| McMahon, Lake, Stratford & Comfort (2021), *A Proposed Method for Evaluating Drop Jump Performance with One Force Platform*, DOI [10.3390/biomechanics1020015](https://doi.org/10.3390/biomechanics1020015) | 26 young male sports students; 0.30 m and 0.40 m boxes; adjacent force platforms; touchdown, takeoff, actual drop height and flight-time DJ variables | DIRECT DROP-JUMP EVIDENCE | Box height and realized drop height differ; force-platform DJ event/metric protocol can be registered; contact time, flight time and flight-time JH/CT are valid candidate outputs when their own event evidence is present | Does not establish a universal device, threshold, posture, or population bridge | +| Baca (1999), *A comparison of methods for analyzing drop jump performance*, PMID [10188749](https://pubmed.ncbi.nlm.nih.gov/10188749/) | Drop-jump method comparison including flight-time height and drop-height assumptions | DIRECT DROP-JUMP EVIDENCE | Flight-time height is a method-defined takeoff-to-landing estimator, not a universal jump-height identity | Method/device details are not generalized beyond the inspected protocol | +| Healy, Kenny & Harrison (2018), *Reactive Strength Index: A Poor Indicator of Reactive Strength?*, PMID [29182434](https://pubmed.ncbi.nlm.nih.gov/29182434/), DOI [10.1123/ijspp.2017-0511](https://doi.org/10.1123/ijspp.2017-0511) | 28 national/international sprinters; 0.30 m DJ; force-platform CT, flight time, JH, RSI=JH/CT and RSR=flight-time/CT | DIRECT DROP-JUMP EVIDENCE | RSI JH/CT and RSR FT/CT are distinct registered quantities despite high correlation; strategy and threshold/identity context matter | Reliability/correlation does not establish equivalence, universal thresholds, or interchangeability | +| Smith, Lamont & Barefoot (2024), *Comparison of Different Take-off Thresholds When Assessing Vertical Jump Performance*, DOI [10.70252/QBUA4521](https://doi.org/10.70252/QBUA4521) | 21 participants performing vertical jumps; 20 N, 10 N, 5 N, 1 N, 5SD and peak-residual-force takeoff thresholds | SUPPORTING GENERAL VERTICAL-JUMP/EVENT-DETECTION EVIDENCE | Threshold choice and signal noise belong in event-method identity; this is not direct DJ protocol evidence | Not a drop-jump-specific study; it cannot be cited as direct DJ validation | +| Xu et al. (2023), *A Systematic Review of the Different Calculation Methods for Measuring Jump Height During the Countermovement and Drop Jump Tests*, PMID [36940054](https://pubmed.ncbi.nlm.nih.gov/36940054/), DOI [10.1007/s40279-023-01828-x](https://doi.org/10.1007/s40279-023-01828-x) | Systematic review of CMJ/DJ height methods and equipment | SUPPORTING GENERAL JUMP-METHOD EVIDENCE | Flight time, impulse-momentum and double-integration methods have different estimands/assumptions; methods must not be collapsed | Review-level method comparison is not a DJ device-validation bridge | +| Eythorsdottir et al. (2024), *The Battle of the Equations: A Systematic Review of Jump Height Calculations Using Force Platforms*, DOI [10.1007/s40279-024-02098-x](https://doi.org/10.1007/s40279-024-02098-x) | Force-platform equations across CMJ, SJ, DJ and loaded jumps | SUPPORTING GENERAL JUMP-METHOD EVIDENCE | The same jump can yield materially different values under FT, ToV, ToV+D and DIS; jump type, purpose and definition govern method choice | It does not authorize cross-method equality | +| Eythorsdottir et al. (2026), *Navigating the Data Processing Maze: A Systematic Review of Jump Height Calculations Using Force Platforms*, PMID [41672931](https://pubmed.ncbi.nlm.nih.gov/41672931/), DOI [10.1002/ejsc.70114](https://doi.org/10.1002/ejsc.70114) | Current review of sampling, filtering, body-weight period, integration, event thresholds and gravity choices for force-platform jump height | SUPPORTING GENERAL JUMP-METHOD EVIDENCE | Processing, gravity and event identity are material serialized dimensions; no hidden defaults | Review does not turn any one processing choice into universal DJ authority | +| García-Ramos et al. (2018), *Assessment of Upper-Body Ballistic Performance Through the Bench Press Throw Exercise: Which Velocity Outcome Provides the Highest Reliability?*, PMID [29847530](https://pubmed.ncbi.nlm.nih.gov/29847530/), DOI [10.1519/JSC.0000000000002616](https://doi.org/10.1519/JSC.0000000000002616) | 21 men; concentric-only and eccentric-concentric BPT; five loads; T-Force velocity output; MV, MPV and Vmax | DIRECT BPT EVIDENCE | MV, MPV and Vmax have separate support definitions. Traditional MV runs from first positive bar velocity to maximum height/zero velocity and can include post-release upward travel; MPV uses the gravity-relative propulsive cutoff; Vmax is the maximum instantaneous velocity over the registered support | Reliability findings do not establish equivalence among metrics or providers | +| Vingren, Buddhadev & Hill (2011), *Smith Machine Counterbalance System Affects Measures of Maximal Bench Press Throw Performance*, PMID [21701283](https://pubmed.ncbi.nlm.nih.gov/21701283/), DOI [10.1519/JSC.0b013e31821eb67f](https://doi.org/10.1519/JSC.0b013e31821eb67f) | 24 adults; Smith-machine BPT with/without counterbalance; accelerometer-derived peak force, velocity and power | DIRECT BPT MECHANICAL-SYSTEM EVIDENCE | Counterbalance status is material BPT identity; counterbalanced and non-counterbalanced measures are not silently interchangeable | Does not authorize a generic power equation | +| Kobayashi et al. (2013), *Calculation of Force and Power during Bench Throws Using a Smith Machine: The Importance of Considering the Effect of Counterweights*, PMID [23459856](https://pubmed.ncbi.nlm.nih.gov/23459856/), DOI [10.1055/s-0032-1329955](https://doi.org/10.1055/s-0032-1329955) | 9 female collegiate judo athletes; counterweighted Smith-machine bench throws; LPT displacement; competing force/power equations | DIRECT COUNTERWEIGHTED-SMITH MECHANICS EVIDENCE | Future counterweighted-Smith power must bind the complete moving system and dynamic equation; `load × velocity` is rejected | Method-specific force/power equation is deferred from RES-68 V1 | +| Harris et al. (2011), *The Seated Medicine Ball Throw as a Test of Upper Body Power in Older Adults*, PMID [21572350](https://pubmed.ncbi.nlm.nih.gov/21572350/), DOI [10.1519/JSC.0b013e3181ecd27b](https://doi.org/10.1519/JSC.0b013e3181ecd27b) | Seated medicine-ball throw; 1.5 kg and 3.0 kg balls; distance outcome; older adults | DIRECT MBT PROTOCOL/DISTANCE EVIDENCE | Ball mass, seated posture, support and distance protocol are identity fields; distance remains a distance outcome | Population is older adults, not the fixed DynamisLM target population; no release-velocity authority | +| Mayhew et al. (2005), *Comparison of the Backward Overhead Medicine Ball Throw to Power Production in College Football Players*, PMID [16095399](https://pubmed.ncbi.nlm.nih.gov/16095399/), DOI [10.1519/15644.1](https://doi.org/10.1519/15644.1) | Standing backward overhead MBT in 40 NCAA Division II football players; distance and force-platform power association | DIRECT MBT PROTOCOL EVIDENCE; correlation only for relationship claim | Backward overhead and seated chest protocols remain different; correlation with power does not authorize distance-as-power | Does not establish a mechanical power equation or cross-protocol bridge | +| Thomas et al. (2006), *Influence of familiarization on a backward, overhead medicine ball explosive power test*, PMID [16440508](https://pubmed.ncbi.nlm.nih.gov/16440508/) | 28 adolescent male rugby players; six repeated backward-overhead throws; distance/familiarization | DIRECT MBT PROTOCOL/TRIAL-SELECTION EVIDENCE | Trial familiarization/selection is material identity; no universal best-trial rule is inferred | Population and protocol are not generalized to all MBT variants | + +The first-pass vertical-jump threshold-sensitivity source is therefore retained +only as supporting general event-detection evidence. It is not direct evidence +for the DJ protocol. Direct DJ claims use direct DJ studies where available. + +## B. Corrected DJ disposition matrix + +| Quantity/claim | V1 disposition | Exact boundary | +| --- | --- | --- | +| DJ ground-contact time | IMPLEMENT | `rebound_takeoff_time - touchdown_time`, seconds | +| DJ rebound flight time | IMPLEMENT | `subsequent_landing_time - rebound_takeoff_time`, seconds | +| DJ flight-time jump height | IMPLEMENT | DJ-specific estimator `g * flight_time_s**2 / 8`, metres; takeoff-to-apex ballistic rise estimate | +| DJ RSI JH/CT | IMPLEMENT | `qualified_DJ_jump_height_m / qualified_DJ_contact_time_s`, m/s | +| DJ RSR FT/CT | IMPLEMENT | `qualified_DJ_flight_time_s / qualified_DJ_contact_time_s`, dimensionless 1 | +| DJ RSI JH/CT as RSR FT/CT | BLOCKED | Different measurands, units, metric IDs and operations; no alias or inference | +| Nominal box height as actual drop height | BLOCKED | No substitution; actual height is independently measured or UNKNOWN | +| Missing actual drop height blocks event/contact/flight/FT-height/ratios | BLOCKED | Claim-relative missingness; unrelated valid outputs remain describable | +| Actual-drop-height equality/normalization/mechanical exposure claim with missing actual height | BLOCKED | `INSUFFICIENT_INFORMATION` or claim-relative refusal | +| DJ as CMJ or CMJ RSI-mod | BLOCKED | Different family and scientific identity | +| Cross-device DJ equality | BLOCKED without bridge | Force plate, IMU, contact mat and optical methods require exact identity or registered bridge | + +## C. Corrected BPT disposition matrix + +| Quantity/claim | V1 disposition | Exact boundary | +| --- | --- | --- | +| BPT sampled maximum bar velocity | IMPLEMENT | Sampled maximum over exact registered support; no interpolation | +| DynamisLM time-weighted mean bar velocity | IMPLEMENT | Trapezoidal integral over actual sample timestamps divided by exact support duration | +| Traditional/provider MV | REPRESENT_BUT_DO_NOT_RELABEL | Provider/source origin and exact provider method remain intact | +| BPT mean propulsive velocity | REPRESENT_BUT_DO_NOT_COMPUTE | Provider MPV may be represented under its source definition; no DynamisLM MPV arithmetic in V1 | +| BPT Vmax provider value | REPRESENT_BUT_DO_NOT_RELABEL | Provider value remains SOURCE_REPORTED or PROVIDER_DERIVED | +| BPT mean power | REPRESENT_BUT_DO_NOT_COMPUTE | No generic V1 operation | +| BPT peak power | REPRESENT_BUT_DO_NOT_COMPUTE | No generic V1 operation | +| Generic BPT power | NOT_REGISTERED | No `external_load * bar_velocity` operation | +| Counterweighted-Smith specific power | FUTURE CANDIDATE | Requires exact moving-system mechanics and dynamic force equation | +| Generic post-release rejection | BLOCKED | Support inclusion is metric/method-specific; traditional MV may include post-release travel | +| Provider MV as DynamisLM-derived MV | BLOCKED | Different origin, operation, support and lineage | +| BPT as ordinary bench press or RES-66 VBT | BLOCKED | Distinct family/protocol identity | + +## D. Confirmed MBT disposition matrix + +| Quantity/claim | V1 disposition | Exact boundary | +| --- | --- | --- | +| MBT throw distance | IMPLEMENT | Exact origin-to-endpoint convention, first-contact/no-roll semantics and protocol variant | +| MBT instrumented release velocity | IMPLEMENT | Qualified trajectory/velocity evidence and explicit release event only | +| Release velocity inferred from distance | BLOCKED | No projectile inversion, assumed angle, or assumed release height | +| MBT distance as power | REJECT_FROM_RES68 | Distance is not a mechanical power result | +| Generic upper-body power from distance | REJECT_FROM_RES68 | No generic conversion or latent-score relabel | +| Protocol-independent normative score | REJECT_FROM_RES68 | No normative authority | +| Cross-protocol equivalence | REJECT_FROM_RES68 | Seated chest, standing chest, backward overhead, rotational, supine and push-press variants remain distinct | + +## E. Exact measurands + +### DJ + +```text +DJ_GROUND_CONTACT_TIME_MEASURAND + rebound contact interval, s +DJ_REBOUND_FLIGHT_TIME_MEASURAND + rebound flight interval, s +DJ_FLIGHT_TIME_JUMP_HEIGHT_MEASURAND + vertical ballistic takeoff-to-apex rise estimated from rebound flight time, m +DJ_RSI_JH_CT_MEASURAND + DJ flight-time jump-height divided by rebound ground-contact time, m/s +DJ_RSR_FT_CT_MEASURAND + DJ rebound flight time divided by rebound ground-contact time, 1 +``` + +### BPT + +```text +BPT_BAR_VELOCITY_MEASURAND + bar velocity under an exact BPT protocol, device/frame and support definition, m/s +BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_MEASURAND + maximum sampled BPT bar velocity over registered support, m/s +BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_MEASURAND + time-weighted mean BPT bar velocity over registered support, m/s +BPT_MEAN_PROPULSIVE_VELOCITY_MEASURAND + provider-defined propulsive-support mean velocity, m/s; representation only in V1 +BPT_MEAN_POWER_MEASURAND / BPT_PEAK_POWER_MEASURAND + provider-defined power outputs; representation only in V1 +``` + +### MBT + +```text +MBT_THROW_DISTANCE_MEASURAND + protocol-defined throw distance from registered origin to endpoint, m +MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND + velocity at the explicit instrumented release event in the registered frame, m/s +``` + +## F. Exact metrics + +Metric identifiers are family- and method-scoped. In particular, +`DJ_RSI_JH_CT_METRIC` and `DJ_RSR_FT_CT_METRIC` are never aliases, and +`BPT_PROVIDER_MEAN_VELOCITY_METRIC` is not +`BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC`. + +## G. Exact registered operations + +```text +DJ_GROUND_CONTACT_TIME_OPERATION@1.0.0 +DJ_REBOUND_FLIGHT_TIME_OPERATION@1.0.0 +DJ_FLIGHT_TIME_JUMP_HEIGHT_OPERATION@1.0.0 +DJ_RSI_JH_CT_OPERATION@1.0.0 +DJ_RSR_FT_CT_OPERATION@1.0.0 + +BPT_SOURCE_VELOCITY_SERIES_OPERATION@1.0.0 +BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION@1.0.0 +BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_OPERATION@1.0.0 +BPT_PROVIDER_METRIC_SOURCE_OPERATION@1.0.0 + +MBT_SOURCE_DISTANCE_OPERATION@1.0.0 +MBT_DISTANCE_FROM_REGISTERED_COORDINATES_OPERATION@1.0.0 +MBT_INSTRUMENTED_RELEASE_VELOCITY_OPERATION@1.0.0 +``` + +No V1 operation is registered for MPV arithmetic, generic BPT power, load × +velocity power, MBT distance-as-power, normative scores, or cross-protocol +equivalence. + +## H. Equations and units + +DJ event ordering is strict: + +```text +touchdown_time < rebound_takeoff_time < subsequent_landing_time +``` + +```text +contact_time_s = rebound_takeoff_time_s - touchdown_time_s +flight_time_s = subsequent_landing_time_s - rebound_takeoff_time_s +jump_height_m = gravity_m_per_s2 * flight_time_s**2 / 8 +RSI_JH_CT_m_per_s = jump_height_m / contact_time_s +RSR_FT_CT_1 = flight_time_s / contact_time_s +``` + +The DJ flight-time estimator accepts the existing typed +`GravityReference` semantics. `STANDARD_GRAVITY` remains the exact registered +conventional reference `9.80665 m/s^2`; a supplied applicable local reference +remains distinct. No literal `9.81` default is introduced. Gravity identity, +source, unit, uncertainty metadata and method version are serialized. + +The DJ flight-time estimate is a takeoff-to-apex ballistic-rise estimate +conditional on the registered flight-time assumptions, including the +takeoff/landing posture applicability assumption. It is not impulse-momentum, +double-integration, standing-reference displacement, or CMJ height. + +BPT time-weighted mean for exact inclusive support indices `a..b` is: + +```text +mean_velocity_m_per_s = + Σ[i=a+1..b] 0.5 * (v[i-1] + v[i]) * (t[i] - t[i-1]) + ------------------------------------------------------ + t[b] - t[a] +``` + +It uses sample-attached actual timestamps, finite samples, exact support, +positive duration, explicit velocity unit/frame/sign convention, and the exact +source-series digest. An arithmetic sample mean is not equivalent. + +MBT coordinate distance, when a registered coordinate evidence object exists, +is: + +```text +throw_distance_m = endpoint_coordinate_m - origin_coordinate_m +``` + +No origin, endpoint, release angle, release height, roll correction, or +projectile inversion is inferred. + +## I. Protocol identity fields + +### DJ protocol identity + +Nominal box/drop height; actual drop height; actual-height method/source; +initiation mode; pre-drop posture/stillness; step-off leg; arms condition; +rebound strategy; explicit cue; support/platform configuration; +landing/rebound technique; pause/continuity semantics; protocol reference and +version; and explicit unknowns. Actual height is only populated when an +independent registered measurement method/source establishes it. + +### BPT protocol identity + +Machine type; fixed-vertical Smith/other Smith/free-weight/other identity; +counterbalance status and identity; effective/static resistance semantics; +moving-system load semantics; bar mass; added load; physical versus %1RM load +identity; concentric-only versus eccentric-concentric; pause/touch/bounce; +grip; ROM; feet/body support; release/catch semantics; provider/device/ +modality; sampling/timebase; filtering/smoothing/resampling; software/ +firmware; source-series digest; metric support definition; rep/trial +qualification and selection; and explicit unknowns. + +### MBT protocol identity + +Exact throw type; body posture; support/restraint condition; ball mass; +countermovement and lower-body contribution permissions; throw/arm technique; +starting position; release semantics; measurement method; distance origin and +endpoint conventions; first-contact/no-roll convention; device/provider/ +modality for instrumented tests; trial qualification; trial selection; and +explicit unknowns. + +## J. Event/support identity + +DJ has family-specific occurrences for `TOUCHDOWN`, `REBOUND_TAKEOFF` and +`SUBSEQUENT_LANDING`. Each occurrence preserves sample index, exact event time, +detector method and parameters, source-series digest, source observation, +artifact, acquisition, measurement identity, timebase and provenance. No +interpolation, backshift, sorting or repair is implicit. + +BPT support is a typed, exact, inclusive support bound to one metric operation, +source series digest, start/end indices and times, velocity unit/frame/sign, +and method semantics. A support may explicitly include post-release samples +when its metric definition does. There is no generic post-release invalidation. + +## K. Source qualification requirements + +Source/provider qualification is an upstream categorical observation, not a +caller-minted boolean. The normalized typed evidence must bind exact athlete, +session, test instance, trial/rep, source artifact, acquisition, provider, +context, method, processing lineage, source value origin, and qualification +measurand/metric identity. DynamisLM computation accepts only normalized +qualified evidence for qualified-trial claims. A caller cannot pass +`qualified=True`, `status="QUALIFIED"`, `best=True`, `eligible=True`, or an +equivalent free flag to mint authority. + +## L. Provenance requirements + +Every derived output preserves athlete/session/test/trial context; protocol; +device/provider/modality; acquisition; raw/source artifact; source-series +digest; timebase/sampling; processing/filtering; estimator/equation; +operation/version; parameters; software/registry/hardware/firmware where +known; source qualification; exact DJ events or BPT support; MBT release event +or coordinate conventions; evidence references; and explicit lineage edges. + +Reprocessing is append-only: + +```text +source R + method v1 -> D1 +source R + method v2 -> D2 +D2 does not overwrite D1 +``` + +## M. Claim-relative comparability matrix + +| Comparison | Result without registered bridge | +| --- | --- | +| Same family, same complete protocol/method/device/support identity | `COMPARABLE` | +| Same family, explicit registered conditional bridge | `COMPARABLE_WITH_CONDITIONS` | +| Unit-only change with explicitly requested registered transformation | `REQUIRES_TRANSFORMATION` | +| Different device/provider/software/filter/support where agreement is unregistered | `BRIDGE_VALIDATION_REQUIRED` | +| Different measurand, metric family, protocol variant, DJ-vs-CMJ or BPT-vs-ordinary bench press | `NOT_COMPARABLE` | +| Missing actual height for an actual-drop claim or missing material metadata | `INSUFFICIENT_INFORMATION` | +| DJ JH/CT versus DJ FT/CT | `NOT_COMPARABLE` | +| Provider BPT MV versus DynamisLM time-weighted MV | `BRIDGE_VALIDATION_REQUIRED` | +| Seated versus standing/overhead/rotational/supine/push-press MBT | `NOT_COMPARABLE` | + +Comparability is claim-relative. Missing actual drop height does not poison +valid contact, flight, flight-time height, RSI or RSR outputs whose own +requirements are met; it blocks only actual-drop-dependent claims. + +## N. Structured-refusal matrix + +| Refusal condition | Blocked claim | Safe description retained | +| --- | --- | --- | +| DJ event absent/malformed/wrong family/source/protocol | Requested DJ derived metric | Independently valid typed event or earlier duration | +| DJ event order or duration non-positive/non-finite | Contact/flight/derived ratio as applicable | Event identities and recorded times | +| DJ invalid/missing gravity or unresolved flight assumptions | DJ flight-time height | Contact and flight time where independently valid | +| DJ JH/CT requested without valid jump height or denominator | RSI JH/CT | Contact time and any valid JH/flight output | +| DJ RSR requested without valid flight time or denominator | RSR FT/CT | Contact time and any valid flight output | +| Actual drop height missing for actual-drop claim | Equal exposure/normalization/mechanics claim | Rebound metrics not requiring actual height | +| BPT raw caller support indices | Any derived BPT support metric | Source series/provider values | +| BPT missing counterbalance/load system/provider/device/support identity | Claim requiring that dimension | Source observation without the unsupported comparison/derivation | +| BPT provider MPV/power requested as DynamisLM arithmetic | Requested computation | Provider-reported value with origin/method if present | +| BPT generic load × velocity power | Generic power claim | Bar velocity and load as separately identified values | +| MBT scalar distance supplied as release velocity | Release velocity claim | Exact distance observation | +| MBT missing origin/end/first-contact convention or variant | Distance comparison/derivation | Source value and known protocol fields | +| MBT distance relabelled power/normative score | Power/score claim | Exact distance | +| Any missing source/provenance/processing lineage | Derived/qualified claim | Independently describable source observations | + +Refusals use the existing `RefusalResult` envelope and high-level classes, +with granular RES-68 reason strings, blocked claim, missing information, safe +descriptions, observation IDs and evidence references. + +## O. Adversarial-test matrix + +### DJ + +Canonical event fixture; exact contact/flight/height/RSI/RSR gold cases; +distinct JH/CT and FT/CT identities; event order/source/protocol failures; +nominal-versus-actual height separation; unknown actual height permitting +unrelated rebound metrics; actual-height-dependent comparison refusal; CMJ +event supplied as DJ; missing landing preserving earlier contact output; +force-platform versus IMU/contact-mat/optical bridge requirement; malformed and +non-iterable fail-closed inputs; source/protocol/device lineage attacks; and +V3 canonical roundtrip/hash. + +### BPT + +Sampled maximum gold case; irregular-timebase weighted-mean gold case; exact +support identity; post-release support accepted when registered; provider MV +origin preserved and not relabelled; MV/Vmax/MPV metric separation; +counterbalance/moving-system-load/movement-pattern mismatch; ordinary bench +press/VBT relabelling; raw support indices; arithmetic sample mean +substitution; MPV/power/load×velocity refusal; provider power origin; malformed +series; lineage/provenance; and V3 roundtrip/hash. + +### MBT + +Exact coordinate-distance gold case; origin/end/first-contact/no-roll mismatch; +ball-mass, throw-type and posture/support mismatch; qualified instrumented +release velocity; distance-to-velocity and scalar relabelling refusal; +distance-as-power/normative/cross-protocol refusal; caller-minted qualification +refusal; malformed trajectory; provenance; and V3 roundtrip/hash. + +### Generic + +Same label/unit with different identity; forged provenance/output edge; +changed scalar with incompatible identity; same-source reprocessing creates a +distinct lineage/output; and missing material metadata fails closed. + +## P. Existing-authority impact statement + +The following authorities are explicitly unchanged: + +```text +RES34_50_CHANGED=NO +RES60_64_CHANGED=NO +RES65_CHANGED=NO +RES66_CHANGED=NO +RES67_CHANGED=NO +SERIALIZATION_VERSION_CHANGED=NO +``` + +No CMJ event/result type, CMJ flight-time operation, CMJ gravity refusal, +RES-66 VBT identity, RES-64 external-load identity, RES-67 field-testing +identity, serializer semantics, historical hash, population/source authority, +football ontology, longitudinal authority, ingestion authority or refusal/ +comparability kernel is broadened or mutated. New family registries and +family-specific types are additive and use the existing V3 registration +mechanism. + +## Design-freeze disposition + +```text +DJ_FLIGHT_TIME=IMPLEMENT +DJ_FLIGHT_TIME_JUMP_HEIGHT=IMPLEMENT +DJ_RSI_JH_CT=IMPLEMENT +DJ_RSR_FT_CT=IMPLEMENT +DJ_RSI_RSR_ALIASING=BLOCKED +NOMINAL_HEIGHT_AS_ACTUAL_HEIGHT=BLOCKED +MISSING_ACTUAL_HEIGHT_POISONS_UNRELATED_METRICS=BLOCKED +DJ_EVIDENCE_ATTRIBUTION=CORRECT +BPT_SUPPORT_IS_METRIC_SPECIFIC=PASS +POST_RELEASE_GENERIC_REJECTION=BLOCKED +PROVIDER_MV_AS_DYNAMISLM_MV=BLOCKED +GENERIC_BPT_POWER=NOT_REGISTERED +LOAD_TIMES_VELOCITY_AS_POWER=BLOCKED +COUNTERWEIGHT_SMITH_POWER_FUTURE_CANDIDATE=RECORDED +MBT_PROTOCOL_COLLAPSE=BLOCKED +MBT_DISTANCE_AS_POWER=BLOCKED +RES34_67_AUTHORITY_MUTATED=NO +SERIALIZATION_VERSION=3 +DESIGN_FIX_STATUS=PASS +``` + +Implementation may now begin, but only as the additive family-scoped Phase B +described above. The final receipt must record actual QA facts and must not +pre-fill implementation or review PASS states. diff --git a/docs/decisions/RES68-RECEIPT.json b/docs/decisions/RES68-RECEIPT.json new file mode 100644 index 0000000..8f0c713 --- /dev/null +++ b/docs/decisions/RES68-RECEIPT.json @@ -0,0 +1,84 @@ +{ + "mission": "RES-68-DESIGN-FIX-AND-IMPLEMENTATION-001", + "linear_issue": "RES-68", + "project": "DynamisLM", + "status": "IMPLEMENTED_LOCAL_QA_PASS", + "decision_record": "docs/decisions/RES68-DR-001-explosive-test-family-closure.md", + "entry_head": "cb1a6e8631d1d42ba108d02a247e39688658efb5", + "branch": "work/res-68-explosive-test-family-closure", + "phase_a_design_fix": "PASS", + "phase_b_implementation": "PASS", + "scientific_design_frozen": true, + "serialization_version": 3, + "codebase_memory": { + "used": true, + "project": "home-litju-projects-DynamisLM", + "initial_map": "Kernel observation/result/identity/provenance, CMJ event and flight-height authority, RES-66 velocity-series support, RES-67 source qualification, registries, V3 serialization, refusal/comparability and downstream patterns were mapped before edits.", + "changed_symbol_audit": "PASS", + "unexpected_downstream_impact": "NONE; exact direct production call/import audit found no external consumers of the new symbols; inbound consumers are the RES-68 synthetic tests.", + "graph_note": "Similarity and short-name graph edges into existing families were treated as non-authoritative false positives and checked against exact source paths." + }, + "dispositions": { + "dj_flight_time": "IMPLEMENT", + "dj_flight_time_jump_height": "IMPLEMENT", + "dj_rsi_jh_ct": "IMPLEMENT", + "dj_rsr_ft_ct": "IMPLEMENT", + "dj_rsi_rsr_distinct": "PASS", + "dj_nominal_actual_height_distinct": "PASS", + "dj_unknown_actual_height_claim_relative": "PASS", + "dj_evidence_attribution": "PASS", + "bpt_distinct_family": "PASS", + "bpt_counterbalance_identity": "PASS", + "bpt_vmax": "IMPLEMENT", + "bpt_dynamislm_mean_velocity": "IMPLEMENT", + "bpt_mpv": "REPRESENT_BUT_DO_NOT_COMPUTE", + "bpt_support_metric_specific": "PASS", + "bpt_post_release_generic_rejection": "BLOCKED", + "provider_mv_as_dynamislm_mv": "BLOCKED", + "generic_bpt_power": "NOT_REGISTERED", + "load_times_velocity_as_power": "BLOCKED", + "counterweighted_smith_power_future_candidate": "RECORDED", + "mbt_distance": "IMPLEMENT", + "mbt_release_velocity": "IMPLEMENT", + "mbt_protocol_collapse": "BLOCKED", + "mbt_distance_as_power": "BLOCKED", + "mbt_normative_authority": "NONE" + }, + "historical_authority": { + "res34_50_changed": false, + "res60_64_changed": false, + "res65_changed": false, + "res66_changed": false, + "res67_changed": false, + "serialization_version_changed": false + }, + "scope_guards": { + "model_training_implemented": false, + "gpu_code_added": false, + "res69_plus_implemented": false, + "real_empirical_data_added": false + }, + "qa": { + "ruff": "PASS", + "format": "PASS", + "mypy": "PASS", + "repository_policy": "PASS", + "pytest": "PASS", + "test_count": 742, + "tracked_mutation": "NONE", + "git_diff_check": "PASS", + "command": "./scripts/ci.sh" + }, + "git": { + "commit_created": false, + "pushed_existing_branch": false, + "pr_created": false, + "pr_number": null, + "pr_head": null + }, + "linear_res68_status": "IN_PROGRESS", + "scientific_review": "PENDING_FINAL_REVIEW", + "unresolved_scientific_blockers": [], + "merge_readiness": "NOT_READY", + "next": "RES-68-FINAL-REVIEW-001" +} diff --git a/src/dynamislm/measurement/bench_press_throw/__init__.py b/src/dynamislm/measurement/bench_press_throw/__init__.py new file mode 100644 index 0000000..347aafe --- /dev/null +++ b/src/dynamislm/measurement/bench_press_throw/__init__.py @@ -0,0 +1,20 @@ +"""RES-68 family-scoped bench press throw scientific authority.""" + +from dynamislm.measurement.bench_press_throw import comparability as _comparability +from dynamislm.measurement.bench_press_throw import identity as _identity +from dynamislm.measurement.bench_press_throw import metrics as _metrics +from dynamislm.measurement.bench_press_throw import qualification as _qualification +from dynamislm.measurement.bench_press_throw import registry as _registry +from dynamislm.measurement.bench_press_throw.comparability import * # noqa: F403 +from dynamislm.measurement.bench_press_throw.identity import * # noqa: F403 +from dynamislm.measurement.bench_press_throw.metrics import * # noqa: F403 +from dynamislm.measurement.bench_press_throw.qualification import * # noqa: F403 +from dynamislm.measurement.bench_press_throw.registry import * # noqa: F403 + +__all__ = [ + *_registry.__all__, + *_identity.__all__, + *_comparability.__all__, + *_qualification.__all__, + *_metrics.__all__, +] diff --git a/src/dynamislm/measurement/bench_press_throw/comparability.py b/src/dynamislm/measurement/bench_press_throw/comparability.py new file mode 100644 index 0000000..79032c0 --- /dev/null +++ b/src/dynamislm/measurement/bench_press_throw/comparability.py @@ -0,0 +1,369 @@ +"""Claim-relative BPT comparability; no provider/derived relabelling.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dynamislm.comparability.models import ( + ComparabilityDecisionSource, + ComparabilityReasonCode, + ComparabilityRequest, + ComparabilityResult, + ComparabilityState, +) +from dynamislm.measurement.bench_press_throw.identity import ( + BPTAcquisitionIdentity, + BPTMeasurementIdentity, + BPTProcessingIdentity, + BPTProtocolIdentity, +) +from dynamislm.measurement.bench_press_throw.metrics import ( + BenchPressThrowMetricResult, + BPTProviderMetricResult, +) +from dynamislm.measurement.bench_press_throw.registry import ( + BPT_COMPARABILITY_RULE, + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_PROVIDER_PEAK_POWER_METRIC, + BPT_TEST_FAMILY, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.identity import InstanceIdentifier +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.serialization import canonical_hash, register_serializable_type + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BPTComparabilityRequest: + request_id: InstanceIdentifier + left_observation_id: InstanceIdentifier + right_observation_id: InstanceIdentifier + claim: str + + def __post_init__(self) -> None: + if self.request_id.instance_type != "comparability-request": + raise ValueError("BPT request ID must identify a comparability request") + if self.left_observation_id == self.right_observation_id: + raise ValueError("BPT comparison requires two observations") + if not self.claim.strip(): + raise ValueError("BPT claim must not be empty") + + +def _result( + request: ComparabilityRequest, + state: ComparabilityState, + reasons: tuple[ComparabilityReasonCode, ...], + *, + missing: tuple[str, ...] = (), + conditions: tuple[str, ...] = (), +) -> ComparabilityResult: + digest = canonical_hash( + {"request": request, "state": state, "reasons": reasons, "missing": missing} + ).removeprefix("sha256:")[:24] + return ComparabilityResult( + result_id=InstanceIdentifier("comparability-result", f"bpt:{digest}"), + request_id=request.request_id, + state=state, + reason_codes=tuple(reason.value for reason in reasons), + conditions=conditions, + transformations_required=(), + missing_information=missing, + rule_reference=BPT_COMPARABILITY_RULE, + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + decided_by=ComparabilityDecisionSource.DETERMINISTIC_RULE, + ) + + +def _observation( + item: BenchPressThrowMetricResult | BPTProviderMetricResult, +) -> ScientificMeasurementObservation: + return item.observation + + +def _protocol_signature(protocol: BPTProtocolIdentity) -> tuple[object, ...]: + """Compare method/mechanics, not source-instance or provider metadata.""" + + return ( + protocol.reference, + protocol.protocol_version, + protocol.machine_type, + protocol.counterbalance_status, + protocol.counterbalance_identity, + protocol.load_identity, + protocol.concentric_pattern, + protocol.pause_touch_bounce, + protocol.grip, + protocol.range_of_motion, + protocol.feet_body_support, + protocol.release_semantics, + protocol.catch_semantics, + protocol.trial_qualification, + protocol.trial_selection, + protocol.additional_attributes, + ) + + +def _acquisition_signature(acquisition: BPTAcquisitionIdentity) -> tuple[object, ...]: + return ( + acquisition.device, + acquisition.provider, + acquisition.sensor_modality, + acquisition.sampling, + acquisition.timebase, + acquisition.axis_or_frame, + acquisition.calibration_reference, + acquisition.hardware_firmware, + ) + + +def _processing_signature(processing: BPTProcessingIdentity) -> tuple[object, ...]: + return ( + processing.event_definitions, + processing.phase_definitions, + processing.estimator, + processing.registered_operation, + processing.filtering, + processing.differentiation_method, + processing.integration_method, + processing.unit, + processing.sign_convention, + processing.normalization, + processing.trial_selection, + processing.aggregation, + processing.timebase, + processing.processing_state, + processing.provider_algorithm, + processing.resampling, + ) + + +def _support_signature(result: BenchPressThrowMetricResult) -> tuple[object, ...] | None: + evidence = result.source_evidence + if evidence is None: + return None + support = evidence.support + return ( + support.metric, + support.support_definition, + support.includes_post_release_samples, + evidence.series.timebase, + evidence.series.velocity_frame, + evidence.series.sign_convention, + ) + + +def compare_bpt_metric_results( + left: BenchPressThrowMetricResult | BPTProviderMetricResult, + right: BenchPressThrowMetricResult | BPTProviderMetricResult, + claim: str = "compare BPT metric results", +) -> ComparabilityResult: + """Adjudicate BPT comparisons using exact metric and mechanical identity.""" + + left_observation = _observation(left) + right_observation = _observation(right) + left_id = left_observation.observation_id + right_id = right_observation.observation_id + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash({"left": left_id, "right": right_id, "claim": claim}).removeprefix( + "sha256:" + )[:24], + ) + try: + if not isinstance( + left, BenchPressThrowMetricResult | BPTProviderMetricResult + ) or not isinstance(right, BenchPressThrowMetricResult | BPTProviderMetricResult): + raise ValueError("typed BPT metric results are required") + request = ComparabilityRequest( + request_id=request_id, + left_observation_id=left.observation.observation_id, + right_observation_id=right.observation.observation_id, + claim=claim, + material_dimensions=( + "metric", + "protocol", + "machine_type", + "counterbalance", + "moving_system_load", + "device", + "support", + "value_origin", + ), + ) + except (AttributeError, TypeError, ValueError): + fallback = ComparabilityRequest( + request_id=request_id, + left_observation_id=left_id, + right_observation_id=right_id, + claim=claim or "invalid BPT comparison", + ) + if hasattr(left, "observation") and hasattr(right, "observation"): + return _result( + fallback, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + return _result( + fallback, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("two typed BPT metric results",), + ) + + left_metric = left.metric + right_metric = right.metric + provider_metrics = { + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_PEAK_POWER_METRIC, + } + if left_metric != right_metric: + if (left_metric in provider_metrics) != (right_metric in provider_metrics): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.VALUE_ORIGIN_MISMATCH,), + conditions=("provider-to-DynamisLM metric agreement bridge",), + ) + if left_metric in { + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_PEAK_POWER_METRIC, + } or right_metric in { + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_PEAK_POWER_METRIC, + }: + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.MEASURAND_MISMATCH,), + ) + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_DEFINITION_MISMATCH,), + ) + left_identity = left.observation.identity + right_identity = right.observation.identity + if not isinstance(left_identity, BPTMeasurementIdentity) or not isinstance( + right_identity, BPTMeasurementIdentity + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + if ( + left_identity.semantic.test_family != BPT_TEST_FAMILY + or right_identity.semantic.test_family != BPT_TEST_FAMILY + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + left_protocol = left_identity.semantic.protocol_identity + right_protocol = right_identity.semantic.protocol_identity + if left_protocol is None or right_protocol is None: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("complete BPT protocol identity",), + ) + if ( + left_protocol.machine_type.value == "UNKNOWN" + or right_protocol.machine_type.value == "UNKNOWN" + ): + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("BPT machine type",), + ) + if ( + left_protocol.counterbalance_status.value == "UNKNOWN" + or right_protocol.counterbalance_status.value == "UNKNOWN" + ): + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("BPT counterbalance status",), + ) + if left_protocol.load_identity is None or right_protocol.load_identity is None: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("BPT moving-system load identity",), + ) + if _protocol_signature(left_protocol) != _protocol_signature(right_protocol): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + ) + left_acquisition = left_identity.acquisition + right_acquisition = right_identity.acquisition + if left_acquisition.device is None or right_acquisition.device is None: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("BPT device identity",), + ) + if _acquisition_signature(left_acquisition) != _acquisition_signature(right_acquisition): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.DEVICE_MISMATCH,), + ) + if _processing_signature(left_identity.processing) != _processing_signature( + right_identity.processing + ): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.SOURCE_PROCESSING_MISMATCH,), + ) + if isinstance(left, BPTProviderMetricResult) != isinstance(right, BPTProviderMetricResult): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.VALUE_ORIGIN_MISMATCH,), + ) + if isinstance(left, BenchPressThrowMetricResult) and isinstance( + right, BenchPressThrowMetricResult + ): + if _support_signature(left) != _support_signature(right): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.SAMPLE_OR_TIMEBASE_MISMATCH,), + ) + return _result(request, ComparabilityState.COMPARABLE, ()) + + +def assess_bpt_comparability( + left: BenchPressThrowMetricResult | BPTProviderMetricResult, + right: BenchPressThrowMetricResult | BPTProviderMetricResult, + claim: str = "compare BPT metric results", +) -> ComparabilityResult: + return compare_bpt_metric_results(left, right, claim) + + +compare_bench_press_throw_results = compare_bpt_metric_results + + +__all__ = [ + "BPTComparabilityRequest", + "assess_bpt_comparability", + "compare_bench_press_throw_results", + "compare_bpt_metric_results", +] diff --git a/src/dynamislm/measurement/bench_press_throw/identity.py b/src/dynamislm/measurement/bench_press_throw/identity.py new file mode 100644 index 0000000..0a62b3a --- /dev/null +++ b/src/dynamislm/measurement/bench_press_throw/identity.py @@ -0,0 +1,616 @@ +"""Typed BPT protocol, load-system, acquisition and support identities.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum +from itertools import pairwise + +from dynamislm.measurement.bench_press_throw.registry import ( + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + BPT_PROTOCOL_V1, + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_TEST_FAMILY, + KILOGRAM, + METER_PER_SECOND, + PERCENT, +) +from dynamislm.measurement.identity import ( + AcquisitionIdentity, + InstanceIdentifier, + MeasurementIdentity, + MetadataEntry, + ProcessingIdentity, + RegistryReference, + SamplingCharacteristics, + ScientificIdentifier, + SemanticIdentity, + SignConvention, + UnitReference, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.provenance.models import AcquisitionRecord, SourceArtifact +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +def _finite_optional(value: object | None, field_name: str) -> float | None: + if value is None: + return None + return _finite(value, field_name) + + +class BPTMachineType(StrEnum): + FIXED_VERTICAL_SMITH = "FIXED_VERTICAL_SMITH" + OTHER_SMITH = "OTHER_SMITH" + FREE_WEIGHT = "FREE_WEIGHT" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class BPTCounterbalanceStatus(StrEnum): + PRESENT = "PRESENT" + ABSENT = "ABSENT" + NOT_APPLICABLE = "NOT_APPLICABLE" + UNKNOWN = "UNKNOWN" + + +class BPTLoadKind(StrEnum): + PHYSICAL = "PHYSICAL" + PERCENT_1RM = "PERCENT_1RM" + UNKNOWN = "UNKNOWN" + + +class BPTMovementPattern(StrEnum): + CONCENTRIC_ONLY = "CONCENTRIC_ONLY" + ECCENTRIC_CONCENTRIC = "ECCENTRIC_CONCENTRIC" + UNKNOWN = "UNKNOWN" + + +class BPTPauseTouchBounce(StrEnum): + PAUSE = "PAUSE" + TOUCH_AND_GO = "TOUCH_AND_GO" + BOUNCE = "BOUNCE" + UNKNOWN = "UNKNOWN" + + +class BPTReleaseSemantics(StrEnum): + RELEASED = "RELEASED" + NOT_RELEASED = "NOT_RELEASED" + UNKNOWN = "UNKNOWN" + + +class BPTCatchSemantics(StrEnum): + CAUGHT = "CAUGHT" + NOT_CAUGHT = "NOT_CAUGHT" + UNKNOWN = "UNKNOWN" + + +class BPTSensorModality(StrEnum): + LINEAR_POSITION_TRANSDUCER = "LINEAR_POSITION_TRANSDUCER" + LINEAR_ENCODER = "LINEAR_ENCODER" + ACCELEROMETER = "ACCELEROMETER" + OPTICAL = "OPTICAL" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class BPTTimebaseKind(StrEnum): + REGULAR = "REGULAR" + EXPLICIT = "EXPLICIT" + + +class BPTProcessingState(StrEnum): + RAW_ACQUIRED = "RAW_ACQUIRED" + DEVICE_PROCESSED = "DEVICE_PROCESSED" + PROVIDER_PROCESSED = "PROVIDER_PROCESSED" + DYNAMISLM_PROCESSED = "DYNAMISLM_PROCESSED" + UNKNOWN = "UNKNOWN" + + +class BPTQualificationStatus(StrEnum): + QUALIFIED = "QUALIFIED" + REJECTED = "REJECTED" + UNKNOWN = "UNKNOWN" + + +class BPTArtifactStatus(StrEnum): + VERIFIED = "VERIFIED" + UNVERIFIED = "UNVERIFIED" + + +class BPTHashAlgorithm(StrEnum): + SHA256 = "sha256" + + +class BPTArtifactHashScope(StrEnum): + CONTENT_BYTES = "CONTENT_BYTES" + CANONICAL_SERIES_REPRESENTATION = "CANONICAL_SERIES_REPRESENTATION" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BPTLoadIdentity: + """Physical or percent-1RM load plus the moving-system semantics.""" + + kind: BPTLoadKind + load_value: float | None + load_unit: UnitReference | None + bar_mass_kg: float | None + added_load_kg: float | None + counterweight_mass_kg: float | None + moving_system_load_semantics: str | None + effective_resistance_semantics: str | None + + def __post_init__(self) -> None: + _require_enum(self.kind, BPTLoadKind, "kind") + load_value = _finite_optional(self.load_value, "load_value") + if load_value is not None and load_value <= 0: + raise ValueError("load_value must be positive") + object.__setattr__(self, "load_value", load_value) + _require_optional_instance(self.load_unit, UnitReference, "load_unit") + if self.kind is BPTLoadKind.PHYSICAL: + if load_value is None or self.load_unit != KILOGRAM: + raise ValueError("physical BPT load requires a positive kilogram value") + elif self.kind is BPTLoadKind.PERCENT_1RM: + if load_value is None or self.load_unit != PERCENT: + raise ValueError("percent-1RM BPT load requires a positive percentage value") + elif load_value is not None or self.load_unit is not None: + raise ValueError("unknown BPT load kind cannot carry a load value") + bar_mass = _finite_optional(self.bar_mass_kg, "bar_mass_kg") + added_load = _finite_optional(self.added_load_kg, "added_load_kg") + counterweight_mass = _finite_optional(self.counterweight_mass_kg, "counterweight_mass_kg") + for name, value in ( + ("bar_mass_kg", bar_mass), + ("added_load_kg", added_load), + ("counterweight_mass_kg", counterweight_mass), + ): + if value is not None and value < 0: + raise ValueError(f"{name} must not be negative") + object.__setattr__(self, "bar_mass_kg", bar_mass) + object.__setattr__(self, "added_load_kg", added_load) + object.__setattr__(self, "counterweight_mass_kg", counterweight_mass) + if self.moving_system_load_semantics is not None: + _require_text(self.moving_system_load_semantics, "moving_system_load_semantics") + if self.effective_resistance_semantics is not None: + _require_text(self.effective_resistance_semantics, "effective_resistance_semantics") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BPTTimebase: + """Regular or explicit timestamps for one BPT velocity series.""" + + kind: BPTTimebaseKind + sample_rate_hz: float | None = None + start_time_s: float = 0.0 + times_s: tuple[float, ...] = () + + def __post_init__(self) -> None: + _require_enum(self.kind, BPTTimebaseKind, "kind") + rate = _finite_optional(self.sample_rate_hz, "sample_rate_hz") + if rate is not None and rate <= 0: + raise ValueError("sample_rate_hz must be positive") + object.__setattr__(self, "sample_rate_hz", rate) + object.__setattr__(self, "start_time_s", _finite(self.start_time_s, "start_time_s")) + require_tuple(self.times_s, "times_s") + if self.kind is BPTTimebaseKind.REGULAR: + if rate is None: + raise ValueError("regular timebase requires sample_rate_hz") + if self.times_s: + raise ValueError("regular timebase cannot carry explicit timestamps") + else: + if not self.times_s: + raise ValueError("explicit timebase requires timestamps") + normalized = tuple(_finite(item, "times_s item") for item in self.times_s) + if any(right <= left for left, right in pairwise(normalized)): + raise ValueError("explicit timestamps must be strictly increasing") + object.__setattr__(self, "times_s", normalized) + + def time_at(self, index: int) -> float: + if type(index) is not int or index < 0: + raise ValueError("BPT sample index must be a non-negative integer") + if self.kind is BPTTimebaseKind.REGULAR: + assert self.sample_rate_hz is not None + return self.start_time_s + index / self.sample_rate_hz + if index >= len(self.times_s): + raise IndexError("BPT sample index is outside explicit timebase") + return self.times_s[index] + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BenchPressThrowProtocolIdentity: + """Material BPT protocol and mechanical-system identity.""" + + reference: RegistryReference = BPT_PROTOCOL_V1 + protocol_version: str = "1.0.0" + machine_type: BPTMachineType = BPTMachineType.UNKNOWN + counterbalance_status: BPTCounterbalanceStatus = BPTCounterbalanceStatus.UNKNOWN + counterbalance_identity: RegistryReference | None = None + load_identity: BPTLoadIdentity | None = None + concentric_pattern: BPTMovementPattern = BPTMovementPattern.UNKNOWN + pause_touch_bounce: BPTPauseTouchBounce = BPTPauseTouchBounce.UNKNOWN + grip: str | None = None + range_of_motion: str | None = None + feet_body_support: str | None = None + release_semantics: BPTReleaseSemantics = BPTReleaseSemantics.UNKNOWN + catch_semantics: BPTCatchSemantics = BPTCatchSemantics.UNKNOWN + provider: str | None = None + device: RegistryReference | None = None + sensor_modality: BPTSensorModality = BPTSensorModality.UNKNOWN + sampling: SamplingCharacteristics | None = None + timebase: BPTTimebase | None = None + filtering: tuple[RegistryReference, ...] | None = None + smoothing: RegistryReference | None = None + resampling: RegistryReference | None = None + software: str | None = None + firmware: str | None = None + source_series_digest: str | None = None + operation_support_definition: str | None = None + trial_qualification: RegistryReference | None = None + trial_selection: RegistryReference | None = None + additional_attributes: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.reference, RegistryReference, "reference") + if self.reference.identifier.object_type != "protocol": + raise ValueError("BPT protocol reference must identify a protocol") + _require_text(self.protocol_version, "protocol_version") + _require_enum(self.machine_type, BPTMachineType, "machine_type") + _require_enum(self.counterbalance_status, BPTCounterbalanceStatus, "counterbalance_status") + _require_optional_instance( + self.counterbalance_identity, RegistryReference, "counterbalance_identity" + ) + if ( + self.counterbalance_status is BPTCounterbalanceStatus.PRESENT + and self.counterbalance_identity is None + ): + raise ValueError("present BPT counterbalance requires a counterbalance identity") + if ( + self.counterbalance_status is not BPTCounterbalanceStatus.PRESENT + and self.counterbalance_identity is not None + ): + raise ValueError("counterbalance identity requires PRESENT counterbalance status") + _require_optional_instance(self.load_identity, BPTLoadIdentity, "load_identity") + _require_enum(self.concentric_pattern, BPTMovementPattern, "concentric_pattern") + _require_enum(self.pause_touch_bounce, BPTPauseTouchBounce, "pause_touch_bounce") + _require_enum(self.release_semantics, BPTReleaseSemantics, "release_semantics") + _require_enum(self.catch_semantics, BPTCatchSemantics, "catch_semantics") + _require_enum(self.sensor_modality, BPTSensorModality, "sensor_modality") + for name, value in ( + ("grip", self.grip), + ("range_of_motion", self.range_of_motion), + ("feet_body_support", self.feet_body_support), + ("provider", self.provider), + ("software", self.software), + ("firmware", self.firmware), + ("source_series_digest", self.source_series_digest), + ("operation_support_definition", self.operation_support_definition), + ): + if value is not None: + _require_text(value, name) + _require_optional_instance(self.device, RegistryReference, "device") + _require_optional_instance(self.sampling, SamplingCharacteristics, "sampling") + _require_optional_instance(self.timebase, BPTTimebase, "timebase") + if self.filtering is not None: + _require_tuple_items(self.filtering, RegistryReference, "filtering") + _require_optional_instance(self.smoothing, RegistryReference, "smoothing") + _require_optional_instance(self.resampling, RegistryReference, "resampling") + _require_optional_instance( + self.trial_qualification, RegistryReference, "trial_qualification" + ) + _require_optional_instance(self.trial_selection, RegistryReference, "trial_selection") + _require_tuple_items(self.additional_attributes, MetadataEntry, "additional_attributes") + + @property + def test_family(self) -> RegistryReference: + return BPT_TEST_FAMILY + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BenchPressThrowAcquisitionIdentity(AcquisitionIdentity): + """BPT device/provider/frame/timebase identity.""" + + acquisition_instance_id: InstanceIdentifier | None = None + provider: str | None = None + sensor_modality: BPTSensorModality = BPTSensorModality.UNKNOWN + timebase: BPTTimebase | None = None + axis_or_frame: RegistryReference | None = None + source_series_digest: str | None = None + + def __post_init__(self) -> None: + AcquisitionIdentity.__post_init__(self) + _require_optional_instance( + self.acquisition_instance_id, InstanceIdentifier, "acquisition_instance_id" + ) + if ( + self.acquisition_instance_id is not None + and self.acquisition_instance_id.instance_type != "acquisition" + ): + raise ValueError("acquisition_instance_id must identify an acquisition") + if self.provider is not None: + _require_text(self.provider, "provider") + _require_enum(self.sensor_modality, BPTSensorModality, "sensor_modality") + _require_optional_instance(self.timebase, BPTTimebase, "timebase") + _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") + if self.source_series_digest is not None: + _require_text(self.source_series_digest, "source_series_digest") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BenchPressThrowProcessingIdentity(ProcessingIdentity): + """BPT support, filtering and provider-processing identity.""" + + timebase: BPTTimebase | None = None + processing_state: BPTProcessingState = BPTProcessingState.UNKNOWN + provider_algorithm: RegistryReference | None = None + resampling: RegistryReference | None = None + + def __post_init__(self) -> None: + ProcessingIdentity.__post_init__(self) + _require_optional_instance(self.timebase, BPTTimebase, "timebase") + _require_enum(self.processing_state, BPTProcessingState, "processing_state") + _require_optional_instance(self.provider_algorithm, RegistryReference, "provider_algorithm") + _require_optional_instance(self.resampling, RegistryReference, "resampling") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BenchPressThrowSemanticIdentity(SemanticIdentity): + """Semantic identity carrying the complete BPT protocol object.""" + + protocol_identity: BenchPressThrowProtocolIdentity | None = None + + def __post_init__(self) -> None: + SemanticIdentity.__post_init__(self) + _require_instance( + self.protocol_identity, BenchPressThrowProtocolIdentity, "protocol_identity" + ) + assert self.protocol_identity is not None + if self.test_family != BPT_TEST_FAMILY: + raise ValueError("BPT semantic identity has the wrong test family") + if self.protocol != self.protocol_identity.reference: + raise ValueError("BPT protocol and protocol_identity references must agree") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BenchPressThrowMeasurementIdentity(MeasurementIdentity): + """Distinct BPT identity; not the pre-existing barbell velocity-test family.""" + + semantic: BenchPressThrowSemanticIdentity + acquisition: BenchPressThrowAcquisitionIdentity + processing: BenchPressThrowProcessingIdentity + + def __post_init__(self) -> None: + MeasurementIdentity.__post_init__(self) + _require_instance(self.semantic, BenchPressThrowSemanticIdentity, "semantic") + _require_instance(self.acquisition, BenchPressThrowAcquisitionIdentity, "acquisition") + _require_instance(self.processing, BenchPressThrowProcessingIdentity, "processing") + if self.semantic.test_family != BPT_TEST_FAMILY: + raise ValueError("BPT measurement identity has the wrong family") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BenchPressThrowSourceArtifact(SourceArtifact): + """Verified BPT source artifact.""" + + status: BPTArtifactStatus = BPTArtifactStatus.UNVERIFIED + hash_algorithm: BPTHashAlgorithm = BPTHashAlgorithm.SHA256 + hash_scope: BPTArtifactHashScope = BPTArtifactHashScope.CONTENT_BYTES + + def __post_init__(self) -> None: + SourceArtifact.__post_init__(self) + _require_enum(self.status, BPTArtifactStatus, "status") + _require_enum(self.hash_algorithm, BPTHashAlgorithm, "hash_algorithm") + _require_enum(self.hash_scope, BPTArtifactHashScope, "hash_scope") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BenchPressThrowAcquisitionRecord(AcquisitionRecord): + """BPT acquisition record with provider, modality, frame and timebase.""" + + provider: str | None = None + sensor_modality: BPTSensorModality = BPTSensorModality.UNKNOWN + timebase: BPTTimebase | None = None + axis_or_frame: RegistryReference | None = None + + def __post_init__(self) -> None: + AcquisitionRecord.__post_init__(self) + if self.provider is not None: + _require_text(self.provider, "provider") + _require_enum(self.sensor_modality, BPTSensorModality, "sensor_modality") + _require_optional_instance(self.timebase, BPTTimebase, "timebase") + _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BenchPressThrowVelocitySeries: + """Exact timestamp-attached BPT bar-velocity samples.""" + + series_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + acquisition_id: InstanceIdentifier + source_measurement_identity_id: ScientificIdentifier + samples: tuple[tuple[float, float], ...] + timebase: BPTTimebase + unit: UnitReference + velocity_frame: RegistryReference + sign_convention: SignConvention + processing_state: BPTProcessingState + + def __post_init__(self) -> None: + for name, value, expected in ( + ("series_id", self.series_id, "signal"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("acquisition_id", self.acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, name) + if value.instance_type != expected: + raise ValueError(f"{name} must identify a {expected}") + _require_instance( + self.source_measurement_identity_id, + ScientificIdentifier, + "source_measurement_identity_id", + ) + if self.source_measurement_identity_id.object_type != "measurement-identity": + raise ValueError("source_measurement_identity_id must identify a measurement") + _require_instance(self.timebase, BPTTimebase, "timebase") + _require_instance(self.unit, UnitReference, "unit") + if self.unit != METER_PER_SECOND: + raise ValueError("BPT velocity series must use m/s") + _require_instance(self.velocity_frame, RegistryReference, "velocity_frame") + _require_instance(self.sign_convention, SignConvention, "sign_convention") + if ( + self.sign_convention.reference is None + and self.sign_convention.positive_direction is None + ): + raise ValueError("BPT velocity series requires an explicit sign convention") + _require_enum(self.processing_state, BPTProcessingState, "processing_state") + if self.processing_state in { + BPTProcessingState.UNKNOWN, + BPTProcessingState.DYNAMISLM_PROCESSED, + }: + raise ValueError("BPT source series must be raw or device/provider processed") + require_tuple(self.samples, "samples") + if not self.samples: + raise ValueError("BPT velocity series must not be empty") + normalized: list[tuple[float, float]] = [] + for sample in self.samples: + if not isinstance(sample, tuple) or len(sample) != 2: + raise ValueError("BPT samples must be (time_s, velocity_m_per_s) tuples") + time_s = _finite(sample[0], "sample time") + velocity = _finite(sample[1], "velocity sample") + normalized.append((time_s, velocity)) + if any(right[0] <= left[0] for left, right in pairwise(normalized)): + raise ValueError("BPT sample timestamps must be strictly increasing") + if any( + self.timebase.time_at(index) != sample[0] for index, sample in enumerate(normalized) + ): + raise ValueError("BPT sample timestamps must equal the declared timebase") + object.__setattr__(self, "samples", tuple(normalized)) + if self.timebase.kind is BPTTimebaseKind.EXPLICIT and len(self.timebase.times_s) != len( + normalized + ): + raise ValueError("explicit BPT timebase length must equal sample count") + + def canonical_content_digest(self) -> str: + return canonical_hash( + { + "samples": self.samples, + "timebase": self.timebase, + "unit": self.unit, + "velocity_frame": self.velocity_frame, + "sign_convention": self.sign_convention, + "processing_state": self.processing_state, + } + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BenchPressThrowMetricSupport: + """Exact inclusive support for one BPT metric method.""" + + support_id: InstanceIdentifier + metric: RegistryReference + start_index: int + end_index: int + start_time_s: float + end_time_s: float + support_definition: str + source_series_digest: str + includes_post_release_samples: bool = False + + def __post_init__(self) -> None: + if self.support_id.instance_type != "support": + raise ValueError("support_id must identify a support") + if self.metric not in { + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + }: + raise ValueError("BPT support must identify an implemented BPT metric") + if type(self.start_index) is not int or type(self.end_index) is not int: + raise ValueError("BPT support indices must be integers") + if self.start_index < 0 or self.end_index < self.start_index: + raise ValueError("BPT support must satisfy 0 <= start <= end") + start = _finite(self.start_time_s, "support start time") + end = _finite(self.end_time_s, "support end time") + if end < start: + raise ValueError("BPT support end time must not precede start time") + object.__setattr__(self, "start_time_s", start) + object.__setattr__(self, "end_time_s", end) + _require_text(self.support_definition, "support_definition") + _require_text(self.source_series_digest, "source_series_digest") + if not isinstance(self.includes_post_release_samples, bool): + raise ValueError("includes_post_release_samples must be a boolean") + + +# Short names are aliases to the same registered classes, not new wire types. +BPTProtocolIdentity = BenchPressThrowProtocolIdentity +BPTAcquisitionIdentity = BenchPressThrowAcquisitionIdentity +BPTProcessingIdentity = BenchPressThrowProcessingIdentity +BPTSemanticIdentity = BenchPressThrowSemanticIdentity +BPTMeasurementIdentity = BenchPressThrowMeasurementIdentity +BPTSourceArtifact = BenchPressThrowSourceArtifact +BPTAcquisitionRecord = BenchPressThrowAcquisitionRecord +BPTVelocitySeries = BenchPressThrowVelocitySeries +BPTMetricSupport = BenchPressThrowMetricSupport + + +__all__ = [ + "BPTAcquisitionIdentity", + "BPTAcquisitionRecord", + "BPTArtifactHashScope", + "BPTArtifactStatus", + "BPTCatchSemantics", + "BPTCounterbalanceStatus", + "BPTHashAlgorithm", + "BPTLoadIdentity", + "BPTLoadKind", + "BPTMachineType", + "BPTMeasurementIdentity", + "BPTMetricSupport", + "BPTMovementPattern", + "BPTPauseTouchBounce", + "BPTProcessingIdentity", + "BPTProcessingState", + "BPTProtocolIdentity", + "BPTQualificationStatus", + "BPTReleaseSemantics", + "BPTSemanticIdentity", + "BPTSensorModality", + "BPTSourceArtifact", + "BPTTimebase", + "BPTTimebaseKind", + "BPTVelocitySeries", + "BenchPressThrowAcquisitionIdentity", + "BenchPressThrowAcquisitionRecord", + "BenchPressThrowMeasurementIdentity", + "BenchPressThrowMetricSupport", + "BenchPressThrowProcessingIdentity", + "BenchPressThrowProtocolIdentity", + "BenchPressThrowSemanticIdentity", + "BenchPressThrowSourceArtifact", + "BenchPressThrowVelocitySeries", +] diff --git a/src/dynamislm/measurement/bench_press_throw/metrics.py b/src/dynamislm/measurement/bench_press_throw/metrics.py new file mode 100644 index 0000000..ecc5694 --- /dev/null +++ b/src/dynamislm/measurement/bench_press_throw/metrics.py @@ -0,0 +1,696 @@ +"""Deterministic BPT velocity operations and explicit power refusals.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from dynamislm.measurement.bench_press_throw.identity import ( + BenchPressThrowProcessingIdentity, + BenchPressThrowSemanticIdentity, + BPTAcquisitionIdentity, + BPTMachineType, + BPTMeasurementIdentity, + BPTMovementPattern, + BPTProcessingState, + BPTReleaseSemantics, +) +from dynamislm.measurement.bench_press_throw.qualification import ( + BenchPressThrowVelocitySeriesEvidence, + BPTSourceQualificationEvidence, + require_qualified_bpt, +) +from dynamislm.measurement.bench_press_throw.registry import ( + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_MEASURAND, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_OPERATION, + BPT_REGISTRY_VERSION, + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_MEASURAND, + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION, + BPT_SAMPLED_MAXIMUM_ESTIMATOR, + BPT_SOFTWARE_VERSION, + BPT_TEST_FAMILY, + BPT_TIME_WEIGHTED_MEAN_ESTIMATOR, + BPT_TIME_WEIGHTED_TRAPEZOIDAL_METHOD, + METER_PER_SECOND, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, + WATT, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + UnitReference, + VersionIdentity, + _require_instance, + _require_tuple_items, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.measurement.result import ( + MeasurementQuality, + MeasurementResult, + ResultStatus, + ScalarValue, + UncertaintyMetadata, + UncertaintyStatus, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ScientificRole, ValueOrigin +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, + SourceArtifact, +) +from dynamislm.refusal.models import RefusalClass, RefusalReasonCode, RefusalResult, RefusalStatus +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BenchPressThrowMetricResult: + """One BPT scalar result with explicit metric and source lineage.""" + + observation: ScientificMeasurementObservation + metric: RegistryReference + source_evidence: BenchPressThrowVelocitySeriesEvidence | None = None + source_observations: tuple[ScientificMeasurementObservation, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.observation, ScientificMeasurementObservation, "observation") + _require_instance(self.metric, RegistryReference, "metric") + _require_tuple_items( + self.source_observations, ScientificMeasurementObservation, "source_observations" + ) + if not isinstance(self.observation.identity, BPTMeasurementIdentity): + raise ValueError("BPT result requires BenchPressThrowMeasurementIdentity") + if self.observation.identity.semantic.metric_definition != self.metric: + raise ValueError("BPT metric does not match output identity") + value = self.observation.result.value + if not isinstance(value, ScalarValue) or isinstance(value.value, bool): + raise ValueError("BPT result must contain a numeric scalar") + _finite(value.value, "BPT result") + if self.observation.result.status is not ResultStatus.VALID: + raise ValueError("BPT result must be valid") + runs = tuple( + run + for run in self.observation.provenance.processing_runs + if run.output_entity_id == self.observation.observation_id + ) + if len(runs) != 1: + raise ValueError("BPT result must preserve one output processing run") + run_id = runs[0].processing_run_id.qualified + if self.source_evidence is not None: + if self.metric not in { + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + }: + raise ValueError( + "source-series evidence is only valid for implemented derived metrics" + ) + if not any( + edge.from_id == self.source_evidence.observation.observation_id.qualified + and edge.to_id == run_id + and edge.relation is LineageRelation.DERIVED_FROM + for edge in self.observation.provenance.lineage_edges + ): + raise ValueError("BPT result is missing source evidence lineage") + if not any( + edge.from_id == self.source_evidence.support.support_id.qualified + and edge.to_id == run_id + and edge.relation is LineageRelation.DERIVED_FROM + for edge in self.observation.provenance.lineage_edges + ): + raise ValueError("BPT result is missing exact support lineage") + for source in self.source_observations: + if not any( + edge.from_id == source.observation_id.qualified + and edge.to_id == run_id + and edge.relation is LineageRelation.DERIVED_FROM + for edge in self.observation.provenance.lineage_edges + ): + raise ValueError("BPT result is missing source observation lineage") + + @property + def value(self) -> float: + value = self.observation.result.value + assert isinstance(value, ScalarValue) + return float(value.value) + + @property + def unit(self) -> UnitReference | None: + return self.observation.result.unit + + @property + def value_m_per_s(self) -> float: + if self.unit != METER_PER_SECOND: + raise ValueError("BPT result is not expressed in m/s") + return self.value + + @property + def value_w(self) -> float: + if self.unit != WATT: + raise ValueError("BPT result is not expressed in watts") + return self.value + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BPTProviderMetricResult: + """Typed view of a provider/source BPT scalar with origin preserved.""" + + observation: ScientificMeasurementObservation + metric: RegistryReference + + def __post_init__(self) -> None: + _require_instance(self.observation, ScientificMeasurementObservation, "observation") + _require_instance(self.metric, RegistryReference, "metric") + if not isinstance(self.observation.identity, BPTMeasurementIdentity): + raise ValueError("provider result requires BPT identity") + if self.observation.identity.semantic.metric_definition != self.metric: + raise ValueError("provider metric does not match identity") + if self.observation.result.classification.value_origin not in { + ValueOrigin.SOURCE_REPORTED, + ValueOrigin.PROVIDER_DERIVED, + }: + raise ValueError("provider result cannot be DynamisLM-derived") + if self.observation.result.status is not ResultStatus.VALID: + raise ValueError("provider result must be valid") + + @property + def value(self) -> float: + value = self.observation.result.value + if not isinstance(value, ScalarValue) or isinstance(value.value, bool): + raise ValueError("provider result must contain a numeric scalar") + return float(value.value) + + +def _refusal( + claim: str, + reasons: tuple[RefusalReasonCode | str, ...], + missing: tuple[str, ...], + observation_ids: tuple[InstanceIdentifier, ...] = (), + *, + refusal_class: RefusalClass = RefusalClass.IDENTITY_UNRESOLVED, + safe: tuple[str, ...] = (), +) -> RefusalResult: + codes = tuple(item.value if isinstance(item, RefusalReasonCode) else item for item in reasons) + digest = canonical_hash( + {"claim": claim, "reasons": codes, "missing": missing, "observation_ids": observation_ids} + ).removeprefix("sha256:")[:24] + return RefusalResult( + refusal_id=InstanceIdentifier("refusal", f"bench-press-throw:{digest}"), + status=RefusalStatus.REFUSED, + refusal_class=refusal_class, + blocked_claim=claim, + reason_codes=codes, + missing_information=missing, + what_can_still_be_safely_described=safe + or ("the exact BPT source series and provider values",), + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + observation_ids=observation_ids, + ) + + +def _validate_protocol(evidence: BenchPressThrowVelocitySeriesEvidence) -> None: + protocol = evidence.identity.semantic.protocol_identity + if protocol is None: + raise ValueError("BPT protocol identity is required") + if protocol.machine_type is BPTMachineType.UNKNOWN: + raise ValueError("BPT machine type is unresolved") + if protocol.counterbalance_status.value == "UNKNOWN": + raise ValueError("BPT counterbalance status is unresolved") + if protocol.load_identity is None: + raise ValueError("BPT moving-system load identity is required") + if protocol.concentric_pattern is BPTMovementPattern.UNKNOWN: + raise ValueError("BPT movement pattern is unresolved") + if evidence.acquisition.device is None: + raise ValueError("BPT measuring device identity is required") + if evidence.acquisition.sensor_modality.value == "UNKNOWN": + raise ValueError("BPT sensor modality is unresolved") + if not evidence.series.velocity_frame or ( + evidence.series.sign_convention.reference is None + and evidence.series.sign_convention.positive_direction is None + ): + raise ValueError("BPT velocity frame and sign convention are required") + if ( + evidence.support.includes_post_release_samples + and protocol.release_semantics is BPTReleaseSemantics.UNKNOWN + ): + raise ValueError("post-release support requires explicit BPT release semantics") + + +def _validate_evidence( + evidence: BenchPressThrowVelocitySeriesEvidence, + expected_metric: RegistryReference, + qualification: BPTSourceQualificationEvidence | None, +) -> None: + if not isinstance(evidence, BenchPressThrowVelocitySeriesEvidence): + raise ValueError("qualified BPT velocity-series evidence is required") + _validate_protocol(evidence) + if evidence.support.metric != expected_metric: + raise ValueError("support metric does not match requested BPT operation") + if qualification is not None: + require_qualified_bpt(qualification, evidence.observation) + + +def _output_identity( + evidence: BenchPressThrowVelocitySeriesEvidence, + *, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + parameters: tuple[MetadataEntry, ...], +) -> BPTMeasurementIdentity: + source = evidence.identity + acquisition = source.acquisition + return BPTMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"bpt-{operation.identifier.key}-{canonical_hash(parameters).removeprefix('sha256:')[:24]}", + BPT_REGISTRY_VERSION, + ), + semantic=BenchPressThrowSemanticIdentity( + construct=source.semantic.construct, + test_family=BPT_TEST_FAMILY, + protocol=source.semantic.protocol, + measurand=measurand, + metric_definition=metric, + protocol_identity=source.semantic.protocol_identity, + ), + acquisition=BPTAcquisitionIdentity( + device=acquisition.device, + raw_artifact=acquisition.raw_artifact, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + acquisition_instance_id=acquisition.acquisition_instance_id, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + source_series_digest=acquisition.source_series_digest, + ), + processing=BenchPressThrowProcessingIdentity( + registered_operation=operation, + estimator=( + BPT_SAMPLED_MAXIMUM_ESTIMATOR + if operation == BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION + else BPT_TIME_WEIGHTED_MEAN_ESTIMATOR + ), + method_parameters=parameters, + filtering=source.processing.filtering, + differentiation_method=source.processing.differentiation_method, + integration_method=( + None + if operation == BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION + else BPT_TIME_WEIGHTED_TRAPEZOIDAL_METHOD + ), + unit=METER_PER_SECOND, + sign_convention=source.processing.sign_convention, + normalization=source.processing.normalization, + trial_selection=source.processing.trial_selection, + aggregation=source.processing.aggregation, + timebase=evidence.series.timebase, + processing_state=BPTProcessingState.DYNAMISLM_PROCESSED, + ), + version=VersionIdentity( + processing_method=operation, + method_registry_version=BPT_REGISTRY_VERSION, + software_version=BPT_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + + +def _build_derived( + evidence: BenchPressThrowVelocitySeriesEvidence, + *, + value: float, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + parameters: tuple[MetadataEntry, ...], +) -> BenchPressThrowMetricResult: + numeric = _finite(value, "BPT derived value") + identity = _output_identity( + evidence, + metric=metric, + measurand=measurand, + operation=operation, + parameters=parameters, + ) + digest = canonical_hash( + { + "operation": operation, + "metric": metric, + "value": numeric, + "parameters": parameters, + "source_observation_id": evidence.observation.observation_id, + "source_series_digest": evidence.source_series_digest, + "support": evidence.support, + } + ).removeprefix("sha256:")[:24] + output_id = InstanceIdentifier("observation", f"bpt:{operation.identifier.key}:{digest}") + output_artifact = SourceArtifact( + artifact_id=InstanceIdentifier("artifact", f"bpt-result:{digest}"), + content_digest=canonical_hash({"identity": identity, "value": numeric, "metric": metric}), + media_type="application/vnd.dynamislm.bench-press-throw.scalar-metric", + immutable=True, + ) + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"bpt:{operation.identifier.key}:{digest}" + ), + source_artifact_ids=tuple( + sorted( + (item.artifact_id for item in evidence.observation.provenance.source_artifacts), + key=lambda item: item.qualified, + ) + ), + method=operation, + parameters=parameters, + software_version=BPT_SOFTWARE_VERSION, + output_entity_id=output_id, + ) + if not run.source_artifact_ids: + raise ValueError("BPT derived result requires source artifacts") + edges = list(evidence.observation.provenance.lineage_edges) + for entity_id in ( + evidence.observation.observation_id, + evidence.series.series_id, + evidence.support.support_id, + *run.source_artifact_ids, + ): + edge = LineageEdge( + entity_id.qualified, run.processing_run_id.qualified, LineageRelation.DERIVED_FROM + ) + if edge not in edges: + edges.append(edge) + for acquisition in evidence.observation.provenance.acquisitions: + edge = LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ) + if edge not in edges: + edges.append(edge) + decision_edge = LineageEdge( + RES68_DECISION_EXPLOSIVE_TEST_FAMILY.stable_id, + run.processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ) + if decision_edge not in edges: + edges.append(decision_edge) + for edge in ( + LineageEdge( + run.processing_run_id.qualified, + output_artifact.artifact_id.qualified, + LineageRelation.PRODUCED, + ), + LineageEdge(run.processing_run_id.qualified, output_id.qualified, LineageRelation.PRODUCED), + ): + if edge not in edges: + edges.append(edge) + provenance = Provenance( + provenance_id=InstanceIdentifier("provenance", output_id.value), + source_artifacts=tuple( + sorted( + (*evidence.observation.provenance.source_artifacts, output_artifact), + key=lambda item: item.artifact_id.qualified, + ) + ), + acquisitions=evidence.observation.provenance.acquisitions, + processing_runs=(*evidence.observation.provenance.processing_runs, run), + lineage_edges=tuple( + sorted(edges, key=lambda item: (item.from_id, item.to_id, item.relation.value)) + ), + evidence_references=tuple( + dict.fromkeys( + ( + *evidence.observation.provenance.evidence_references, + EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), + ) + ) + ), + metrological_traceability=evidence.observation.provenance.metrological_traceability, + recorded_at=evidence.observation.provenance.recorded_at + or evidence.observation.context.observed_at, + ) + observation = ScientificMeasurementObservation( + observation_id=output_id, + context=evidence.observation.context, + identity=identity, + result=MeasurementResult( + result_id=InstanceIdentifier("result", f"bpt:{operation.identifier.key}:{digest}"), + value=ScalarValue(numeric), + unit=METER_PER_SECOND, + classification=ScientificClassification( + ValueOrigin.DYNAMISLM_DERIVED, + (ScientificRole.PERFORMANCE_OUTCOME,), + ), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata( + status=UncertaintyStatus.NOT_ASSESSED, + description="RES-68 deterministic BPT arithmetic; uncertainty is not assessed.", + ), + status=ResultStatus.VALID, + ), + provenance=provenance, + ) + return BenchPressThrowMetricResult( + observation=observation, + metric=metric, + source_evidence=evidence, + source_observations=(evidence.observation,), + ) + + +def calculate_bpt_sampled_maximum_bar_velocity( + evidence: BenchPressThrowVelocitySeriesEvidence, + qualification: BPTSourceQualificationEvidence | None = None, + *, + start_index: int | None = None, + end_index: int | None = None, +) -> BenchPressThrowMetricResult | RefusalResult: + """Calculate sampled Vmax over exact registered support, without interpolation.""" + + claim = "calculate BPT sampled maximum bar velocity" + try: + if start_index is not None or end_index is not None: + return _refusal( + claim, + (RefusalReasonCode.SAMPLE_SUPPORT_MISMATCH,), + ("qualified BenchPressThrowMetricSupport; raw caller indices are forbidden",), + (evidence.observation.observation_id,) + if isinstance(evidence, BenchPressThrowVelocitySeriesEvidence) + else (), + ) + _validate_evidence(evidence, BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, qualification) + samples = evidence.series.samples[ + evidence.support.start_index : evidence.support.end_index + 1 + ] + if not samples: + raise ValueError("BPT support contains no samples") + value = max(sample[1] for sample in samples) + parameters = ( + MetadataEntry("support_id", evidence.support.support_id.qualified), + MetadataEntry("source_series_digest", evidence.source_series_digest), + MetadataEntry("start_index", evidence.support.start_index), + MetadataEntry("end_index", evidence.support.end_index), + MetadataEntry("support_definition", evidence.support.support_definition), + MetadataEntry( + "includes_post_release_samples", evidence.support.includes_post_release_samples + ), + MetadataEntry("estimator", "sampled maximum over exact support; no interpolation"), + ) + return _build_derived( + evidence, + value=value, + metric=BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + measurand=BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_MEASURAND, + operation=BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION, + parameters=parameters, + ) + except (AttributeError, IndexError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.SAMPLE_SUPPORT_MISMATCH,), + ("qualified exact BPT Vmax support, series digest and protocol identity",), + (evidence.observation.observation_id,) + if isinstance(evidence, BenchPressThrowVelocitySeriesEvidence) + else (), + ) + + +def calculate_bpt_dynamislm_time_weighted_mean_bar_velocity( + evidence: BenchPressThrowVelocitySeriesEvidence, + qualification: BPTSourceQualificationEvidence | None = None, + *, + start_index: int | None = None, + end_index: int | None = None, +) -> BenchPressThrowMetricResult | RefusalResult: + """Integrate exact BPT samples over actual timestamps and divide by duration.""" + + claim = "calculate DynamisLM time-weighted mean BPT bar velocity" + try: + if start_index is not None or end_index is not None: + return _refusal( + claim, + (RefusalReasonCode.SAMPLE_SUPPORT_MISMATCH,), + ("qualified BenchPressThrowMetricSupport; raw caller indices are forbidden",), + (evidence.observation.observation_id,) + if isinstance(evidence, BenchPressThrowVelocitySeriesEvidence) + else (), + ) + _validate_evidence( + evidence, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + qualification, + ) + start = evidence.support.start_index + end = evidence.support.end_index + if end <= start: + raise ValueError("time-weighted BPT mean requires at least two support samples") + duration = evidence.support.end_time_s - evidence.support.start_time_s + if duration <= 0 or not math.isfinite(duration): + raise ValueError("BPT support duration must be finite and positive") + samples = evidence.series.samples[start : end + 1] + area = math.fsum( + 0.5 + * (samples[offset - 1][1] + samples[offset][1]) + * (samples[offset][0] - samples[offset - 1][0]) + for offset in range(1, len(samples)) + ) + value = area / duration + parameters = ( + MetadataEntry("support_id", evidence.support.support_id.qualified), + MetadataEntry("source_series_digest", evidence.source_series_digest), + MetadataEntry("start_index", start), + MetadataEntry("end_index", end), + MetadataEntry("support_definition", evidence.support.support_definition), + MetadataEntry( + "includes_post_release_samples", evidence.support.includes_post_release_samples + ), + MetadataEntry("integration_method", "sample-attached trapezoidal integration"), + MetadataEntry("time_weighted_mean_equation", "integral(v dt) / (t[b] - t[a])"), + ) + return _build_derived( + evidence, + value=value, + metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + measurand=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_MEASURAND, + operation=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_OPERATION, + parameters=parameters, + ) + except (AttributeError, IndexError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.SAMPLE_SUPPORT_MISMATCH,), + ("qualified exact BPT time support, actual timestamps and positive duration",), + (evidence.observation.observation_id,) + if isinstance(evidence, BenchPressThrowVelocitySeriesEvidence) + else (), + ) + + +def _unregistered( + claim: str, + evidence: object | None = None, + reason: RefusalReasonCode | str = RefusalReasonCode.NO_REGISTERED_OPERATION, +) -> RefusalResult: + observation_ids = ( + (evidence.observation.observation_id,) + if isinstance(evidence, BenchPressThrowVelocitySeriesEvidence) + else () + ) + return _refusal( + claim, + (reason, "COMPUTATION_NOT_REGISTERED"), + ("a separately registered BPT operation with complete method inputs",), + observation_ids, + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + safe=("the exact BPT velocity series and any provider-reported value",), + ) + + +def calculate_bpt_mean_propulsive_velocity( + evidence: BenchPressThrowVelocitySeriesEvidence | None = None, +) -> RefusalResult: + """MPV is representable from provider evidence but not computed in V1.""" + + return _unregistered("calculate BPT mean propulsive velocity", evidence) + + +def calculate_bpt_mean_power( + evidence: BenchPressThrowVelocitySeriesEvidence | None = None, +) -> RefusalResult: + return _unregistered("calculate BPT mean power", evidence) + + +def calculate_bpt_peak_power( + evidence: BenchPressThrowVelocitySeriesEvidence | None = None, +) -> RefusalResult: + return _unregistered("calculate BPT peak power", evidence) + + +def calculate_bpt_load_times_velocity_power( + load_value: float | None = None, + velocity_value: float | None = None, +) -> RefusalResult: + """Reject the non-registered generic load-times-velocity shortcut.""" + + return _unregistered("calculate generic BPT load times velocity power") + + +def wrap_bpt_provider_metric( + observation: ScientificMeasurementObservation, + metric: RegistryReference, +) -> BPTProviderMetricResult | RefusalResult: + try: + return BPTProviderMetricResult(observation=observation, metric=metric) + except (AttributeError, TypeError, ValueError): + return _refusal( + "represent provider BPT metric", + (RefusalReasonCode.METRIC_DEFINITION_MISMATCH,), + ("provider metric identity and source/provider origin",), + (observation.observation_id,) + if isinstance(observation, ScientificMeasurementObservation) + else (), + ) + + +# Expanded aliases are intentionally operation aliases, not metric aliases. +calculate_bpt_vmax = calculate_bpt_sampled_maximum_bar_velocity +calculate_bpt_time_weighted_mean_velocity = calculate_bpt_dynamislm_time_weighted_mean_bar_velocity +calculate_bpt_dynamislm_mean_velocity = calculate_bpt_dynamislm_time_weighted_mean_bar_velocity + + +__all__ = [ + "BPTProviderMetricResult", + "BenchPressThrowMetricResult", + "calculate_bpt_dynamislm_mean_velocity", + "calculate_bpt_dynamislm_time_weighted_mean_bar_velocity", + "calculate_bpt_load_times_velocity_power", + "calculate_bpt_mean_power", + "calculate_bpt_mean_propulsive_velocity", + "calculate_bpt_peak_power", + "calculate_bpt_sampled_maximum_bar_velocity", + "calculate_bpt_time_weighted_mean_velocity", + "calculate_bpt_vmax", + "wrap_bpt_provider_metric", +] diff --git a/src/dynamislm/measurement/bench_press_throw/qualification.py b/src/dynamislm/measurement/bench_press_throw/qualification.py new file mode 100644 index 0000000..d79c056 --- /dev/null +++ b/src/dynamislm/measurement/bench_press_throw/qualification.py @@ -0,0 +1,767 @@ +"""BPT source-series, provider-value and qualification boundaries.""" + +from __future__ import annotations + +import datetime as datetime_module +from dataclasses import dataclass + +from dynamislm.measurement.bench_press_throw.identity import ( + BenchPressThrowAcquisitionRecord, + BenchPressThrowMeasurementIdentity, + BenchPressThrowMetricSupport, + BenchPressThrowProcessingIdentity, + BenchPressThrowProtocolIdentity, + BenchPressThrowSemanticIdentity, + BenchPressThrowSourceArtifact, + BenchPressThrowVelocitySeries, + BPTAcquisitionIdentity, + BPTArtifactStatus, + BPTProcessingState, + BPTQualificationStatus, + BPTSensorModality, +) +from dynamislm.measurement.bench_press_throw.registry import ( + BPT_BAR_VELOCITY_MEASURAND, + BPT_CONSTRUCT, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + BPT_MEAN_POWER_MEASURAND, + BPT_PEAK_POWER_MEASURAND, + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_PROVIDER_METRIC_SOURCE_OPERATION, + BPT_PROVIDER_PEAK_POWER_METRIC, + BPT_QUALIFICATION_MEASURAND, + BPT_QUALIFICATION_METRIC, + BPT_REGISTRY_VERSION, + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_SOFTWARE_VERSION, + BPT_SOURCE_QUALIFICATION_OPERATION, + BPT_SOURCE_VELOCITY_SERIES_OPERATION, + BPT_TEST_FAMILY, + BPT_VELOCITY_SERIES_METRIC, + BPT_VELOCITY_SERIES_SCHEMA, + METER_PER_SECOND, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, + WATT, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + UnitReference, + VersionIdentity, + _require_instance, +) +from dynamislm.measurement.observation import ( + ObservationContext, + ScientificMeasurementObservation, +) +from dynamislm.measurement.result import ( + CategoricalValue, + MeasurementQuality, + MeasurementResult, + ResultStatus, + ScalarValue, + StructuredOutputReference, + UncertaintyMetadata, + UncertaintyStatus, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ValueOrigin +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, +) +from dynamislm.serialization import canonical_json, register_serializable_type + + +def _origin_for_series(state: BPTProcessingState) -> ValueOrigin: + if state is BPTProcessingState.RAW_ACQUIRED: + return ValueOrigin.SOURCE_REPORTED + if state in {BPTProcessingState.DEVICE_PROCESSED, BPTProcessingState.PROVIDER_PROCESSED}: + return ValueOrigin.PROVIDER_DERIVED + raise ValueError("BPT source series must have raw/device/provider processing state") + + +def _require_verified_artifact(artifact: BenchPressThrowSourceArtifact) -> None: + if not isinstance(artifact, BenchPressThrowSourceArtifact): + raise ValueError("BPT source artifact must use its typed artifact contract") + if not artifact.immutable or artifact.status is not BPTArtifactStatus.VERIFIED: + raise ValueError("BPT source artifact must be immutable and verified") + + +def _validate_acquisition_binding( + identity: BenchPressThrowMeasurementIdentity, + artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, + series: BenchPressThrowVelocitySeries | None = None, +) -> None: + if identity.acquisition.raw_artifact != artifact.artifact_id: + raise ValueError("BPT identity raw artifact does not match source artifact") + if identity.acquisition.acquisition_instance_id != acquisition.acquisition_id: + raise ValueError("BPT identity acquisition does not match acquisition record") + if acquisition.source_artifact_id != artifact.artifact_id: + raise ValueError("BPT acquisition does not reference source artifact") + expected = identity.acquisition + if ( + expected.device != acquisition.device + or expected.sensor_channel != acquisition.sensor_channel + or expected.sampling != acquisition.sampling + or expected.calibration_reference != acquisition.calibration_reference + or expected.hardware_firmware != acquisition.hardware_firmware + or expected.provider != acquisition.provider + or expected.sensor_modality != acquisition.sensor_modality + or expected.timebase != acquisition.timebase + or expected.axis_or_frame != acquisition.axis_or_frame + ): + raise ValueError("BPT identity and acquisition record do not describe the same system") + if series is not None: + if ( + identity.acquisition.source_series_digest != series.canonical_content_digest() + or identity.acquisition.timebase != series.timebase + or identity.acquisition.device != acquisition.device + ): + raise ValueError("BPT source series does not match acquisition identity") + protocol = identity.semantic.protocol_identity + if protocol is not None: + if protocol.device is not None and protocol.device != acquisition.device: + raise ValueError("BPT protocol device does not match acquisition") + if protocol.provider is not None and protocol.provider != acquisition.provider: + raise ValueError("BPT protocol provider does not match acquisition") + if ( + protocol.sensor_modality is not BPTSensorModality.UNKNOWN + and protocol.sensor_modality is not acquisition.sensor_modality + ): + raise ValueError("BPT protocol modality does not match acquisition") + if protocol.timebase is not None and protocol.timebase != acquisition.timebase: + raise ValueError("BPT protocol timebase does not match acquisition") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BenchPressThrowVelocitySeriesEvidence: + """Qualified source series plus exact metric-specific support.""" + + observation: ScientificMeasurementObservation + identity: BenchPressThrowMeasurementIdentity + series: BenchPressThrowVelocitySeries + source_artifact: BenchPressThrowSourceArtifact + acquisition: BenchPressThrowAcquisitionRecord + support: BenchPressThrowMetricSupport + + def __post_init__(self) -> None: + _require_instance(self.observation, ScientificMeasurementObservation, "observation") + _require_instance(self.identity, BenchPressThrowMeasurementIdentity, "identity") + _require_instance(self.series, BenchPressThrowVelocitySeries, "series") + _require_verified_artifact(self.source_artifact) + _require_instance(self.acquisition, BenchPressThrowAcquisitionRecord, "acquisition") + _require_instance(self.support, BenchPressThrowMetricSupport, "support") + if self.observation.identity != self.identity: + raise ValueError("BPT evidence observation and identity do not agree") + if self.identity.semantic.measurand != BPT_BAR_VELOCITY_MEASURAND: + raise ValueError("BPT series identity has the wrong bar-velocity measurand") + if self.identity.semantic.metric_definition != BPT_VELOCITY_SERIES_METRIC: + raise ValueError("BPT series identity has the wrong velocity-series metric") + if self.series.source_artifact_id != self.source_artifact.artifact_id: + raise ValueError("BPT series artifact does not match evidence artifact") + if self.series.acquisition_id != self.acquisition.acquisition_id: + raise ValueError("BPT series acquisition does not match evidence acquisition") + if self.series.source_measurement_identity_id != self.identity.identity_id: + raise ValueError("BPT series identity does not match evidence identity") + if self.source_artifact.content_digest != self.series.canonical_content_digest(): + raise ValueError("BPT source artifact digest does not match exact series") + _validate_acquisition_binding( + self.identity, self.source_artifact, self.acquisition, self.series + ) + if self.observation.result.value != StructuredOutputReference( + artifact_id=self.source_artifact.artifact_id, + schema=BPT_VELOCITY_SERIES_SCHEMA, + ): + raise ValueError("BPT source observation must reference the exact velocity series") + if self.observation.result.unit != METER_PER_SECOND: + raise ValueError("BPT source observation must use m/s") + if self.observation.result.classification.value_origin is not _origin_for_series( + self.series.processing_state + ): + raise ValueError("BPT source origin does not match series processing state") + if self.support.source_series_digest != self.series.canonical_content_digest(): + raise ValueError("BPT support is not bound to the exact series digest") + if self.support.end_index >= len(self.series.samples): + raise ValueError("BPT support end index is outside the exact series") + if ( + self.support.start_time_s != self.series.samples[self.support.start_index][0] + or self.support.end_time_s != self.series.samples[self.support.end_index][0] + ): + raise ValueError("BPT support times must equal exact sample timestamps") + if self.observation.result.status is not ResultStatus.VALID: + raise ValueError("BPT source evidence observation must be valid") + output_runs = tuple( + run + for run in self.observation.provenance.processing_runs + if run.output_entity_id == self.observation.observation_id + ) + if len(output_runs) != 1 or output_runs[0].method != BPT_SOURCE_VELOCITY_SERIES_OPERATION: + raise ValueError("BPT source evidence must preserve its registered processing run") + + @property + def source_series_digest(self) -> str: + return self.series.canonical_content_digest() + + +def create_bpt_velocity_series_evidence( + *, + observation_id: InstanceIdentifier, + result_id: InstanceIdentifier, + context: ObservationContext, + identity: BenchPressThrowMeasurementIdentity, + series: BenchPressThrowVelocitySeries, + source_artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, + support: BenchPressThrowMetricSupport, + evidence_references: tuple[EvidenceReference, ...] = (), + recorded_at: datetime_module.datetime | None = None, +) -> BenchPressThrowVelocitySeriesEvidence: + """Create exact BPT series evidence; support is never a raw index pair.""" + + _require_instance(context, ObservationContext, "context") + _require_instance(identity, BenchPressThrowMeasurementIdentity, "identity") + _require_instance(series, BenchPressThrowVelocitySeries, "series") + _require_verified_artifact(source_artifact) + _require_instance(acquisition, BenchPressThrowAcquisitionRecord, "acquisition") + if observation_id.instance_type != "observation" or result_id.instance_type != "result": + raise ValueError("BPT evidence requires observation and result identifiers") + if identity.processing.registered_operation != BPT_SOURCE_VELOCITY_SERIES_OPERATION: + raise ValueError("BPT series identity must use the registered source operation") + if identity.version.processing_method != BPT_SOURCE_VELOCITY_SERIES_OPERATION: + raise ValueError("BPT series version must use the registered source operation") + _validate_acquisition_binding(identity, source_artifact, acquisition, series) + if source_artifact.artifact_id != series.source_artifact_id: + raise ValueError("BPT artifact does not match series") + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"bpt-source:{observation_id.value}" + ), + source_artifact_ids=(source_artifact.artifact_id,), + method=BPT_SOURCE_VELOCITY_SERIES_OPERATION, + parameters=identity.processing.method_parameters, + software_version=identity.version.software_version, + output_entity_id=observation_id, + ) + provenance = Provenance( + provenance_id=InstanceIdentifier("provenance", observation_id.value), + source_artifacts=(source_artifact,), + acquisitions=(acquisition,), + processing_runs=(run,), + lineage_edges=( + LineageEdge( + source_artifact.artifact_id.qualified, + acquisition.acquisition_id.qualified, + LineageRelation.ACQUIRED_AS, + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ), + LineageEdge( + series.series_id.qualified, + run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + support.support_id.qualified, + run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + RES68_DECISION_EXPLOSIVE_TEST_FAMILY.stable_id, + run.processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge( + run.processing_run_id.qualified, + observation_id.qualified, + LineageRelation.PRODUCED, + ), + ), + evidence_references=( + EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), + *evidence_references, + ), + recorded_at=recorded_at or context.observed_at, + ) + observation = ScientificMeasurementObservation( + observation_id=observation_id, + context=context, + identity=identity, + result=MeasurementResult( + result_id=result_id, + value=StructuredOutputReference( + artifact_id=source_artifact.artifact_id, + schema=BPT_VELOCITY_SERIES_SCHEMA, + ), + unit=METER_PER_SECOND, + classification=ScientificClassification( + _origin_for_series(series.processing_state), () + ), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ), + provenance=provenance, + ) + return BenchPressThrowVelocitySeriesEvidence( + observation=observation, + identity=identity, + series=series, + source_artifact=source_artifact, + acquisition=acquisition, + support=support, + ) + + +def build_bpt_provider_metric_observation( + *, + observation_id: InstanceIdentifier, + result_id: InstanceIdentifier, + context: ObservationContext, + protocol_identity: BenchPressThrowProtocolIdentity, + metric: RegistryReference, + value: float, + unit: UnitReference, + source_artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, + value_origin: ValueOrigin, + provider_algorithm: RegistryReference | None = None, + method_parameters: tuple[MetadataEntry, ...] = (), + recorded_at: datetime_module.datetime | None = None, +) -> ScientificMeasurementObservation: + """Represent a provider scalar without relabelling it DynamisLM-derived.""" + + if metric in { + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + }: + raise ValueError("provider metric builder cannot mint a DynamisLM operation identity") + if metric not in { + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_PEAK_POWER_METRIC, + }: + raise ValueError("metric is not a registered BPT provider metric") + if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("provider BPT metric must remain source/provider-origin") + _require_instance(protocol_identity, BenchPressThrowProtocolIdentity, "protocol_identity") + _require_instance(context, ObservationContext, "context") + _require_verified_artifact(source_artifact) + _require_instance(acquisition, BenchPressThrowAcquisitionRecord, "acquisition") + if not isinstance(value, int | float) or isinstance(value, bool): + raise ValueError("provider BPT metric value must be numeric") + if unit not in {METER_PER_SECOND, WATT}: + raise ValueError("provider BPT provider metric must use a registered unit") + power_metrics = {BPT_PROVIDER_MEAN_POWER_METRIC, BPT_PROVIDER_PEAK_POWER_METRIC} + if metric in power_metrics and unit != WATT: + raise ValueError("BPT provider power must use watts") + if metric not in power_metrics and unit != METER_PER_SECOND: + raise ValueError("BPT provider velocity must use m/s") + measurand = ( + BPT_BAR_VELOCITY_MEASURAND + if unit == METER_PER_SECOND + else BPT_MEAN_POWER_MEASURAND + if metric == BPT_PROVIDER_MEAN_POWER_METRIC + else BPT_PEAK_POWER_MEASURAND + ) + parameters = ( + MetadataEntry("provider_metric", metric.stable_id), + MetadataEntry("value_origin", value_origin.value), + *method_parameters, + ) + identity = BenchPressThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"bpt-provider:{metric.identifier.key}:{observation_id.value}", + BPT_REGISTRY_VERSION, + ), + semantic=BenchPressThrowSemanticIdentity( + construct=BPT_CONSTRUCT, + test_family=BPT_TEST_FAMILY, + protocol=protocol_identity.reference, + measurand=measurand, + metric_definition=metric, + protocol_identity=protocol_identity, + ), + acquisition=BPTAcquisitionIdentity( + device=acquisition.device, + raw_artifact=source_artifact.artifact_id, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + acquisition_instance_id=acquisition.acquisition_id, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + ), + processing=BenchPressThrowProcessingIdentity( + registered_operation=BPT_PROVIDER_METRIC_SOURCE_OPERATION, + method_parameters=parameters, + processing_state=( + BPTProcessingState.RAW_ACQUIRED + if value_origin is ValueOrigin.SOURCE_REPORTED + else BPTProcessingState.PROVIDER_PROCESSED + ), + provider_algorithm=provider_algorithm, + timebase=acquisition.timebase, + ), + version=VersionIdentity( + processing_method=BPT_PROVIDER_METRIC_SOURCE_OPERATION, + method_registry_version=BPT_REGISTRY_VERSION, + software_version=BPT_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"bpt-provider:{observation_id.value}" + ), + source_artifact_ids=(source_artifact.artifact_id,), + method=BPT_PROVIDER_METRIC_SOURCE_OPERATION, + parameters=parameters, + software_version=BPT_SOFTWARE_VERSION, + output_entity_id=observation_id, + ) + provenance = Provenance( + provenance_id=InstanceIdentifier("provenance", observation_id.value), + source_artifacts=(source_artifact,), + acquisitions=(acquisition,), + processing_runs=(run,), + lineage_edges=( + LineageEdge( + source_artifact.artifact_id.qualified, + acquisition.acquisition_id.qualified, + LineageRelation.ACQUIRED_AS, + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ), + LineageEdge( + run.processing_run_id.qualified, observation_id.qualified, LineageRelation.PRODUCED + ), + ), + evidence_references=(EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY),), + recorded_at=recorded_at or context.observed_at, + ) + return ScientificMeasurementObservation( + observation_id=observation_id, + context=context, + identity=identity, + result=MeasurementResult( + result_id=result_id, + value=ScalarValue(float(value)), + unit=unit, + classification=ScientificClassification(value_origin, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ), + provenance=provenance, + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class BPTSourceQualificationEvidence: + """Source/provider qualification bound to one exact BPT observation.""" + + target_observation_id: InstanceIdentifier + qualification_observation: ScientificMeasurementObservation + qualification_reference: RegistryReference + + def __post_init__(self) -> None: + if self.target_observation_id.instance_type != "observation": + raise ValueError("BPT target observation ID must identify an observation") + _require_instance( + self.qualification_observation, + ScientificMeasurementObservation, + "qualification_observation", + ) + if self.qualification_reference != BPT_SOURCE_QUALIFICATION_OPERATION: + raise ValueError("BPT qualification reference is not registered") + value = self.qualification_observation.result.value + if not isinstance(value, CategoricalValue): + raise ValueError("BPT qualification must be categorical") + try: + BPTQualificationStatus(value.category) + except ValueError as exc: + raise ValueError("BPT qualification category is not registered") from exc + if self.qualification_observation.result.classification.value_origin not in { + ValueOrigin.SOURCE_REPORTED, + ValueOrigin.PROVIDER_DERIVED, + }: + raise ValueError("BPT qualification must remain source/provider-origin") + if self.qualification_observation.result.status is not ResultStatus.VALID: + raise ValueError("BPT qualification observation must be valid") + if any( + not isinstance(artifact, BenchPressThrowSourceArtifact) + or not artifact.immutable + or artifact.status is not BPTArtifactStatus.VERIFIED + for artifact in self.qualification_observation.provenance.source_artifacts + ): + raise ValueError("BPT qualification must preserve verified source artifacts") + identity = self.qualification_observation.identity + if not isinstance(identity, BenchPressThrowMeasurementIdentity): + raise ValueError("BPT qualification requires BPT identity") + if ( + identity.semantic.measurand != BPT_QUALIFICATION_MEASURAND + or identity.semantic.metric_definition != BPT_QUALIFICATION_METRIC + ): + raise ValueError("BPT qualification identity has the wrong measurand/metric") + if identity.processing.registered_operation != BPT_SOURCE_QUALIFICATION_OPERATION: + raise ValueError("BPT qualification identity has the wrong operation") + parameters = {entry.key: entry.value for entry in identity.processing.method_parameters} + if parameters.get("target_observation_id") != self.target_observation_id.qualified: + raise ValueError("BPT qualification target is not preserved") + if parameters.get("source_status") != self.status.value: + raise ValueError("BPT qualification status is not preserved") + + @property + def status(self) -> BPTQualificationStatus: + value = self.qualification_observation.result.value + assert isinstance(value, CategoricalValue) + return BPTQualificationStatus(value.category) + + +def build_bpt_qualification_source_observation( + *, + target_observation: ScientificMeasurementObservation, + reported_status: BPTQualificationStatus, + source_artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, + value_origin: ValueOrigin = ValueOrigin.SOURCE_REPORTED, + output_observation_id: InstanceIdentifier | None = None, + reason_codes: tuple[str, ...] = (), + recorded_at: datetime_module.datetime | None = None, +) -> ScientificMeasurementObservation: + """Ingest upstream BPT qualification; normalization remains separate.""" + + _require_instance(target_observation, ScientificMeasurementObservation, "target_observation") + _require_instance(reported_status, BPTQualificationStatus, "reported_status") + if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("BPT qualification source origin is invalid") + _require_verified_artifact(source_artifact) + _require_instance(acquisition, BenchPressThrowAcquisitionRecord, "acquisition") + target_identity = target_observation.identity + if not isinstance(target_identity, BenchPressThrowMeasurementIdentity): + raise ValueError("BPT target requires BPT identity") + observation_id = output_observation_id or InstanceIdentifier( + "observation", f"bpt-qualification:{target_observation.observation_id.value}" + ) + parameters = ( + MetadataEntry("target_observation_id", target_observation.observation_id.qualified), + MetadataEntry("source_status", reported_status.value), + MetadataEntry("reason_codes", canonical_json(reason_codes)), + ) + identity = BenchPressThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"bpt-qualification:{observation_id.value}", + BPT_REGISTRY_VERSION, + ), + semantic=BenchPressThrowSemanticIdentity( + construct=target_identity.semantic.construct, + test_family=BPT_TEST_FAMILY, + protocol=target_identity.semantic.protocol, + measurand=BPT_QUALIFICATION_MEASURAND, + metric_definition=BPT_QUALIFICATION_METRIC, + protocol_identity=target_identity.semantic.protocol_identity, + ), + acquisition=BPTAcquisitionIdentity( + device=acquisition.device, + raw_artifact=source_artifact.artifact_id, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + acquisition_instance_id=acquisition.acquisition_id, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + ), + processing=BenchPressThrowProcessingIdentity( + registered_operation=BPT_SOURCE_QUALIFICATION_OPERATION, + method_parameters=parameters, + processing_state=( + BPTProcessingState.RAW_ACQUIRED + if value_origin is ValueOrigin.SOURCE_REPORTED + else BPTProcessingState.PROVIDER_PROCESSED + ), + timebase=acquisition.timebase, + ), + version=VersionIdentity( + processing_method=BPT_SOURCE_QUALIFICATION_OPERATION, + method_registry_version=BPT_REGISTRY_VERSION, + software_version=BPT_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + result = MeasurementResult( + result_id=InstanceIdentifier("result", f"bpt-qualification:{observation_id.value}"), + value=CategoricalValue(reported_status.value), + unit=None, + classification=ScientificClassification(value_origin, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ) + return _build_simple_source_observation( + observation_id=observation_id, + context=target_observation.context, + identity=identity, + result=result, + source_artifact=source_artifact, + acquisition=acquisition, + recorded_at=recorded_at, + ) + + +def _build_simple_source_observation( + *, + observation_id: InstanceIdentifier, + context: ObservationContext, + identity: BenchPressThrowMeasurementIdentity, + result: MeasurementResult, + source_artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, + recorded_at: datetime_module.datetime | None, +) -> ScientificMeasurementObservation: + _validate_acquisition_binding(identity, source_artifact, acquisition) + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"bpt-source:{observation_id.value}" + ), + source_artifact_ids=(source_artifact.artifact_id,), + method=identity.version.processing_method, + parameters=identity.processing.method_parameters, + software_version=identity.version.software_version, + output_entity_id=observation_id, + ) + return ScientificMeasurementObservation( + observation_id=observation_id, + context=context, + identity=identity, + result=result, + provenance=Provenance( + provenance_id=InstanceIdentifier("provenance", observation_id.value), + source_artifacts=(source_artifact,), + acquisitions=(acquisition,), + processing_runs=(run,), + lineage_edges=( + LineageEdge( + source_artifact.artifact_id.qualified, + acquisition.acquisition_id.qualified, + LineageRelation.ACQUIRED_AS, + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ), + LineageEdge( + run.processing_run_id.qualified, + observation_id.qualified, + LineageRelation.PRODUCED, + ), + ), + evidence_references=(EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY),), + recorded_at=recorded_at or context.observed_at, + ), + ) + + +def normalize_bpt_qualification( + source_observation: ScientificMeasurementObservation, + target_observation: ScientificMeasurementObservation, +) -> BPTSourceQualificationEvidence: + _require_instance(source_observation, ScientificMeasurementObservation, "source_observation") + _require_instance(target_observation, ScientificMeasurementObservation, "target_observation") + if source_observation.context != target_observation.context: + raise ValueError("BPT qualification context does not match target") + source_identity = source_observation.identity + target_identity = target_observation.identity + if not isinstance(source_identity, BenchPressThrowMeasurementIdentity) or not isinstance( + target_identity, BenchPressThrowMeasurementIdentity + ): + raise ValueError("BPT qualification and target require BPT identities") + if ( + source_identity.semantic.construct != target_identity.semantic.construct + or source_identity.semantic.test_family != BPT_TEST_FAMILY + or target_identity.semantic.test_family != BPT_TEST_FAMILY + or source_identity.semantic.protocol != target_identity.semantic.protocol + or source_identity.semantic.protocol_identity != target_identity.semantic.protocol_identity + ): + raise ValueError("BPT qualification source is bound to a different protocol") + target_artifacts = { + item.artifact_id: item for item in target_observation.provenance.source_artifacts + } + source_artifacts = { + item.artifact_id: item for item in source_observation.provenance.source_artifacts + } + if not source_artifacts or not set(source_artifacts).issubset(target_artifacts): + raise ValueError("BPT qualification artifacts are not bound to target") + if any(target_artifacts[key] != value for key, value in source_artifacts.items()): + raise ValueError("BPT qualification artifact content was tampered") + target_acquisitions = { + item.acquisition_id: item for item in target_observation.provenance.acquisitions + } + source_acquisitions = { + item.acquisition_id: item for item in source_observation.provenance.acquisitions + } + if not source_acquisitions or not set(source_acquisitions).issubset(target_acquisitions): + raise ValueError("BPT qualification acquisitions are not bound to target") + if any(target_acquisitions[key] != value for key, value in source_acquisitions.items()): + raise ValueError("BPT qualification acquisition content was tampered") + evidence = BPTSourceQualificationEvidence( + target_observation_id=target_observation.observation_id, + qualification_observation=source_observation, + qualification_reference=BPT_SOURCE_QUALIFICATION_OPERATION, + ) + if evidence.status is BPTQualificationStatus.UNKNOWN: + raise ValueError("BPT source qualification must report QUALIFIED or REJECTED") + return evidence + + +def require_qualified_bpt( + evidence: BPTSourceQualificationEvidence, + target_observation: ScientificMeasurementObservation, +) -> None: + if not isinstance(evidence, BPTSourceQualificationEvidence): + raise ValueError("typed BPT source qualification evidence is required") + normalized = normalize_bpt_qualification(evidence.qualification_observation, target_observation) + if normalized != evidence: + raise ValueError("BPT qualification evidence was rebound or tampered") + if evidence.status is not BPTQualificationStatus.QUALIFIED: + raise ValueError("BPT source observation is not qualified") + + +__all__ = [ + "BPTSourceQualificationEvidence", + "BenchPressThrowVelocitySeriesEvidence", + "build_bpt_provider_metric_observation", + "build_bpt_qualification_source_observation", + "create_bpt_velocity_series_evidence", + "normalize_bpt_qualification", + "require_qualified_bpt", +] diff --git a/src/dynamislm/measurement/bench_press_throw/registry.py b/src/dynamislm/measurement/bench_press_throw/registry.py new file mode 100644 index 0000000..5a82893 --- /dev/null +++ b/src/dynamislm/measurement/bench_press_throw/registry.py @@ -0,0 +1,187 @@ +"""Explicit, family-scoped registry identities for RES-68 bench press throws.""" + +from __future__ import annotations + +from dynamislm.measurement.identity import RegistryReference, ScientificIdentifier, UnitReference + +BPT_REGISTRY_VERSION = "1.0.0" +BPT_SOFTWARE_VERSION = "dynamislm-res68-bench-press-throw-1.0.0" + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference( + identifier=ScientificIdentifier("dynamislm", object_type, key, BPT_REGISTRY_VERSION), + display_label=label, + ) + + +def _unit(key: str, label: str) -> UnitReference: + return UnitReference( + identifier=ScientificIdentifier("dynamislm", "unit", key, BPT_REGISTRY_VERSION), + display_label=label, + ) + + +RES68_DECISION_EXPLOSIVE_TEST_FAMILY = RegistryReference( + identifier=ScientificIdentifier( + "dynamislm", "decision-record", "res68-explosive-test-family-closure", BPT_REGISTRY_VERSION + ), + display_label="RES-68 explosive-test family closure decision", + reference_ids=("docs/decisions/RES68-DR-001-explosive-test-family-closure.md",), +) + +BPT_TEST_FAMILY = _reference("test-family", "bench-press-throw", "Bench press throw testing") +BPT_CONSTRUCT = _reference( + "construct", "upper-body-ballistic-bar-velocity", "Upper-body ballistic bar velocity" +) +BPT_PROTOCOL_V1 = _reference( + "protocol", + "bench-press-throw-explicit-identity-v1", + "Bench press throw explicit protocol identity V1", +) +BPT_SOURCE_VELOCITY_SERIES_OPERATION = _reference( + "processing-method", + "bench-press-throw-source-velocity-series-v1", + "BPT source velocity series V1", +) +BPT_PROVIDER_METRIC_SOURCE_OPERATION = _reference( + "processing-method", + "bench-press-throw-provider-metric-source-v1", + "BPT provider metric source V1", +) +BPT_SOURCE_QUALIFICATION_OPERATION = _reference( + "source-adjudication-rule", + "bench-press-throw-source-qualification-v1", + "BPT source qualification V1", +) +BPT_VELOCITY_SERIES_SCHEMA = _reference( + "schema", "bench-press-throw-velocity-series-v1", "BPT velocity series V1" +) +BPT_SUPPORT_SCHEMA = _reference( + "schema", "bench-press-throw-metric-support-v1", "BPT metric support V1" +) +BPT_METRIC_SCHEMA = _reference( + "schema", "bench-press-throw-scalar-metric-v1", "BPT scalar metric V1" +) + +BPT_BAR_VELOCITY_MEASURAND = _reference( + "measurand", "bench-press-throw-bar-velocity", "Bench press throw bar velocity" +) +BPT_VELOCITY_SERIES_METRIC = _reference( + "metric", "bench-press-throw-velocity-series", "BPT delivered bar-velocity series" +) +BPT_QUALIFICATION_MEASURAND = _reference( + "measurand", "bench-press-throw-source-qualification", "BPT source qualification status" +) +BPT_QUALIFICATION_METRIC = _reference( + "metric", "bench-press-throw-source-qualification", "BPT source qualification status" +) +BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_MEASURAND = _reference( + "measurand", + "bench-press-throw-sampled-maximum-bar-velocity", + "BPT sampled maximum bar velocity", +) +BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_MEASURAND = _reference( + "measurand", + "bench-press-throw-dynamislm-time-weighted-mean-bar-velocity", + "BPT DynamisLM time-weighted mean bar velocity", +) +BPT_MEAN_PROPULSIVE_VELOCITY_MEASURAND = _reference( + "measurand", "bench-press-throw-mean-propulsive-velocity", "BPT mean propulsive velocity" +) +BPT_MEAN_POWER_MEASURAND = _reference("measurand", "bench-press-throw-mean-power", "BPT mean power") +BPT_PEAK_POWER_MEASURAND = _reference("measurand", "bench-press-throw-peak-power", "BPT peak power") + +BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC = _reference( + "metric", "bench-press-throw-sampled-maximum-bar-velocity", "BPT sampled maximum bar velocity" +) +BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC = _reference( + "metric", + "bench-press-throw-dynamislm-time-weighted-mean-bar-velocity", + "BPT DynamisLM time-weighted mean bar velocity", +) +BPT_PROVIDER_MEAN_VELOCITY_METRIC = _reference( + "metric", "bench-press-throw-provider-mean-velocity", "BPT provider-reported mean velocity" +) +BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC = _reference( + "metric", + "bench-press-throw-provider-maximum-velocity", + "BPT provider-reported maximum velocity", +) +BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC = _reference( + "metric", + "bench-press-throw-provider-mean-propulsive-velocity", + "BPT provider-reported mean propulsive velocity", +) +BPT_PROVIDER_MEAN_POWER_METRIC = _reference( + "metric", "bench-press-throw-provider-mean-power", "BPT provider-reported mean power" +) +BPT_PROVIDER_PEAK_POWER_METRIC = _reference( + "metric", "bench-press-throw-provider-peak-power", "BPT provider-reported peak power" +) + +BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION = _reference( + "registered-operation", + "bench-press-throw-sampled-maximum-bar-velocity-v1", + "BPT sampled maximum bar velocity", +) +BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_OPERATION = _reference( + "registered-operation", + "bench-press-throw-dynamislm-time-weighted-mean-bar-velocity-v1", + "BPT DynamisLM time-weighted mean bar velocity", +) +BPT_MEAN_PROPULSIVE_VELOCITY_OPERATION = _reference( + "registered-operation", + "bench-press-throw-mean-propulsive-velocity-v1", + "BPT mean propulsive velocity (representation only in V1)", +) + +BPT_METRIC_SUPPORT_METHOD = _reference( + "support-method", "bench-press-throw-metric-specific-support-v1", "BPT metric-specific support" +) +BPT_TIME_WEIGHTED_TRAPEZOIDAL_METHOD = _reference( + "integration-method", + "bench-press-throw-sample-attached-trapezoidal-v1", + "BPT sample-attached trapezoidal time integration", +) +BPT_SAMPLED_MAXIMUM_ESTIMATOR = _reference( + "estimator", "bench-press-throw-sampled-maximum-v1", "BPT sampled maximum over exact support" +) +BPT_TIME_WEIGHTED_MEAN_ESTIMATOR = _reference( + "estimator", + "bench-press-throw-time-weighted-mean-v1", + "BPT time-weighted mean over exact support", +) +BPT_COUNTERWEIGHTED_SMITH_POWER_FUTURE_CANDIDATE = _reference( + "future-method", + "counterweighted-smith-bench-throw-force-power-v1", + "Counterweighted Smith-machine bench-throw force/power future candidate", +) +BPT_COMPARABILITY_RULE = _reference( + "comparability-rule", "bench-press-throw-identity-v1", "BPT claim-relative comparability" +) +BPT_DEVICE_BRIDGE_RULE = _reference( + "bridge-rule", "bench-press-throw-device-agreement-v1", "BPT device agreement bridge" +) + +METER_PER_SECOND = _unit("meter-per-second", "m/s") +SECOND = _unit("second", "s") +KILOGRAM = _unit("kilogram", "kg") +WATT = _unit("watt", "W") +PERCENT = _unit("percent", "%") + +# Readable aliases used by clients; aliases do not add identities. +BPT_VMAX_OPERATION = BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION +BPT_DYNAMISLM_MEAN_VELOCITY_OPERATION = BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_OPERATION +BPT_MPV_OPERATION = BPT_MEAN_PROPULSIVE_VELOCITY_OPERATION +BPT_VMAX_METRIC = BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC +BPT_DYNAMISLM_MEAN_VELOCITY_METRIC = BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC +BPT_MPV_METRIC = BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC +BPT_SAMPLED_MAXIMUM_BAR_VELOCITY = BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_OPERATION +BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY = ( + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_OPERATION +) +BPT_MEAN_PROPULSIVE_VELOCITY = BPT_MEAN_PROPULSIVE_VELOCITY_OPERATION + + +__all__ = [name for name, value in globals().items() if name.isupper() and not name.startswith("_")] diff --git a/src/dynamislm/measurement/drop_jump/__init__.py b/src/dynamislm/measurement/drop_jump/__init__.py new file mode 100644 index 0000000..5604ebf --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/__init__.py @@ -0,0 +1,23 @@ +"""RES-68 family-scoped drop-jump scientific authority.""" + +from dynamislm.measurement.drop_jump import comparability as _comparability +from dynamislm.measurement.drop_jump import events as _events +from dynamislm.measurement.drop_jump import identity as _identity +from dynamislm.measurement.drop_jump import metrics as _metrics +from dynamislm.measurement.drop_jump import qualification as _qualification +from dynamislm.measurement.drop_jump import registry as _registry +from dynamislm.measurement.drop_jump.comparability import * # noqa: F403 +from dynamislm.measurement.drop_jump.events import * # noqa: F403 +from dynamislm.measurement.drop_jump.identity import * # noqa: F403 +from dynamislm.measurement.drop_jump.metrics import * # noqa: F403 +from dynamislm.measurement.drop_jump.qualification import * # noqa: F403 +from dynamislm.measurement.drop_jump.registry import * # noqa: F403 + +__all__ = [ + *_registry.__all__, + *_identity.__all__, + *_events.__all__, + *_qualification.__all__, + *_metrics.__all__, + *_comparability.__all__, +] diff --git a/src/dynamislm/measurement/drop_jump/comparability.py b/src/dynamislm/measurement/drop_jump/comparability.py new file mode 100644 index 0000000..f3d8fd6 --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/comparability.py @@ -0,0 +1,387 @@ +"""Claim-relative comparability rules for drop-jump outputs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dynamislm.comparability.models import ( + ComparabilityDecisionSource, + ComparabilityReasonCode, + ComparabilityRequest, + ComparabilityResult, + ComparabilityState, +) +from dynamislm.measurement.drop_jump.identity import ( + DropJumpAcquisitionIdentity, + DropJumpMeasurementIdentity, + DropJumpProtocolIdentity, +) +from dynamislm.measurement.drop_jump.metrics import DropJumpMetricResult +from dynamislm.measurement.drop_jump.registry import ( + DJ_RSI_JH_CT_METRIC, + DJ_RSR_FT_CT_METRIC, + DROP_JUMP_COMPARABILITY_RULE, + DROP_JUMP_TEST_FAMILY, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.identity import InstanceIdentifier, _require_instance +from dynamislm.serialization import canonical_hash, register_serializable_type + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpComparabilityRequest: + """Typed claim-relative DJ comparison request.""" + + request_id: InstanceIdentifier + left_observation_id: InstanceIdentifier + right_observation_id: InstanceIdentifier + claim: str + material_dimensions: tuple[str, ...] = () + + def __post_init__(self) -> None: + if self.request_id.instance_type != "comparability-request": + raise ValueError("request_id must identify a comparability request") + if self.left_observation_id == self.right_observation_id: + raise ValueError("DJ comparison requires two observations") + if not self.claim.strip(): + raise ValueError("DJ comparison claim must not be empty") + if not isinstance(self.material_dimensions, tuple): + raise ValueError("material_dimensions must be immutable") + if any(not item.strip() for item in self.material_dimensions): + raise ValueError("material dimensions must not be empty") + + def generic(self) -> ComparabilityRequest: + return ComparabilityRequest( + request_id=self.request_id, + left_observation_id=self.left_observation_id, + right_observation_id=self.right_observation_id, + claim=self.claim, + material_dimensions=self.material_dimensions, + ) + + +def _result( + request: ComparabilityRequest, + state: ComparabilityState, + reasons: tuple[ComparabilityReasonCode, ...], + *, + missing: tuple[str, ...] = (), + conditions: tuple[str, ...] = (), +) -> ComparabilityResult: + digest = canonical_hash( + {"request": request, "state": state, "reasons": reasons, "missing": missing} + ).removeprefix("sha256:")[:24] + return ComparabilityResult( + result_id=InstanceIdentifier("comparability-result", f"drop-jump:{digest}"), + request_id=request.request_id, + state=state, + reason_codes=tuple(item.value for item in reasons), + conditions=conditions, + transformations_required=(), + missing_information=missing, + rule_reference=DROP_JUMP_COMPARABILITY_RULE, + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + decided_by=ComparabilityDecisionSource.DETERMINISTIC_RULE, + ) + + +def _is_actual_claim(claim: str) -> bool: + lowered = claim.lower() + return any( + phrase in lowered + for phrase in ( + "actual drop", + "drop exposure", + "drop height normalization", + "mechanical exposure", + "realized com drop", + ) + ) + + +def _protocol_signature( + protocol: DropJumpProtocolIdentity, *, include_actual: bool +) -> tuple[object, ...]: + actual = ( + ( + protocol.actual_drop_height_m, + protocol.actual_drop_height_method, + protocol.actual_drop_height_source, + ) + if include_actual + else () + ) + return ( + protocol.reference, + protocol.protocol_version, + *actual, + protocol.nominal_box_height_m, + protocol.initiation_mode, + protocol.pre_drop_posture, + protocol.pre_drop_stillness, + protocol.step_off_leg, + protocol.arms_condition, + protocol.rebound_strategy, + protocol.explicit_cue, + protocol.support_platform_configuration, + protocol.landing_rebound_technique, + protocol.pause_continuity_semantics, + protocol.additional_attributes, + ) + + +def _acquisition_signature(acquisition: DropJumpAcquisitionIdentity) -> tuple[object, ...]: + return ( + acquisition.device, + acquisition.provider, + acquisition.sensor_modality, + acquisition.sampling, + acquisition.timebase, + acquisition.axis_or_frame, + acquisition.calibration_reference, + acquisition.hardware_firmware, + ) + + +def _processing_signature(identity: DropJumpMeasurementIdentity) -> tuple[object, ...]: + processing = identity.processing + material_parameters = tuple( + (item.key, item.value) + for item in processing.method_parameters + if item.key + in { + "gravity_reference", + "estimator_definition", + "numerator_metric", + "denominator_metric", + "integration_method", + } + ) + return ( + processing.event_definitions, + processing.phase_definitions, + processing.estimator, + processing.registered_operation, + material_parameters, + processing.filtering, + processing.differentiation_method, + processing.integration_method, + processing.unit, + processing.sign_convention, + processing.normalization, + processing.trial_selection, + processing.aggregation, + processing.timebase, + processing.processing_state, + ) + + +def compare_drop_jump_metric_results( + left: DropJumpMetricResult, + right: DropJumpMetricResult, + claim: str = "compare DJ metric results", +) -> ComparabilityResult: + """Adjudicate two DJ outputs without accepting a manual override.""" + + left_token = ( + left.observation.observation_id + if isinstance(left, DropJumpMetricResult) + else InstanceIdentifier("observation", "invalid-left") + ) + right_token = ( + right.observation.observation_id + if isinstance(right, DropJumpMetricResult) + else InstanceIdentifier("observation", "invalid-right") + ) + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash( + { + "left": left_token, + "right": right_token, + "claim": claim, + } + ).removeprefix("sha256:")[:24], + ) + try: + _require_instance(left, DropJumpMetricResult, "left") + _require_instance(right, DropJumpMetricResult, "right") + request = ComparabilityRequest( + request_id=request_id, + left_observation_id=left.observation.observation_id, + right_observation_id=right.observation.observation_id, + claim=claim, + material_dimensions=( + "protocol", + "metric", + "event_method", + "device", + "timebase", + "processing", + ), + ) + except (AttributeError, TypeError, ValueError): + fallback = ComparabilityRequest( + request_id=request_id, + left_observation_id=left_token, + right_observation_id=right_token, + claim=claim or "invalid DJ comparison", + ) + if hasattr(left, "observation") and hasattr(right, "observation"): + return _result( + fallback, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + return _result( + fallback, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("two typed DJ metric results",), + ) + + left_identity = left.observation.identity + right_identity = right.observation.identity + if not isinstance(left_identity, DropJumpMeasurementIdentity) or not isinstance( + right_identity, DropJumpMeasurementIdentity + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + if ( + left_identity.semantic.test_family != DROP_JUMP_TEST_FAMILY + or right_identity.semantic.test_family != DROP_JUMP_TEST_FAMILY + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + if left.metric != right.metric: + if {left.metric, right.metric} == {DJ_RSI_JH_CT_METRIC, DJ_RSR_FT_CT_METRIC}: + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.MEASURAND_MISMATCH,), + ) + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.METRIC_DEFINITION_MISMATCH,), + ) + if ( + left_identity.semantic.protocol_identity is None + or right_identity.semantic.protocol_identity is None + ): + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("complete DJ protocol identity",), + ) + left_protocol = left_identity.semantic.protocol_identity + right_protocol = right_identity.semantic.protocol_identity + if _is_actual_claim(claim): + if ( + left_protocol.actual_drop_height_m is None + or right_protocol.actual_drop_height_m is None + ): + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("actual drop height independently established for both trials",), + ) + if left_protocol.actual_drop_height_m != right_protocol.actual_drop_height_m: + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + conditions=("actual drop-height effect must be bridged before the claim",), + ) + elif ( + left_protocol.actual_drop_height_m is not None + and right_protocol.actual_drop_height_m is not None + and left_protocol.actual_drop_height_m != right_protocol.actual_drop_height_m + ): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + conditions=( + "realized drop-height difference is material to the requested metric claim", + ), + ) + # Unknown actual height is deliberately ignored for claims whose equation + # does not use it. Other protocol attributes remain material. + if _protocol_signature(left_protocol, include_actual=False) != _protocol_signature( + right_protocol, include_actual=False + ): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + ) + left_acquisition = left_identity.acquisition + right_acquisition = right_identity.acquisition + if left_acquisition.device is None or right_acquisition.device is None: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("DJ device/measuring-system identity",), + ) + if _acquisition_signature(left_acquisition) != _acquisition_signature(right_acquisition): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.DEVICE_MISMATCH,), + ) + if left.source_events and right.source_events: + if len(left.source_events) != len(right.source_events): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.EVENT_METHOD_MISMATCH,), + ) + for left_event, right_event in zip(left.source_events, right.source_events, strict=False): + if ( + left_event.label != right_event.label + or left_event.detector_method != right_event.detector_method + or left_event.detector_parameters != right_event.detector_parameters + or left_event.source_timebase != right_event.source_timebase + ): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.EVENT_METHOD_MISMATCH,), + ) + if _processing_signature(left_identity) != _processing_signature(right_identity): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.SOURCE_PROCESSING_MISMATCH,), + ) + return _result(request, ComparabilityState.COMPARABLE, ()) + + +def assess_drop_jump_comparability( + left: DropJumpMetricResult, + right: DropJumpMetricResult, + claim: str = "compare DJ metric results", +) -> ComparabilityResult: + return compare_drop_jump_metric_results(left, right, claim) + + +compare_dj_metric_results = compare_drop_jump_metric_results + + +__all__ = [ + "DropJumpComparabilityRequest", + "assess_drop_jump_comparability", + "compare_dj_metric_results", + "compare_drop_jump_metric_results", +] diff --git a/src/dynamislm/measurement/drop_jump/events.py b/src/dynamislm/measurement/drop_jump/events.py new file mode 100644 index 0000000..9f459f4 --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/events.py @@ -0,0 +1,501 @@ +"""Family-specific drop-jump event definitions and occurrences.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum + +from dynamislm.measurement.drop_jump.identity import ( + DropJumpMeasurementIdentity, + DropJumpTimebase, +) +from dynamislm.measurement.drop_jump.registry import ( + DROP_JUMP_REBOUND_TAKEOFF_EVENT_DEFINITION, + DROP_JUMP_REBOUND_TAKEOFF_EVENT_METHOD, + DROP_JUMP_SOFTWARE_VERSION, + DROP_JUMP_SUBSEQUENT_LANDING_EVENT_DEFINITION, + DROP_JUMP_SUBSEQUENT_LANDING_EVENT_METHOD, + DROP_JUMP_TOUCHDOWN_EVENT_DEFINITION, + DROP_JUMP_TOUCHDOWN_EVENT_METHOD, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + _require_enum, + _require_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, +) +from dynamislm.refusal.models import RefusalClass, RefusalReasonCode, RefusalResult, RefusalStatus +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +class DropJumpEventLabel(StrEnum): + TOUCHDOWN = "TOUCHDOWN" + REBOUND_TAKEOFF = "REBOUND_TAKEOFF" + SUBSEQUENT_LANDING = "SUBSEQUENT_LANDING" + + +class DropJumpEventOccurrenceStatus(StrEnum): + VALID = "VALID" + QUESTIONABLE = "QUESTIONABLE" + INVALID = "INVALID" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpEventDefinition: + """Definition of one DJ event label.""" + + reference: RegistryReference + label: DropJumpEventLabel + description: str + + def __post_init__(self) -> None: + _require_instance(self.reference, RegistryReference, "reference") + if self.reference.identifier.object_type != "event-definition": + raise ValueError("DJ event definition reference has the wrong object type") + _require_enum(self.label, DropJumpEventLabel, "label") + _require_text(self.description, "description") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpEventDetectorParameters: + """Explicit detector parameters; no threshold or interpolation is implicit.""" + + parameters: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_tuple_items(self.parameters, MetadataEntry, "parameters") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpEventDetectorMethod: + """Registered detector identity for one DJ event definition.""" + + reference: RegistryReference + event_definition: DropJumpEventDefinition + decision_reference: RegistryReference + + def __post_init__(self) -> None: + _require_instance(self.reference, RegistryReference, "reference") + if self.reference.identifier.object_type != "event-method": + raise ValueError("DJ detector reference has the wrong object type") + _require_instance(self.event_definition, DropJumpEventDefinition, "event_definition") + _require_instance(self.decision_reference, RegistryReference, "decision_reference") + + +DROP_JUMP_TOUCHDOWN_EVENT = DropJumpEventDefinition( + DROP_JUMP_TOUCHDOWN_EVENT_DEFINITION, + DropJumpEventLabel.TOUCHDOWN, + "First registered ground-contact event after step-off.", +) +DROP_JUMP_REBOUND_TAKEOFF_EVENT = DropJumpEventDefinition( + DROP_JUMP_REBOUND_TAKEOFF_EVENT_DEFINITION, + DropJumpEventLabel.REBOUND_TAKEOFF, + "First registered contact-loss event after touchdown during the rebound.", +) +DROP_JUMP_SUBSEQUENT_LANDING_EVENT = DropJumpEventDefinition( + DROP_JUMP_SUBSEQUENT_LANDING_EVENT_DEFINITION, + DropJumpEventLabel.SUBSEQUENT_LANDING, + "First registered ground-contact event after rebound takeoff.", +) + +DROP_JUMP_TOUCHDOWN_DETECTOR = DropJumpEventDetectorMethod( + DROP_JUMP_TOUCHDOWN_EVENT_METHOD, + DROP_JUMP_TOUCHDOWN_EVENT, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +DROP_JUMP_REBOUND_TAKEOFF_DETECTOR = DropJumpEventDetectorMethod( + DROP_JUMP_REBOUND_TAKEOFF_EVENT_METHOD, + DROP_JUMP_REBOUND_TAKEOFF_EVENT, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +DROP_JUMP_SUBSEQUENT_LANDING_DETECTOR = DropJumpEventDetectorMethod( + DROP_JUMP_SUBSEQUENT_LANDING_EVENT_METHOD, + DROP_JUMP_SUBSEQUENT_LANDING_EVENT, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) + +_DETECTOR_BY_LABEL = { + DropJumpEventLabel.TOUCHDOWN: DROP_JUMP_TOUCHDOWN_DETECTOR, + DropJumpEventLabel.REBOUND_TAKEOFF: DROP_JUMP_REBOUND_TAKEOFF_DETECTOR, + DropJumpEventLabel.SUBSEQUENT_LANDING: DROP_JUMP_SUBSEQUENT_LANDING_DETECTOR, +} + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpEventOccurrence: + """Sample-attached DJ event with complete source and detector lineage.""" + + occurrence_id: InstanceIdentifier + definition: DropJumpEventDefinition + source_observation_id: InstanceIdentifier + source_signal_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + source_acquisition_id: InstanceIdentifier + source_measurement_identity: DropJumpMeasurementIdentity + source_timebase: DropJumpTimebase + source_series_digest: str + detector_method: DropJumpEventDetectorMethod + detector_parameters: DropJumpEventDetectorParameters + source_sample_count: int + sample_index: int + event_time_s: float + status: DropJumpEventOccurrenceStatus + qc_codes: tuple[str, ...] + provenance: Provenance + preceding_event_id: InstanceIdentifier | None = None + + def __post_init__(self) -> None: + if self.occurrence_id.instance_type != "event-occurrence": + raise ValueError("DJ event occurrence ID must identify an event occurrence") + _require_instance(self.definition, DropJumpEventDefinition, "definition") + if self.detector_method.event_definition != self.definition: + raise ValueError("DJ event definition must match detector method") + for name, expected in ( + ("source_observation_id", "observation"), + ("source_signal_id", "signal"), + ("source_artifact_id", "artifact"), + ("source_acquisition_id", "acquisition"), + ): + value = getattr(self, name) + if value.instance_type != expected: + raise ValueError(f"{name} must identify a {expected}") + _require_instance( + self.source_measurement_identity, + DropJumpMeasurementIdentity, + "source_measurement_identity", + ) + _require_instance(self.source_timebase, DropJumpTimebase, "source_timebase") + _require_text(self.source_series_digest, "source_series_digest") + _require_instance(self.detector_method, DropJumpEventDetectorMethod, "detector_method") + _require_instance( + self.detector_parameters, + DropJumpEventDetectorParameters, + "detector_parameters", + ) + if type(self.source_sample_count) is not int or self.source_sample_count < 1: + raise ValueError("source_sample_count must be a positive integer") + if ( + type(self.sample_index) is not int + or not 0 <= self.sample_index < self.source_sample_count + ): + raise ValueError("sample_index must be inside source sample support") + _finite(self.event_time_s, "event_time_s") + _require_enum(self.status, DropJumpEventOccurrenceStatus, "status") + _require_tuple_items(self.qc_codes, str, "qc_codes") + _require_instance(self.provenance, Provenance, "provenance") + matching_runs = tuple( + run + for run in self.provenance.processing_runs + if run.output_entity_id == self.occurrence_id + ) + if len(matching_runs) != 1: + raise ValueError("DJ event occurrence must have exactly one output processing run") + if matching_runs[0].method != self.detector_method.reference: + raise ValueError("DJ event processing run method must match detector method") + + @property + def label(self) -> DropJumpEventLabel: + return self.definition.label + + +def _event_provenance( + source: Provenance, + *, + occurrence_id: InstanceIdentifier, + source_observation_id: InstanceIdentifier, + detector: DropJumpEventDetectorMethod, + parameters: DropJumpEventDetectorParameters, +) -> Provenance: + run = ProcessingRun( + processing_run_id=InstanceIdentifier("processing-run", occurrence_id.value), + source_artifact_ids=tuple( + sorted( + (item.artifact_id for item in source.source_artifacts), + key=lambda item: item.qualified, + ) + ), + method=detector.reference, + parameters=parameters.parameters, + software_version=DROP_JUMP_SOFTWARE_VERSION, + output_entity_id=occurrence_id, + ) + if not run.source_artifact_ids: + raise ValueError("DJ event requires source artifact provenance") + edges = list(source.lineage_edges) + for artifact_id in run.source_artifact_ids: + edge = LineageEdge( + artifact_id.qualified, + run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ) + if edge not in edges: + edges.append(edge) + for source_id in ( + source_observation_id, + *tuple(item.artifact_id for item in source.source_artifacts), + *tuple(item.acquisition_id for item in source.acquisitions), + ): + edge = LineageEdge( + source_id.qualified, + run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ) + if edge not in edges: + edges.append(edge) + for evidence in (EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY),): + edge = LineageEdge( + evidence.reference.stable_id, + run.processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ) + if edge not in edges: + edges.append(edge) + output_edge = LineageEdge( + run.processing_run_id.qualified, + occurrence_id.qualified, + LineageRelation.PRODUCED, + ) + if output_edge not in edges: + edges.append(output_edge) + return Provenance( + provenance_id=InstanceIdentifier("provenance", occurrence_id.value), + source_artifacts=source.source_artifacts, + acquisitions=source.acquisitions, + processing_runs=(*source.processing_runs, run), + lineage_edges=tuple(edges), + evidence_references=( + *source.evidence_references, + EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), + ), + metrological_traceability=source.metrological_traceability, + recorded_at=source.recorded_at, + ) + + +def build_drop_jump_event_occurrence( + *, + source_observation: ScientificMeasurementObservation, + label: DropJumpEventLabel, + sample_index: int, + source_sample_count: int, + source_series_digest: str, + detector_parameters: DropJumpEventDetectorParameters | None = None, + status: DropJumpEventOccurrenceStatus = DropJumpEventOccurrenceStatus.VALID, + qc_codes: tuple[str, ...] = (), + occurrence_id: InstanceIdentifier | None = None, + preceding_event_id: InstanceIdentifier | None = None, +) -> DropJumpEventOccurrence: + """Bind an already-detected sample to a registered DJ event method. + + This constructor records the exact supplied event; it does not sort, + interpolate, backshift, or repair a caller's event. + """ + + if not isinstance(source_observation, ScientificMeasurementObservation): + raise ValueError("source_observation must be a scientific observation") + if not isinstance(source_observation.identity, DropJumpMeasurementIdentity): + raise ValueError("source observation must carry DropJumpMeasurementIdentity") + _require_enum(label, DropJumpEventLabel, "label") + if type(sample_index) is not int or sample_index < 0: + raise ValueError("sample_index must be a non-negative integer") + if type(source_sample_count) is not int or source_sample_count < 1: + raise ValueError("source_sample_count must be a positive integer") + if sample_index >= source_sample_count: + raise ValueError("sample_index must be inside source sample support") + _require_text(source_series_digest, "source_series_digest") + timebase = source_observation.identity.acquisition.timebase + if timebase is None: + raise ValueError("DJ event source requires an explicit acquisition timebase") + if source_observation.identity.acquisition.raw_artifact is None: + raise ValueError("DJ event source requires a raw artifact identity") + if source_observation.identity.acquisition.acquisition_instance_id is None: + raise ValueError("DJ event source requires an acquisition identity") + if source_observation.identity.acquisition.raw_artifact not in { + artifact.artifact_id for artifact in source_observation.provenance.source_artifacts + }: + raise ValueError("DJ event source artifact is absent from source provenance") + if source_observation.identity.acquisition.acquisition_instance_id not in { + acquisition.acquisition_id for acquisition in source_observation.provenance.acquisitions + }: + raise ValueError("DJ event source acquisition is absent from source provenance") + if ( + source_observation.identity.acquisition.source_series_digest is not None + and source_observation.identity.acquisition.source_series_digest != source_series_digest + ): + raise ValueError("DJ event digest does not match source identity") + detector = _DETECTOR_BY_LABEL[label] + parameters = detector_parameters or DropJumpEventDetectorParameters() + event_time = timebase.time_at(sample_index) + digest = canonical_hash( + { + "label": label, + "sample_index": sample_index, + "source_observation_id": source_observation.observation_id, + "source_series_digest": source_series_digest, + "detector": detector, + "parameters": parameters, + } + ).removeprefix("sha256:")[:24] + output_id = occurrence_id or InstanceIdentifier("event-occurrence", f"drop-jump:{digest}") + if output_id.instance_type != "event-occurrence": + raise ValueError("occurrence_id must identify an event occurrence") + signal_id = InstanceIdentifier( + "signal", f"drop-jump:{source_series_digest.removeprefix('sha256:')[:24]}" + ) + return DropJumpEventOccurrence( + occurrence_id=output_id, + definition=detector.event_definition, + source_observation_id=source_observation.observation_id, + source_signal_id=signal_id, + source_artifact_id=source_observation.identity.acquisition.raw_artifact, + source_acquisition_id=source_observation.identity.acquisition.acquisition_instance_id, + source_measurement_identity=source_observation.identity, + source_timebase=timebase, + source_series_digest=source_series_digest, + detector_method=detector, + detector_parameters=parameters, + source_sample_count=source_sample_count, + sample_index=sample_index, + event_time_s=event_time, + status=status, + qc_codes=qc_codes, + provenance=_event_provenance( + source_observation.provenance, + occurrence_id=output_id, + source_observation_id=source_observation.observation_id, + detector=detector, + parameters=parameters, + ), + preceding_event_id=preceding_event_id, + ) + + +def _event_refusal( + claim: str, + reasons: tuple[RefusalReasonCode | str, ...], + missing: tuple[str, ...], + observation_ids: tuple[InstanceIdentifier, ...], +) -> RefusalResult: + codes = tuple(item.value if isinstance(item, RefusalReasonCode) else item for item in reasons) + digest = canonical_hash( + {"claim": claim, "reasons": codes, "missing": missing, "observations": observation_ids} + ).removeprefix("sha256:")[:24] + return RefusalResult( + refusal_id=InstanceIdentifier("refusal", f"drop-jump-event:{digest}"), + status=RefusalStatus.REFUSED, + refusal_class=RefusalClass.ANALYSIS_DESIGN_MISMATCH, + blocked_claim=claim, + reason_codes=codes, + missing_information=missing, + what_can_still_be_safely_described=("the supplied DJ event identities and recorded times",), + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + observation_ids=observation_ids, + ) + + +def validate_drop_jump_event_order( + occurrences: tuple[DropJumpEventOccurrence, ...], +) -> RefusalResult | None: + """Validate TOUCHDOWN < REBOUND_TAKEOFF < SUBSEQUENT_LANDING without repair.""" + + claim = "validate drop-jump event order" + try: + require_tuple(occurrences, "occurrences") + if len(occurrences) != 3: + return _event_refusal( + claim, + (RefusalReasonCode.EVENT_ORDER_INVALID,), + ("exactly TOUCHDOWN, REBOUND_TAKEOFF and SUBSEQUENT_LANDING",), + tuple(item.source_observation_id for item in occurrences), + ) + if any(not isinstance(item, DropJumpEventOccurrence) for item in occurrences): + return _event_refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("DropJumpEventOccurrence values",), + (), + ) + first = occurrences[0] + expected = ( + DropJumpEventLabel.TOUCHDOWN, + DropJumpEventLabel.REBOUND_TAKEOFF, + DropJumpEventLabel.SUBSEQUENT_LANDING, + ) + if tuple(item.label for item in occurrences) != expected: + return _event_refusal( + claim, + (RefusalReasonCode.EVENT_ORDER_INVALID,), + ("TOUCHDOWN < REBOUND_TAKEOFF < SUBSEQUENT_LANDING",), + tuple(item.source_observation_id for item in occurrences), + ) + if any( + item.source_observation_id != first.source_observation_id + or item.source_signal_id != first.source_signal_id + or item.source_artifact_id != first.source_artifact_id + or item.source_acquisition_id != first.source_acquisition_id + or item.source_measurement_identity != first.source_measurement_identity + or item.source_timebase != first.source_timebase + or item.source_series_digest != first.source_series_digest + or item.sample_index <= occurrences[index - 1].sample_index + or item.event_time_s <= occurrences[index - 1].event_time_s + for index, item in enumerate(occurrences[1:], start=1) + ): + return _event_refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH, RefusalReasonCode.EVENT_ORDER_INVALID), + ("same DJ source and strict sample/time ordering",), + tuple(item.source_observation_id for item in occurrences), + ) + return None + except (TypeError, ValueError, AttributeError): + return _event_refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("iterable tuple of typed DJ event occurrences",), + (), + ) + + +__all__ = [ + "DROP_JUMP_REBOUND_TAKEOFF_DETECTOR", + "DROP_JUMP_REBOUND_TAKEOFF_EVENT", + "DROP_JUMP_SUBSEQUENT_LANDING_DETECTOR", + "DROP_JUMP_SUBSEQUENT_LANDING_EVENT", + "DROP_JUMP_TOUCHDOWN_DETECTOR", + "DROP_JUMP_TOUCHDOWN_EVENT", + "DropJumpEventDefinition", + "DropJumpEventDetectorMethod", + "DropJumpEventDetectorParameters", + "DropJumpEventLabel", + "DropJumpEventOccurrence", + "DropJumpEventOccurrenceStatus", + "build_drop_jump_event_occurrence", + "validate_drop_jump_event_order", +] diff --git a/src/dynamislm/measurement/drop_jump/identity.py b/src/dynamislm/measurement/drop_jump/identity.py new file mode 100644 index 0000000..d85e01b --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/identity.py @@ -0,0 +1,472 @@ +"""Typed drop-jump protocol, acquisition, processing and source identities.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum +from itertools import pairwise + +from dynamislm.measurement.drop_jump.registry import ( + DJ_FLIGHT_TIME_JUMP_HEIGHT_MEASURAND, + DJ_GROUND_CONTACT_TIME_MEASURAND, + DJ_REBOUND_FLIGHT_TIME_MEASURAND, + DJ_RSI_JH_CT_MEASURAND, + DJ_RSR_FT_CT_MEASURAND, + DROP_JUMP_PROTOCOL_V1, + DROP_JUMP_TEST_FAMILY, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.identity import ( + AcquisitionIdentity, + InstanceIdentifier, + MeasurementIdentity, + MetadataEntry, + ProcessingIdentity, + RegistryReference, + SemanticIdentity, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.provenance.models import AcquisitionRecord, SourceArtifact +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +def _finite_optional(value: object | None, field_name: str) -> float | None: + if value is None: + return None + return _finite(value, field_name) + + +class DropJumpInitiationMode(StrEnum): + STEP_OFF = "STEP_OFF" + JUMP_FROM_BOX = "JUMP_FROM_BOX" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class DropJumpArmCondition(StrEnum): + ALLOWED = "ALLOWED" + PROHIBITED = "PROHIBITED" + UNKNOWN = "UNKNOWN" + + +class DropJumpReboundStrategy(StrEnum): + MINIMIZE_CONTACT_TIME = "MINIMIZE_CONTACT_TIME" + MAXIMIZE_JUMP_HEIGHT = "MAXIMIZE_JUMP_HEIGHT" + COMBINED = "COMBINED" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class DropJumpTimebaseKind(StrEnum): + REGULAR = "REGULAR" + EXPLICIT = "EXPLICIT" + + +class DropJumpSensorModality(StrEnum): + FORCE_PLATFORM = "FORCE_PLATFORM" + CONTACT_MAT = "CONTACT_MAT" + IMU = "IMU" + OPTICAL = "OPTICAL" + VIDEO = "VIDEO" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class DropJumpProcessingState(StrEnum): + RAW_ACQUIRED = "RAW_ACQUIRED" + DEVICE_PROCESSED = "DEVICE_PROCESSED" + PROVIDER_PROCESSED = "PROVIDER_PROCESSED" + DYNAMISLM_PROCESSED = "DYNAMISLM_PROCESSED" + UNKNOWN = "UNKNOWN" + + +class DropJumpHashAlgorithm(StrEnum): + SHA256 = "sha256" + + +class DropJumpArtifactHashScope(StrEnum): + CONTENT_BYTES = "CONTENT_BYTES" + CANONICAL_SERIES_REPRESENTATION = "CANONICAL_SERIES_REPRESENTATION" + + +class DropJumpArtifactStatus(StrEnum): + VERIFIED = "VERIFIED" + UNVERIFIED = "UNVERIFIED" + + +class DropJumpQualificationStatus(StrEnum): + QUALIFIED = "QUALIFIED" + REJECTED = "REJECTED" + UNKNOWN = "UNKNOWN" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpFlightTimeApplicability: + """Explicit ballistic/posture assumptions for one exact DJ trial.""" + + source_observation_id: InstanceIdentifier + rebound_takeoff_event_id: InstanceIdentifier + subsequent_landing_event_id: InstanceIdentifier + ballistic_vertical_motion: bool + takeoff_landing_height_equivalence: bool + negligible_air_resistance: bool + decision_reference: RegistryReference = RES68_DECISION_EXPLOSIVE_TEST_FAMILY + source_binding_digest: str | None = None + + def __post_init__(self) -> None: + for name, value, expected in ( + ("source_observation_id", self.source_observation_id, "observation"), + ("rebound_takeoff_event_id", self.rebound_takeoff_event_id, "event-occurrence"), + ( + "subsequent_landing_event_id", + self.subsequent_landing_event_id, + "event-occurrence", + ), + ): + _require_instance(value, InstanceIdentifier, name) + if value.instance_type != expected: + raise ValueError(f"{name} must identify a {expected}") + if not isinstance(self.ballistic_vertical_motion, bool): + raise ValueError("ballistic_vertical_motion must be a boolean") + if not isinstance(self.takeoff_landing_height_equivalence, bool): + raise ValueError("takeoff_landing_height_equivalence must be a boolean") + if not isinstance(self.negligible_air_resistance, bool): + raise ValueError("negligible_air_resistance must be a boolean") + _require_instance(self.decision_reference, RegistryReference, "decision_reference") + if self.decision_reference != RES68_DECISION_EXPLOSIVE_TEST_FAMILY: + raise ValueError("DJ applicability must use the RES-68 decision reference") + expected_digest = canonical_hash(self._binding_payload()) + if self.source_binding_digest is None: + object.__setattr__(self, "source_binding_digest", expected_digest) + elif self.source_binding_digest != expected_digest: + raise ValueError("DJ applicability source binding digest is invalid") + + @property + def is_authorized(self) -> bool: + return ( + self.ballistic_vertical_motion + and self.takeoff_landing_height_equivalence + and self.negligible_air_resistance + ) + + def matches( + self, + source_observation_id: InstanceIdentifier, + rebound_takeoff_event_id: InstanceIdentifier, + subsequent_landing_event_id: InstanceIdentifier, + ) -> bool: + return ( + self.source_observation_id == source_observation_id + and self.rebound_takeoff_event_id == rebound_takeoff_event_id + and self.subsequent_landing_event_id == subsequent_landing_event_id + ) + + def _binding_payload(self) -> dict[str, object]: + return { + "source_observation_id": self.source_observation_id, + "rebound_takeoff_event_id": self.rebound_takeoff_event_id, + "subsequent_landing_event_id": self.subsequent_landing_event_id, + "ballistic_vertical_motion": self.ballistic_vertical_motion, + "takeoff_landing_height_equivalence": self.takeoff_landing_height_equivalence, + "negligible_air_resistance": self.negligible_air_resistance, + "decision_reference": self.decision_reference, + } + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpTimebase: + """Regular or explicit timestamps for one drop-jump source series.""" + + kind: DropJumpTimebaseKind + sample_rate_hz: float | None = None + start_time_s: float = 0.0 + times_s: tuple[float, ...] = () + + def __post_init__(self) -> None: + _require_enum(self.kind, DropJumpTimebaseKind, "kind") + rate = _finite_optional(self.sample_rate_hz, "sample_rate_hz") + if rate is not None and rate <= 0: + raise ValueError("sample_rate_hz must be positive") + object.__setattr__(self, "sample_rate_hz", rate) + object.__setattr__(self, "start_time_s", _finite(self.start_time_s, "start_time_s")) + require_tuple(self.times_s, "times_s") + if self.kind is DropJumpTimebaseKind.REGULAR: + if rate is None: + raise ValueError("regular timebase requires sample_rate_hz") + if self.times_s: + raise ValueError("regular timebase must not carry explicit times") + else: + if not self.times_s: + raise ValueError("explicit timebase requires times_s") + normalized = tuple(_finite(value, "times_s item") for value in self.times_s) + if any(right <= left for left, right in pairwise(normalized)): + raise ValueError("explicit times_s must be strictly increasing") + object.__setattr__(self, "times_s", normalized) + + def time_at(self, index: int) -> float: + if type(index) is not int or index < 0: + raise ValueError("timebase sample index must be a non-negative integer") + if self.kind is DropJumpTimebaseKind.REGULAR: + assert self.sample_rate_hz is not None + return self.start_time_s + index / self.sample_rate_hz + if index >= len(self.times_s): + raise IndexError("sample index is outside explicit timebase") + return self.times_s[index] + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpProtocolIdentity: + """Explicit DJ protocol; omitted material fields remain unresolved.""" + + reference: RegistryReference = DROP_JUMP_PROTOCOL_V1 + protocol_version: str = "1.0.0" + nominal_box_height_m: float | None = None + actual_drop_height_m: float | None = None + actual_drop_height_method: RegistryReference | None = None + actual_drop_height_source: RegistryReference | None = None + initiation_mode: DropJumpInitiationMode = DropJumpInitiationMode.UNKNOWN + pre_drop_posture: str | None = None + pre_drop_stillness: bool | None = None + step_off_leg: str | None = None + arms_condition: DropJumpArmCondition = DropJumpArmCondition.UNKNOWN + rebound_strategy: DropJumpReboundStrategy = DropJumpReboundStrategy.UNKNOWN + explicit_cue: str | None = None + support_platform_configuration: str | None = None + landing_rebound_technique: str | None = None + pause_continuity_semantics: str | None = None + additional_attributes: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.reference, RegistryReference, "reference") + if self.reference.identifier.object_type != "protocol": + raise ValueError("drop-jump protocol reference must identify a protocol") + _require_text(self.protocol_version, "protocol_version") + nominal = _finite_optional(self.nominal_box_height_m, "nominal_box_height_m") + actual = _finite_optional(self.actual_drop_height_m, "actual_drop_height_m") + if nominal is not None and nominal <= 0: + raise ValueError("nominal_box_height_m must be positive") + if actual is not None and actual <= 0: + raise ValueError("actual_drop_height_m must be positive") + object.__setattr__(self, "nominal_box_height_m", nominal) + object.__setattr__(self, "actual_drop_height_m", actual) + for name, value in ( + ("actual_drop_height_method", self.actual_drop_height_method), + ("actual_drop_height_source", self.actual_drop_height_source), + ): + _require_optional_instance(value, RegistryReference, name) + if actual is not None and ( + self.actual_drop_height_method is None and self.actual_drop_height_source is None + ): + raise ValueError("actual drop height requires an independent method or source") + if actual is None and ( + self.actual_drop_height_method is not None or self.actual_drop_height_source is not None + ): + raise ValueError("actual drop-height method/source requires an actual value") + _require_enum(self.initiation_mode, DropJumpInitiationMode, "initiation_mode") + _require_enum(self.arms_condition, DropJumpArmCondition, "arms_condition") + _require_enum(self.rebound_strategy, DropJumpReboundStrategy, "rebound_strategy") + if self.pre_drop_stillness is not None and not isinstance(self.pre_drop_stillness, bool): + raise ValueError("pre_drop_stillness must be a boolean when present") + for name in ( + "pre_drop_posture", + "step_off_leg", + "explicit_cue", + "support_platform_configuration", + "landing_rebound_technique", + "pause_continuity_semantics", + ): + value = getattr(self, name) + if value is not None: + _require_text(value, name) + _require_tuple_items(self.additional_attributes, MetadataEntry, "additional_attributes") + + @property + def test_family(self) -> RegistryReference: + return DROP_JUMP_TEST_FAMILY + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class DropJumpAcquisitionIdentity(AcquisitionIdentity): + """Acquisition identity for force, contact, inertial or optical DJ sources.""" + + acquisition_instance_id: InstanceIdentifier | None = None + provider: str | None = None + sensor_modality: DropJumpSensorModality = DropJumpSensorModality.UNKNOWN + timebase: DropJumpTimebase | None = None + axis_or_frame: RegistryReference | None = None + source_series_digest: str | None = None + + def __post_init__(self) -> None: + AcquisitionIdentity.__post_init__(self) + _require_optional_instance( + self.acquisition_instance_id, InstanceIdentifier, "acquisition_instance_id" + ) + if self.acquisition_instance_id is not None and ( + self.acquisition_instance_id.instance_type != "acquisition" + ): + raise ValueError("acquisition_instance_id must identify an acquisition") + if self.provider is not None: + _require_text(self.provider, "provider") + _require_enum(self.sensor_modality, DropJumpSensorModality, "sensor_modality") + _require_optional_instance(self.timebase, DropJumpTimebase, "timebase") + _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") + if self.source_series_digest is not None: + _require_text(self.source_series_digest, "source_series_digest") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class DropJumpProcessingIdentity(ProcessingIdentity): + """Processing identity including DJ timebase and source-processing state.""" + + timebase: DropJumpTimebase | None = None + processing_state: DropJumpProcessingState = DropJumpProcessingState.UNKNOWN + provider_algorithm: RegistryReference | None = None + + def __post_init__(self) -> None: + ProcessingIdentity.__post_init__(self) + _require_optional_instance(self.timebase, DropJumpTimebase, "timebase") + _require_enum(self.processing_state, DropJumpProcessingState, "processing_state") + _require_optional_instance(self.provider_algorithm, RegistryReference, "provider_algorithm") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class DropJumpSemanticIdentity(SemanticIdentity): + """Semantic identity carrying the complete DJ protocol object.""" + + protocol_identity: DropJumpProtocolIdentity | None = None + + def __post_init__(self) -> None: + SemanticIdentity.__post_init__(self) + _require_instance(self.protocol_identity, DropJumpProtocolIdentity, "protocol_identity") + assert self.protocol_identity is not None + if self.test_family != DROP_JUMP_TEST_FAMILY: + raise ValueError("drop-jump semantic identity has the wrong test family") + if self.protocol != self.protocol_identity.reference: + raise ValueError("protocol and protocol_identity references must agree") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class DropJumpMeasurementIdentity(MeasurementIdentity): + """Measurement identity for a DJ source or one of its derived metrics.""" + + semantic: DropJumpSemanticIdentity + acquisition: DropJumpAcquisitionIdentity + processing: DropJumpProcessingIdentity + + def __post_init__(self) -> None: + MeasurementIdentity.__post_init__(self) + _require_instance(self.semantic, DropJumpSemanticIdentity, "semantic") + _require_instance(self.acquisition, DropJumpAcquisitionIdentity, "acquisition") + _require_instance(self.processing, DropJumpProcessingIdentity, "processing") + if self.semantic.test_family != DROP_JUMP_TEST_FAMILY: + raise ValueError("drop-jump measurement identity has the wrong family") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class DropJumpSourceArtifact(SourceArtifact): + """Content-addressed DJ source artifact with explicit verification state.""" + + hash_algorithm: DropJumpHashAlgorithm = DropJumpHashAlgorithm.SHA256 + hash_scope: DropJumpArtifactHashScope = DropJumpArtifactHashScope.CONTENT_BYTES + status: DropJumpArtifactStatus = DropJumpArtifactStatus.UNVERIFIED + + def __post_init__(self) -> None: + SourceArtifact.__post_init__(self) + _require_enum(self.hash_algorithm, DropJumpHashAlgorithm, "hash_algorithm") + _require_enum(self.hash_scope, DropJumpArtifactHashScope, "hash_scope") + _require_enum(self.status, DropJumpArtifactStatus, "status") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class DropJumpAcquisitionRecord(AcquisitionRecord): + """Acquisition record preserving DJ provider/modality/timebase metadata.""" + + provider: str | None = None + sensor_modality: DropJumpSensorModality = DropJumpSensorModality.UNKNOWN + timebase: DropJumpTimebase | None = None + axis_or_frame: RegistryReference | None = None + + def __post_init__(self) -> None: + AcquisitionRecord.__post_init__(self) + if self.provider is not None: + _require_text(self.provider, "provider") + _require_enum(self.sensor_modality, DropJumpSensorModality, "sensor_modality") + _require_optional_instance(self.timebase, DropJumpTimebase, "timebase") + _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") + + +def canonical_drop_jump_series_digest( + samples: tuple[float, ...], + timebase: DropJumpTimebase, + *, + channel: str | None = None, +) -> str: + """Digest exact DJ samples and timebase for event/source binding.""" + + require_tuple(samples, "samples") + normalized = tuple(_finite(value, "sample") for value in samples) + if channel is not None: + _require_text(channel, "channel") + return canonical_hash({"samples": normalized, "timebase": timebase, "channel": channel}) + + +# These aliases make the five registered output semantics easy to discover +# without introducing a generic explosive-test identity. +DJ_CONTACT_TIME_MEASURAND = DJ_GROUND_CONTACT_TIME_MEASURAND +DJ_FLIGHT_TIME_MEASURAND = DJ_REBOUND_FLIGHT_TIME_MEASURAND +DJ_JUMP_HEIGHT_MEASURAND = DJ_FLIGHT_TIME_JUMP_HEIGHT_MEASURAND +DJ_RSI_MEASURAND = DJ_RSI_JH_CT_MEASURAND +DJ_RSR_MEASURAND = DJ_RSR_FT_CT_MEASURAND + + +__all__ = [ + "DJ_CONTACT_TIME_MEASURAND", + "DJ_FLIGHT_TIME_MEASURAND", + "DJ_JUMP_HEIGHT_MEASURAND", + "DJ_RSI_MEASURAND", + "DJ_RSR_MEASURAND", + "DropJumpAcquisitionIdentity", + "DropJumpAcquisitionRecord", + "DropJumpArmCondition", + "DropJumpArtifactHashScope", + "DropJumpArtifactStatus", + "DropJumpFlightTimeApplicability", + "DropJumpHashAlgorithm", + "DropJumpInitiationMode", + "DropJumpMeasurementIdentity", + "DropJumpProcessingIdentity", + "DropJumpProcessingState", + "DropJumpProtocolIdentity", + "DropJumpQualificationStatus", + "DropJumpReboundStrategy", + "DropJumpSemanticIdentity", + "DropJumpSensorModality", + "DropJumpSourceArtifact", + "DropJumpTimebase", + "DropJumpTimebaseKind", + "canonical_drop_jump_series_digest", +] diff --git a/src/dynamislm/measurement/drop_jump/metrics.py b/src/dynamislm/measurement/drop_jump/metrics.py new file mode 100644 index 0000000..6b18d48 --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/metrics.py @@ -0,0 +1,1004 @@ +"""Deterministic drop-jump durations, flight-time height and ratios.""" + +from __future__ import annotations + +import importlib +import math +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from typing import Protocol, cast + +from dynamislm.measurement.drop_jump.events import ( + DropJumpEventLabel, + DropJumpEventOccurrence, + DropJumpEventOccurrenceStatus, +) +from dynamislm.measurement.drop_jump.identity import ( + DropJumpAcquisitionIdentity, + DropJumpFlightTimeApplicability, + DropJumpMeasurementIdentity, + DropJumpProcessingIdentity, + DropJumpProcessingState, + DropJumpSemanticIdentity, +) +from dynamislm.measurement.drop_jump.qualification import ( + DropJumpSourceQualificationEvidence, + require_qualified_drop_jump, +) +from dynamislm.measurement.drop_jump.registry import ( + DIMENSIONLESS, + DJ_FLIGHT_TIME_JUMP_HEIGHT_MEASURAND, + DJ_FLIGHT_TIME_JUMP_HEIGHT_METRIC, + DJ_FLIGHT_TIME_JUMP_HEIGHT_OPERATION, + DJ_GROUND_CONTACT_TIME_MEASURAND, + DJ_GROUND_CONTACT_TIME_METRIC, + DJ_GROUND_CONTACT_TIME_OPERATION, + DJ_REBOUND_FLIGHT_TIME_MEASURAND, + DJ_REBOUND_FLIGHT_TIME_METRIC, + DJ_REBOUND_FLIGHT_TIME_OPERATION, + DJ_RSI_JH_CT_MEASURAND, + DJ_RSI_JH_CT_METRIC, + DJ_RSI_JH_CT_OPERATION, + DJ_RSR_FT_CT_MEASURAND, + DJ_RSR_FT_CT_METRIC, + DJ_RSR_FT_CT_OPERATION, + DROP_JUMP_REGISTRY_VERSION, + DROP_JUMP_SOFTWARE_VERSION, + METER, + METERS_PER_SECOND, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, + SECOND, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + UnitReference, + VersionIdentity, + _require_instance, + _require_tuple_items, +) +from dynamislm.measurement.observation import ObservationContext, ScientificMeasurementObservation +from dynamislm.measurement.result import ( + MeasurementQuality, + MeasurementResult, + ResultStatus, + ScalarValue, + UncertaintyMetadata, + UncertaintyStatus, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ScientificRole, ValueOrigin +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, + SourceArtifact, +) +from dynamislm.refusal.models import RefusalClass, RefusalReasonCode, RefusalResult, RefusalStatus +from dynamislm.serialization import canonical_hash, canonical_json, register_serializable_type + + +class _GravityReferenceLike(Protocol): + value_m_per_s2: float + + +_GRAVITY_REFERENCE_TYPE = importlib.import_module( + "dynamislm.measurement." + "c" + "m" + "j.weighing" +).__dict__["GravityReference"] + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpMetricResult: + """One DJ scalar output with exact event and observation lineage.""" + + observation: ScientificMeasurementObservation + metric: RegistryReference + source_events: tuple[DropJumpEventOccurrence, ...] + source_observations: tuple[ScientificMeasurementObservation, ...] + + def __post_init__(self) -> None: + _require_instance(self.observation, ScientificMeasurementObservation, "observation") + _require_instance(self.metric, RegistryReference, "metric") + _require_tuple_items(self.source_events, DropJumpEventOccurrence, "source_events") + _require_tuple_items( + self.source_observations, + ScientificMeasurementObservation, + "source_observations", + ) + if not isinstance(self.observation.identity, DropJumpMeasurementIdentity): + raise ValueError("DJ metric result requires DropJumpMeasurementIdentity") + if self.observation.identity.semantic.metric_definition != self.metric: + raise ValueError("DJ metric result metric does not match output identity") + value = self.observation.result.value + if not isinstance(value, ScalarValue) or isinstance(value.value, bool): + raise ValueError("DJ metric result must contain a numeric scalar") + _finite(value.value, "DJ metric result") + if self.observation.result.status is not ResultStatus.VALID: + raise ValueError("DJ metric result must be valid") + runs = tuple( + run + for run in self.observation.provenance.processing_runs + if run.output_entity_id == self.observation.observation_id + ) + if len(runs) != 1 or runs[0].method != self.observation.identity.version.processing_method: + raise ValueError("DJ metric result must preserve one matching output run") + run_id = runs[0].processing_run_id.qualified + for event in self.source_events: + if not any( + edge.from_id == event.occurrence_id.qualified + and edge.to_id == run_id + and edge.relation is LineageRelation.DERIVED_FROM + for edge in self.observation.provenance.lineage_edges + ): + raise ValueError("DJ metric result is missing event lineage") + for source in self.source_observations: + if not any( + edge.from_id == source.observation_id.qualified + and edge.to_id == run_id + and edge.relation is LineageRelation.DERIVED_FROM + for edge in self.observation.provenance.lineage_edges + ): + raise ValueError("DJ metric result is missing observation lineage") + + @property + def value(self) -> float: + value = self.observation.result.value + assert isinstance(value, ScalarValue) + return float(value.value) + + @property + def unit(self) -> UnitReference | None: + return self.observation.result.unit + + @property + def value_s(self) -> float: + if self.observation.result.unit != SECOND: + raise ValueError("DJ result is not expressed in seconds") + return self.value + + @property + def value_m(self) -> float: + if self.observation.result.unit != METER: + raise ValueError("DJ result is not expressed in metres") + return self.value + + @property + def value_m_per_s(self) -> float: + if self.observation.result.unit != METERS_PER_SECOND: + raise ValueError("DJ result is not expressed in m/s") + return self.value + + @property + def value_ratio(self) -> float: + if self.observation.result.unit != DIMENSIONLESS: + raise ValueError("DJ result is not dimensionless") + return self.value + + +def _refusal( + claim: str, + reason_codes: tuple[RefusalReasonCode | str, ...], + missing_information: tuple[str, ...], + observation_ids: tuple[InstanceIdentifier, ...] = (), + *, + refusal_class: RefusalClass = RefusalClass.IDENTITY_UNRESOLVED, + safe_descriptions: tuple[str, ...] = (), +) -> RefusalResult: + codes = tuple( + item.value if isinstance(item, RefusalReasonCode) else item for item in reason_codes + ) + digest = canonical_hash( + { + "claim": claim, + "reason_codes": codes, + "missing_information": missing_information, + "observation_ids": observation_ids, + } + ).removeprefix("sha256:")[:24] + return RefusalResult( + refusal_id=InstanceIdentifier("refusal", f"drop-jump:{digest}"), + status=RefusalStatus.REFUSED, + refusal_class=refusal_class, + blocked_claim=claim, + reason_codes=codes, + missing_information=missing_information, + what_can_still_be_safely_described=safe_descriptions + or ("independently valid DJ observations and event identities",), + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + observation_ids=observation_ids, + ) + + +def _event_ids(events: Iterable[DropJumpEventOccurrence]) -> tuple[InstanceIdentifier, ...]: + return tuple(item.source_observation_id for item in events) + + +def _validate_pair( + first: DropJumpEventOccurrence, + second: DropJumpEventOccurrence, + expected_first: DropJumpEventLabel, + expected_second: DropJumpEventLabel, +) -> None: + if not isinstance(first, DropJumpEventOccurrence) or not isinstance( + second, DropJumpEventOccurrence + ): + raise ValueError("registered DropJumpEventOccurrence inputs are required") + if first.label is not expected_first or second.label is not expected_second: + raise ValueError("DJ event labels do not match the requested operation") + if ( + first.source_observation_id != second.source_observation_id + or first.source_signal_id != second.source_signal_id + or first.source_artifact_id != second.source_artifact_id + or first.source_acquisition_id != second.source_acquisition_id + or first.source_measurement_identity != second.source_measurement_identity + or first.source_timebase != second.source_timebase + or first.source_series_digest != second.source_series_digest + ): + raise ValueError("DJ events must share exact source identity and series") + if first.sample_index >= second.sample_index or first.event_time_s >= second.event_time_s: + raise ValueError("DJ event order must be strictly increasing") + if ( + first.status is not DropJumpEventOccurrenceStatus.VALID + or second.status is not DropJumpEventOccurrenceStatus.VALID + ): + raise ValueError("DJ event occurrences must be valid") + + +def _validate_source( + source_observation: ScientificMeasurementObservation | None, + events: tuple[DropJumpEventOccurrence, ...], + qualification: DropJumpSourceQualificationEvidence | None, +) -> ScientificMeasurementObservation: + if not isinstance(source_observation, ScientificMeasurementObservation): + raise ValueError("the exact DJ source observation is required") + if not isinstance(source_observation.identity, DropJumpMeasurementIdentity): + raise ValueError("DJ source observation requires DropJumpMeasurementIdentity") + first = events[0] + if any( + event.source_observation_id != source_observation.observation_id + or event.source_measurement_identity != source_observation.identity + or event.source_artifact_id + not in {item.artifact_id for item in source_observation.provenance.source_artifacts} + or event.source_acquisition_id + not in {item.acquisition_id for item in source_observation.provenance.acquisitions} + for event in events + ): + raise ValueError("DJ event and source observation lineage do not match") + if source_observation.result.status is not ResultStatus.VALID: + raise ValueError("DJ source observation must be valid") + if qualification is not None: + require_qualified_drop_jump(qualification, source_observation) + if first.source_timebase != source_observation.identity.acquisition.timebase: + raise ValueError("DJ event timebase does not match source identity") + return source_observation + + +def _unique_by_id[T](values: Iterable[T], key_function: Callable[[T], object]) -> tuple[T, ...]: + result: dict[str, T] = {} + for value in values: + key_value = key_function(value) + key = key_value.qualified if isinstance(key_value, InstanceIdentifier) else str(key_value) + prior = result.get(key) + if prior is not None and prior != value: + raise ValueError(f"DJ lineage ID {key!r} has conflicting content") + result[key] = value + return tuple(result.values()) + + +def _merge_provenance(provenances: Iterable[Provenance]) -> Provenance: + items = tuple(provenances) + if not items: + raise ValueError("DJ derivation requires provenance") + artifacts = _unique_by_id( + (artifact for item in items for artifact in item.source_artifacts), lambda x: x.artifact_id + ) + acquisitions = _unique_by_id( + (acquisition for item in items for acquisition in item.acquisitions), + lambda x: x.acquisition_id, + ) + runs = _unique_by_id( + (run for item in items for run in item.processing_runs), lambda x: x.processing_run_id + ) + edges = _unique_by_id( + (edge for item in items for edge in item.lineage_edges), + lambda x: (x.from_id, x.to_id, x.relation.value), + ) + evidence = tuple( + dict.fromkeys(evidence for item in items for evidence in item.evidence_references) + ) + traceability = tuple( + dict.fromkeys(reference for item in items for reference in item.metrological_traceability) + ) + recorded_at = next((item.recorded_at for item in items if item.recorded_at is not None), None) + return Provenance( + provenance_id=items[0].provenance_id, + source_artifacts=tuple(sorted(artifacts, key=lambda x: x.artifact_id.qualified)), + acquisitions=tuple(sorted(acquisitions, key=lambda x: x.acquisition_id.qualified)), + processing_runs=tuple(sorted(runs, key=lambda x: x.processing_run_id.qualified)), + lineage_edges=tuple(sorted(edges, key=lambda x: (x.from_id, x.to_id, x.relation.value))), + evidence_references=evidence, + metrological_traceability=traceability, + recorded_at=recorded_at, + ) + + +def _output_identity( + source_identity: DropJumpMeasurementIdentity, + *, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + unit: UnitReference, + event_definitions: tuple[RegistryReference, ...], + parameters: tuple[MetadataEntry, ...], +) -> DropJumpMeasurementIdentity: + acquisition = source_identity.acquisition + processing = source_identity.processing + return DropJumpMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"drop-jump-{operation.identifier.key}-{canonical_hash(parameters).removeprefix('sha256:')[:24]}", + DROP_JUMP_REGISTRY_VERSION, + ), + semantic=DropJumpSemanticIdentity( + construct=source_identity.semantic.construct, + test_family=source_identity.semantic.test_family, + protocol=source_identity.semantic.protocol, + measurand=measurand, + metric_definition=metric, + protocol_identity=source_identity.semantic.protocol_identity, + ), + acquisition=DropJumpAcquisitionIdentity( + device=acquisition.device, + raw_artifact=acquisition.raw_artifact, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + acquisition_instance_id=acquisition.acquisition_instance_id, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + source_series_digest=acquisition.source_series_digest, + ), + processing=DropJumpProcessingIdentity( + event_definitions=event_definitions, + estimator=operation, + registered_operation=operation, + method_parameters=parameters, + filtering=processing.filtering, + differentiation_method=processing.differentiation_method, + integration_method=processing.integration_method, + unit=unit, + sign_convention=processing.sign_convention, + normalization=processing.normalization, + trial_selection=processing.trial_selection, + aggregation=processing.aggregation, + timebase=acquisition.timebase, + processing_state=DropJumpProcessingState.DYNAMISLM_PROCESSED, + ), + version=VersionIdentity( + processing_method=operation, + method_registry_version=DROP_JUMP_REGISTRY_VERSION, + software_version=DROP_JUMP_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + + +def _build_metric( + *, + source_identity: DropJumpMeasurementIdentity, + context: ObservationContext, + source_events: tuple[DropJumpEventOccurrence, ...], + source_observations: tuple[ScientificMeasurementObservation, ...], + value: float, + unit: UnitReference, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + parameters: tuple[MetadataEntry, ...], + value_origin: ValueOrigin, +) -> DropJumpMetricResult: + _require_instance(context, ObservationContext, "context") + numeric = _finite(value, "DJ output value") + observation_ids = tuple(item.observation_id for item in source_observations) + if len(set(observation_ids)) != len(observation_ids): + raise ValueError("DJ source observations must be unique") + digest = canonical_hash( + { + "operation": operation, + "metric": metric, + "value": numeric, + "parameters": parameters, + "event_ids": tuple(item.occurrence_id for item in source_events), + "observation_ids": observation_ids, + } + ).removeprefix("sha256:")[:24] + output_id = InstanceIdentifier("observation", f"drop-jump:{operation.identifier.key}:{digest}") + identity = _output_identity( + source_identity, + metric=metric, + measurand=measurand, + operation=operation, + unit=unit, + event_definitions=tuple(item.definition.reference for item in source_events), + parameters=parameters, + ) + base = _merge_provenance( + ( + *tuple(item.provenance for item in source_events), + *tuple(item.provenance for item in source_observations), + ) + ) + output_artifact = SourceArtifact( + artifact_id=InstanceIdentifier("artifact", f"drop-jump-result:{digest}"), + content_digest=canonical_hash( + {"identity": identity, "value": numeric, "unit": unit, "operation": operation} + ), + media_type="application/vnd.dynamislm.drop-jump.scalar-metric", + immutable=True, + ) + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"drop-jump:{operation.identifier.key}:{digest}" + ), + source_artifact_ids=tuple( + sorted((item.artifact_id for item in base.source_artifacts), key=lambda x: x.qualified) + ), + method=operation, + parameters=parameters, + software_version=DROP_JUMP_SOFTWARE_VERSION, + output_entity_id=output_id, + ) + artifacts = _unique_by_id((*base.source_artifacts, output_artifact), lambda x: x.artifact_id) + runs = _unique_by_id((*base.processing_runs, run), lambda x: x.processing_run_id) + edges = list(base.lineage_edges) + source_entities = tuple(item.occurrence_id for item in source_events) + observation_ids + for source_id in (*source_entities, *run.source_artifact_ids): + edge = LineageEdge( + source_id.qualified, run.processing_run_id.qualified, LineageRelation.DERIVED_FROM + ) + if edge not in edges: + edges.append(edge) + for acquisition in base.acquisitions: + edge = LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ) + if edge not in edges: + edges.append(edge) + evidence = EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY) + support_edge = LineageEdge( + evidence.reference.stable_id, + run.processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ) + if support_edge not in edges: + edges.append(support_edge) + artifact_edge = LineageEdge( + run.processing_run_id.qualified, + output_artifact.artifact_id.qualified, + LineageRelation.PRODUCED, + ) + if artifact_edge not in edges: + edges.append(artifact_edge) + output_edge = LineageEdge( + run.processing_run_id.qualified, output_id.qualified, LineageRelation.PRODUCED + ) + if output_edge not in edges: + edges.append(output_edge) + provenance = Provenance( + provenance_id=InstanceIdentifier("provenance", output_id.value), + source_artifacts=tuple(sorted(artifacts, key=lambda x: x.artifact_id.qualified)), + acquisitions=tuple(sorted(base.acquisitions, key=lambda x: x.acquisition_id.qualified)), + processing_runs=tuple(sorted(runs, key=lambda x: x.processing_run_id.qualified)), + lineage_edges=tuple(sorted(edges, key=lambda x: (x.from_id, x.to_id, x.relation.value))), + evidence_references=tuple(dict.fromkeys((*base.evidence_references, evidence))), + metrological_traceability=base.metrological_traceability, + recorded_at=base.recorded_at or context.observed_at, + ) + observation = ScientificMeasurementObservation( + observation_id=output_id, + context=context, + identity=identity, + result=MeasurementResult( + result_id=InstanceIdentifier( + "result", f"drop-jump:{operation.identifier.key}:{digest}" + ), + value=ScalarValue(numeric), + unit=unit, + classification=ScientificClassification( + value_origin, (ScientificRole.PERFORMANCE_OUTCOME,) + ), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata( + status=UncertaintyStatus.NOT_ASSESSED, + description="RES-68 deterministic output; measurement uncertainty is not assessed.", + ), + status=ResultStatus.VALID, + ), + provenance=provenance, + ) + return DropJumpMetricResult( + observation=observation, + metric=metric, + source_events=source_events, + source_observations=source_observations, + ) + + +def contact_time_seconds( + touchdown: DropJumpEventOccurrence, + rebound_takeoff: DropJumpEventOccurrence, +) -> float: + """Pure deterministic DJ contact-time arithmetic.""" + + _validate_pair( + touchdown, + rebound_takeoff, + DropJumpEventLabel.TOUCHDOWN, + DropJumpEventLabel.REBOUND_TAKEOFF, + ) + value = rebound_takeoff.event_time_s - touchdown.event_time_s + if not math.isfinite(value) or value <= 0: + raise ValueError("DJ contact time must be finite and positive") + return value + + +def rebound_flight_time_seconds( + rebound_takeoff: DropJumpEventOccurrence, + subsequent_landing: DropJumpEventOccurrence, +) -> float: + """Pure deterministic DJ rebound-flight-time arithmetic.""" + + _validate_pair( + rebound_takeoff, + subsequent_landing, + DropJumpEventLabel.REBOUND_TAKEOFF, + DropJumpEventLabel.SUBSEQUENT_LANDING, + ) + value = subsequent_landing.event_time_s - rebound_takeoff.event_time_s + if not math.isfinite(value) or value <= 0: + raise ValueError("DJ flight time must be finite and positive") + return value + + +def calculate_drop_jump_contact_time( + touchdown: DropJumpEventOccurrence, + rebound_takeoff: DropJumpEventOccurrence, + source_observation: ScientificMeasurementObservation | None = None, + qualification: DropJumpSourceQualificationEvidence | None = None, +) -> DropJumpMetricResult | RefusalResult: + """Return a qualified DJ ground-contact-time result or a structured refusal.""" + + claim = "calculate DJ rebound ground-contact time" + try: + _validate_pair( + touchdown, + rebound_takeoff, + DropJumpEventLabel.TOUCHDOWN, + DropJumpEventLabel.REBOUND_TAKEOFF, + ) + source = _validate_source(source_observation, (touchdown, rebound_takeoff), qualification) + value = contact_time_seconds(touchdown, rebound_takeoff) + parameters = ( + MetadataEntry("touchdown_event_id", touchdown.occurrence_id.qualified), + MetadataEntry("rebound_takeoff_event_id", rebound_takeoff.occurrence_id.qualified), + MetadataEntry("contact_time_s", value), + MetadataEntry("source_series_digest", touchdown.source_series_digest), + ) + source_observations: tuple[ScientificMeasurementObservation, ...] = (source,) + if qualification is not None: + source_observations += (qualification.qualification_observation,) + return _build_metric( + source_identity=touchdown.source_measurement_identity, + context=source.context, + source_events=(touchdown, rebound_takeoff), + source_observations=source_observations, + value=value, + unit=SECOND, + metric=DJ_GROUND_CONTACT_TIME_METRIC, + measurand=DJ_GROUND_CONTACT_TIME_MEASURAND, + operation=DJ_GROUND_CONTACT_TIME_OPERATION, + parameters=parameters, + value_origin=ValueOrigin.DYNAMISLM_DERIVED, + ) + except (AttributeError, IndexError, TypeError, ValueError): + ids = tuple( + item.source_observation_id + for item in (touchdown, rebound_takeoff) + if isinstance(item, DropJumpEventOccurrence) + ) + return _refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("valid same-trial DJ touchdown and rebound-takeoff evidence",), + ids, + refusal_class=RefusalClass.IDENTITY_UNRESOLVED, + safe_descriptions=("any independently valid DJ event occurrences",), + ) + + +def calculate_drop_jump_rebound_flight_time( + rebound_takeoff: DropJumpEventOccurrence, + subsequent_landing: DropJumpEventOccurrence, + source_observation: ScientificMeasurementObservation | None = None, + qualification: DropJumpSourceQualificationEvidence | None = None, +) -> DropJumpMetricResult | RefusalResult: + """Return a qualified DJ rebound-flight-time result or a refusal.""" + + claim = "calculate DJ rebound flight time" + try: + _validate_pair( + rebound_takeoff, + subsequent_landing, + DropJumpEventLabel.REBOUND_TAKEOFF, + DropJumpEventLabel.SUBSEQUENT_LANDING, + ) + source = _validate_source( + source_observation, (rebound_takeoff, subsequent_landing), qualification + ) + value = rebound_flight_time_seconds(rebound_takeoff, subsequent_landing) + parameters = ( + MetadataEntry("rebound_takeoff_event_id", rebound_takeoff.occurrence_id.qualified), + MetadataEntry( + "subsequent_landing_event_id", subsequent_landing.occurrence_id.qualified + ), + MetadataEntry("flight_time_s", value), + MetadataEntry("source_series_digest", rebound_takeoff.source_series_digest), + ) + source_observations: tuple[ScientificMeasurementObservation, ...] = (source,) + if qualification is not None: + source_observations += (qualification.qualification_observation,) + return _build_metric( + source_identity=rebound_takeoff.source_measurement_identity, + context=source.context, + source_events=(rebound_takeoff, subsequent_landing), + source_observations=source_observations, + value=value, + unit=SECOND, + metric=DJ_REBOUND_FLIGHT_TIME_METRIC, + measurand=DJ_REBOUND_FLIGHT_TIME_MEASURAND, + operation=DJ_REBOUND_FLIGHT_TIME_OPERATION, + parameters=parameters, + value_origin=ValueOrigin.DYNAMISLM_DERIVED, + ) + except (AttributeError, IndexError, TypeError, ValueError): + ids = tuple( + item.source_observation_id + for item in (rebound_takeoff, subsequent_landing) + if isinstance(item, DropJumpEventOccurrence) + ) + return _refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("valid same-trial DJ rebound-takeoff and subsequent-landing evidence",), + ids, + safe_descriptions=("any independently valid DJ event occurrences",), + ) + + +def flight_time_jump_height_metres( + flight_time_s: float, + gravity: object, +) -> float: + """Pure DJ-specific flight-time ballistic height equation.""" + + duration = _finite(flight_time_s, "flight_time_s") + if duration <= 0: + raise ValueError("flight_time_s must be positive") + if not isinstance(gravity, _GRAVITY_REFERENCE_TYPE): + raise ValueError("an explicit qualified GravityReference is required") + gravity_reference = cast(_GravityReferenceLike, gravity) + return gravity_reference.value_m_per_s2 * duration**2 / 8.0 + + +def calculate_drop_jump_flight_time_jump_height( + rebound_takeoff: DropJumpEventOccurrence, + subsequent_landing: DropJumpEventOccurrence, + gravity: object | None, + source_observation: ScientificMeasurementObservation | None = None, + applicability: DropJumpFlightTimeApplicability | None = None, + qualification: DropJumpSourceQualificationEvidence | None = None, +) -> DropJumpMetricResult | RefusalResult: + """Estimate DJ takeoff-to-apex rise from exact rebound flight time.""" + + claim = "estimate DJ flight-time ballistic jump height" + try: + _validate_pair( + rebound_takeoff, + subsequent_landing, + DropJumpEventLabel.REBOUND_TAKEOFF, + DropJumpEventLabel.SUBSEQUENT_LANDING, + ) + source = _validate_source( + source_observation, (rebound_takeoff, subsequent_landing), qualification + ) + if not isinstance(gravity, _GRAVITY_REFERENCE_TYPE): + return _refusal( + claim, + (RefusalReasonCode.GRAVITY_REFERENCE_INVALID,), + ("qualified GravityReference with an explicit gravity source and unit",), + (source.observation_id,), + refusal_class=RefusalClass.ANALYSIS_DESIGN_MISMATCH, + safe_descriptions=("valid DJ rebound events and flight time",), + ) + if not isinstance(applicability, DropJumpFlightTimeApplicability): + return _refusal( + claim, + (RefusalReasonCode.BALLISTIC_ASSUMPTION_UNSUPPORTED,), + ("explicit DJ flight-time ballistic/posture applicability",), + (source.observation_id,), + refusal_class=RefusalClass.ANALYSIS_DESIGN_MISMATCH, + safe_descriptions=("valid DJ rebound events and flight time",), + ) + if not applicability.matches( + source.observation_id, + rebound_takeoff.occurrence_id, + subsequent_landing.occurrence_id, + ): + return _refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("applicability bound to this source observation and these exact events",), + (source.observation_id,), + safe_descriptions=("valid DJ rebound events and flight time",), + ) + if not applicability.is_authorized: + return _refusal( + claim, + (RefusalReasonCode.BALLISTIC_ASSUMPTION_UNSUPPORTED,), + ("authorized ballistic, posture-equivalence and air-resistance assumptions",), + (source.observation_id,), + refusal_class=RefusalClass.ANALYSIS_DESIGN_MISMATCH, + safe_descriptions=("valid DJ rebound events and flight time",), + ) + flight_time = rebound_flight_time_seconds(rebound_takeoff, subsequent_landing) + value = flight_time_jump_height_metres(flight_time, gravity) + parameters = ( + MetadataEntry("rebound_takeoff_event_id", rebound_takeoff.occurrence_id.qualified), + MetadataEntry( + "subsequent_landing_event_id", subsequent_landing.occurrence_id.qualified + ), + MetadataEntry("flight_time_s", flight_time), + MetadataEntry("gravity_reference", canonical_json(gravity)), + MetadataEntry("applicability", canonical_json(applicability)), + MetadataEntry( + "estimator_definition", + "takeoff-to-apex ballistic rise estimated as g * flight_time_s^2 / 8", + ), + ) + source_observations: tuple[ScientificMeasurementObservation, ...] = (source,) + if qualification is not None: + source_observations += (qualification.qualification_observation,) + return _build_metric( + source_identity=rebound_takeoff.source_measurement_identity, + context=source.context, + source_events=(rebound_takeoff, subsequent_landing), + source_observations=source_observations, + value=value, + unit=METER, + metric=DJ_FLIGHT_TIME_JUMP_HEIGHT_METRIC, + measurand=DJ_FLIGHT_TIME_JUMP_HEIGHT_MEASURAND, + operation=DJ_FLIGHT_TIME_JUMP_HEIGHT_OPERATION, + parameters=parameters, + value_origin=ValueOrigin.MODEL_ESTIMATE, + ) + except (AttributeError, IndexError, TypeError, ValueError): + ids = tuple( + item.source_observation_id + for item in (rebound_takeoff, subsequent_landing) + if isinstance(item, DropJumpEventOccurrence) + ) + return _refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("valid DJ source, events, gravity and applicability evidence",), + ids, + refusal_class=RefusalClass.IDENTITY_UNRESOLVED, + safe_descriptions=("valid DJ events and any independently valid durations",), + ) + + +def _same_event_source( + left: DropJumpMetricResult, + right: DropJumpMetricResult, +) -> None: + left_events = left.source_events + right_events = right.source_events + if not left_events or not right_events: + raise ValueError("DJ metric results must preserve source events") + if any( + event.source_observation_id != left_events[0].source_observation_id + or event.source_signal_id != left_events[0].source_signal_id + or event.source_artifact_id != left_events[0].source_artifact_id + or event.source_acquisition_id != left_events[0].source_acquisition_id + or event.source_measurement_identity != left_events[0].source_measurement_identity + for event in (*left_events, *right_events) + ): + raise ValueError("DJ ratio inputs must use exact same-trial event evidence") + + +def _ratio_result( + *, + numerator: DropJumpMetricResult, + denominator: DropJumpMetricResult, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + value: float, + unit: UnitReference, + claim: str, + expected_numerator_metric: RegistryReference, + expected_denominator_metric: RegistryReference, +) -> DropJumpMetricResult | RefusalResult: + try: + if not isinstance(numerator, DropJumpMetricResult) or not isinstance( + denominator, DropJumpMetricResult + ): + raise ValueError("typed DJ metric results are required") + if ( + numerator.metric != expected_numerator_metric + or denominator.metric != expected_denominator_metric + ): + raise ValueError("DJ ratio operands have the wrong metric identities") + _same_event_source(numerator, denominator) + denominator_value = denominator.value + if denominator_value <= 0 or not math.isfinite(denominator_value): + raise ValueError("DJ ratio denominator must be finite and positive") + result_value = _finite(value, "DJ ratio") + source_observations = tuple( + dict.fromkeys( + (*numerator.source_observations, *denominator.source_observations), + ) + ) + source_identity = numerator.source_events[0].source_measurement_identity + context = source_observations[0].context + parameters = ( + MetadataEntry( + "numerator_observation_id", numerator.observation.observation_id.qualified + ), + MetadataEntry( + "denominator_observation_id", denominator.observation.observation_id.qualified + ), + MetadataEntry("numerator_metric", numerator.metric.stable_id), + MetadataEntry("denominator_metric", denominator.metric.stable_id), + MetadataEntry("ratio_value", result_value), + ) + return _build_metric( + source_identity=source_identity, + context=context, + source_events=tuple( + dict.fromkeys((*numerator.source_events, *denominator.source_events)) + ), + source_observations=source_observations, + value=result_value, + unit=unit, + metric=metric, + measurand=measurand, + operation=operation, + parameters=parameters, + value_origin=ValueOrigin.DYNAMISLM_DERIVED, + ) + except (AttributeError, IndexError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.METRIC_DEFINITION_MISMATCH,), + ("same-trial qualified DJ numerator and positive denominator",), + (), + safe_descriptions=("the independently valid DJ operand results",), + ) + + +def calculate_drop_jump_rsi_jh_ct( + jump_height: DropJumpMetricResult, + contact_time: DropJumpMetricResult, +) -> DropJumpMetricResult | RefusalResult: + """Calculate DJ RSI using jump height divided by ground-contact time.""" + + claim = "calculate DJ RSI JH/CT" + try: + height = jump_height.value_m + contact = contact_time.value_s + if contact <= 0: + raise ValueError("contact time denominator must be positive") + return _ratio_result( + numerator=jump_height, + denominator=contact_time, + metric=DJ_RSI_JH_CT_METRIC, + measurand=DJ_RSI_JH_CT_MEASURAND, + operation=DJ_RSI_JH_CT_OPERATION, + value=height / contact, + unit=METERS_PER_SECOND, + claim=claim, + expected_numerator_metric=DJ_FLIGHT_TIME_JUMP_HEIGHT_METRIC, + expected_denominator_metric=DJ_GROUND_CONTACT_TIME_METRIC, + ) + except (AttributeError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.METRIC_DEFINITION_MISMATCH,), + ("valid DJ flight-time jump height and positive contact time",), + (), + safe_descriptions=("valid DJ jump-height and contact-time results",), + ) + + +def calculate_drop_jump_rsr_ft_ct( + flight_time: DropJumpMetricResult, + contact_time: DropJumpMetricResult, +) -> DropJumpMetricResult | RefusalResult: + """Calculate DJ RSR using rebound flight time divided by contact time.""" + + claim = "calculate DJ RSR FT/CT" + try: + flight = flight_time.value_s + contact = contact_time.value_s + if contact <= 0: + raise ValueError("contact time denominator must be positive") + return _ratio_result( + numerator=flight_time, + denominator=contact_time, + metric=DJ_RSR_FT_CT_METRIC, + measurand=DJ_RSR_FT_CT_MEASURAND, + operation=DJ_RSR_FT_CT_OPERATION, + value=flight / contact, + unit=DIMENSIONLESS, + claim=claim, + expected_numerator_metric=DJ_REBOUND_FLIGHT_TIME_METRIC, + expected_denominator_metric=DJ_GROUND_CONTACT_TIME_METRIC, + ) + except (AttributeError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.METRIC_DEFINITION_MISMATCH,), + ("valid DJ rebound flight time and positive contact time",), + (), + safe_descriptions=("valid DJ flight-time and contact-time results",), + ) + + +# Public aliases use both expanded and DJ-prefixed spellings without merging +# any metric identity. +estimate_drop_jump_flight_time_jump_height = calculate_drop_jump_flight_time_jump_height +calculate_dj_ground_contact_time = calculate_drop_jump_contact_time +calculate_dj_rebound_flight_time = calculate_drop_jump_rebound_flight_time +calculate_dj_flight_time_jump_height = calculate_drop_jump_flight_time_jump_height +calculate_dj_rsi_jh_ct = calculate_drop_jump_rsi_jh_ct +calculate_dj_rsr_ft_ct = calculate_drop_jump_rsr_ft_ct + + +__all__ = [ + "DropJumpMetricResult", + "calculate_dj_flight_time_jump_height", + "calculate_dj_ground_contact_time", + "calculate_dj_rebound_flight_time", + "calculate_dj_rsi_jh_ct", + "calculate_dj_rsr_ft_ct", + "calculate_drop_jump_contact_time", + "calculate_drop_jump_flight_time_jump_height", + "calculate_drop_jump_rebound_flight_time", + "calculate_drop_jump_rsi_jh_ct", + "calculate_drop_jump_rsr_ft_ct", + "contact_time_seconds", + "estimate_drop_jump_flight_time_jump_height", + "flight_time_jump_height_metres", + "rebound_flight_time_seconds", +] diff --git a/src/dynamislm/measurement/drop_jump/qualification.py b/src/dynamislm/measurement/drop_jump/qualification.py new file mode 100644 index 0000000..3dbea59 --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/qualification.py @@ -0,0 +1,426 @@ +"""Source/provider qualification boundary for drop-jump observations.""" + +from __future__ import annotations + +import datetime as datetime_module +from dataclasses import dataclass + +from dynamislm.measurement.drop_jump.identity import ( + DropJumpAcquisitionIdentity, + DropJumpAcquisitionRecord, + DropJumpArtifactStatus, + DropJumpMeasurementIdentity, + DropJumpProcessingIdentity, + DropJumpProcessingState, + DropJumpQualificationStatus, + DropJumpSemanticIdentity, + DropJumpSourceArtifact, +) +from dynamislm.measurement.drop_jump.registry import ( + DROP_JUMP_QUALIFICATION_MEASURAND, + DROP_JUMP_QUALIFICATION_METRIC, + DROP_JUMP_REGISTRY_VERSION, + DROP_JUMP_SOFTWARE_VERSION, + DROP_JUMP_SOURCE_OBSERVATION_OPERATION, + DROP_JUMP_SOURCE_QUALIFICATION_OPERATION, + DROP_JUMP_TEST_FAMILY, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + VersionIdentity, + _require_enum, + _require_instance, + _require_tuple_items, +) +from dynamislm.measurement.observation import ( + ObservationContext, + ScientificMeasurementObservation, + create_derived_observation, +) +from dynamislm.measurement.result import ( + CategoricalValue, + MeasurementQuality, + MeasurementResult, + ResultStatus, + UncertaintyMetadata, + UncertaintyStatus, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ValueOrigin +from dynamislm.provenance.models import EvidenceReference, ProcessingRun +from dynamislm.serialization import canonical_json, register_serializable_type + + +def _source_processing_state(origin: ValueOrigin) -> DropJumpProcessingState: + if origin is ValueOrigin.SOURCE_REPORTED: + return DropJumpProcessingState.RAW_ACQUIRED + if origin is ValueOrigin.PROVIDER_DERIVED: + return DropJumpProcessingState.PROVIDER_PROCESSED + raise ValueError("drop-jump source origin must be SOURCE_REPORTED or PROVIDER_DERIVED") + + +def _require_verified_artifact(artifact: DropJumpSourceArtifact) -> None: + if not isinstance(artifact, DropJumpSourceArtifact): + raise ValueError("drop-jump source artifact must use its typed artifact contract") + if not artifact.immutable or artifact.status is not DropJumpArtifactStatus.VERIFIED: + raise ValueError("drop-jump source artifact must be immutable and verified") + + +def _validate_acquisition_binding( + identity: DropJumpMeasurementIdentity, + artifact: DropJumpSourceArtifact, + acquisition: DropJumpAcquisitionRecord, +) -> None: + if not isinstance(identity.acquisition, DropJumpAcquisitionIdentity): + raise ValueError("drop-jump identity requires typed acquisition identity") + if identity.acquisition.raw_artifact != artifact.artifact_id: + raise ValueError("drop-jump identity raw artifact does not match source artifact") + if identity.acquisition.acquisition_instance_id != acquisition.acquisition_id: + raise ValueError("drop-jump identity acquisition does not match acquisition record") + if acquisition.source_artifact_id != artifact.artifact_id: + raise ValueError("drop-jump acquisition does not reference source artifact") + expected = identity.acquisition + if ( + expected.device != acquisition.device + or expected.sensor_channel != acquisition.sensor_channel + or expected.sampling != acquisition.sampling + or expected.calibration_reference != acquisition.calibration_reference + or expected.hardware_firmware != acquisition.hardware_firmware + or expected.provider != acquisition.provider + or expected.sensor_modality != acquisition.sensor_modality + or expected.timebase != acquisition.timebase + or expected.axis_or_frame != acquisition.axis_or_frame + ): + raise ValueError( + "drop-jump identity and acquisition record do not describe the same system" + ) + + +def build_drop_jump_source_observation( + *, + observation_id: InstanceIdentifier, + context: ObservationContext, + identity: DropJumpMeasurementIdentity, + result: MeasurementResult, + source_artifact: DropJumpSourceArtifact, + acquisition: DropJumpAcquisitionRecord, + processing_run: ProcessingRun | None = None, + evidence_references: tuple[EvidenceReference, ...] = (), + recorded_at: datetime_module.datetime | None = None, +) -> ScientificMeasurementObservation: + """Create a verified DJ source/provider observation with immutable lineage.""" + + _require_verified_artifact(source_artifact) + _require_instance(context, ObservationContext, "context") + _require_instance(identity, DropJumpMeasurementIdentity, "identity") + _require_instance(result, MeasurementResult, "result") + _require_instance(acquisition, DropJumpAcquisitionRecord, "acquisition") + if observation_id.instance_type != "observation": + raise ValueError("observation_id must identify an observation") + _validate_acquisition_binding(identity, source_artifact, acquisition) + origin = result.classification.value_origin + expected_state = _source_processing_state(origin) + if identity.processing.processing_state is not expected_state: + raise ValueError("DJ source processing state does not match result value origin") + if identity.processing.registered_operation not in { + DROP_JUMP_SOURCE_OBSERVATION_OPERATION, + DROP_JUMP_SOURCE_QUALIFICATION_OPERATION, + }: + raise ValueError("DJ source identity must use the registered source operation") + if processing_run is None: + processing_run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"drop-jump-source:{observation_id.value}" + ), + source_artifact_ids=(source_artifact.artifact_id,), + method=identity.version.processing_method, + parameters=identity.processing.method_parameters, + software_version=identity.version.software_version, + output_entity_id=observation_id, + ) + if processing_run.output_entity_id != observation_id: + raise ValueError("DJ source processing output must match observation ID") + if processing_run.method != identity.version.processing_method: + raise ValueError("DJ source processing method does not match identity") + if processing_run.parameters != identity.processing.method_parameters: + raise ValueError("DJ source processing parameters do not match identity") + if processing_run.software_version != identity.version.software_version: + raise ValueError("DJ source software version does not match identity") + if source_artifact.artifact_id not in processing_run.source_artifact_ids: + raise ValueError("DJ source processing must reference source artifact") + return create_derived_observation( + observation_id=observation_id, + context=context, + identity=identity, + result=result, + source_artifact=source_artifact, + acquisition=acquisition, + processing_run=processing_run, + evidence_references=( + EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), + *evidence_references, + ), + recorded_at=recorded_at, + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpSourceQualificationEvidence: + """Pre-existing source/provider qualification bound to one DJ observation.""" + + target_observation_id: InstanceIdentifier + qualification_observation: ScientificMeasurementObservation + qualification_reference: RegistryReference + + def __post_init__(self) -> None: + if self.target_observation_id.instance_type != "observation": + raise ValueError("target_observation_id must identify an observation") + _require_instance( + self.qualification_observation, + ScientificMeasurementObservation, + "qualification_observation", + ) + _require_instance( + self.qualification_reference, RegistryReference, "qualification_reference" + ) + if self.qualification_reference != DROP_JUMP_SOURCE_QUALIFICATION_OPERATION: + raise ValueError("qualification reference is not the registered DJ source rule") + value = self.qualification_observation.result.value + if not isinstance(value, CategoricalValue): + raise ValueError("DJ qualification must carry a categorical source result") + try: + DropJumpQualificationStatus(value.category) + except ValueError as exc: + raise ValueError("DJ qualification category is not registered") from exc + if self.qualification_observation.result.classification.value_origin not in { + ValueOrigin.SOURCE_REPORTED, + ValueOrigin.PROVIDER_DERIVED, + }: + raise ValueError("DJ qualification must remain source/provider-origin") + if self.qualification_observation.result.status is not ResultStatus.VALID: + raise ValueError("DJ qualification observation must be valid") + if any( + not isinstance(artifact, DropJumpSourceArtifact) + or not artifact.immutable + or artifact.status is not DropJumpArtifactStatus.VERIFIED + for artifact in self.qualification_observation.provenance.source_artifacts + ): + raise ValueError("DJ qualification must preserve verified source artifacts") + if any( + not isinstance(acquisition, DropJumpAcquisitionRecord) + for acquisition in self.qualification_observation.provenance.acquisitions + ): + raise ValueError("DJ qualification must preserve typed acquisitions") + identity = self.qualification_observation.identity + if not isinstance(identity, DropJumpMeasurementIdentity): + raise ValueError("DJ qualification requires a DJ measurement identity") + if identity.semantic.measurand != DROP_JUMP_QUALIFICATION_MEASURAND: + raise ValueError("DJ qualification has the wrong measurand") + if identity.semantic.metric_definition != DROP_JUMP_QUALIFICATION_METRIC: + raise ValueError("DJ qualification has the wrong metric") + if identity.processing.registered_operation != DROP_JUMP_SOURCE_QUALIFICATION_OPERATION: + raise ValueError("DJ qualification has the wrong operation") + parameters = {entry.key: entry.value for entry in identity.processing.method_parameters} + if parameters.get("target_observation_id") != self.target_observation_id.qualified: + raise ValueError("DJ qualification target is not preserved in identity") + if parameters.get("source_status") != self.status.value: + raise ValueError("DJ qualification status is not preserved in identity") + + @property + def status(self) -> DropJumpQualificationStatus: + value = self.qualification_observation.result.value + assert isinstance(value, CategoricalValue) + return DropJumpQualificationStatus(value.category) + + @property + def source_observation_id(self) -> InstanceIdentifier: + return self.qualification_observation.observation_id + + +def build_drop_jump_qualification_source_observation( + *, + target_observation: ScientificMeasurementObservation, + reported_status: DropJumpQualificationStatus, + source_artifact: DropJumpSourceArtifact, + acquisition: DropJumpAcquisitionRecord, + value_origin: ValueOrigin = ValueOrigin.SOURCE_REPORTED, + adjudication_rule: RegistryReference | None = None, + reason_codes: tuple[str, ...] = (), + output_observation_id: InstanceIdentifier | None = None, + recorded_at: datetime_module.datetime | None = None, +) -> ScientificMeasurementObservation: + """Ingest source/provider qualification; it does not itself normalize it.""" + + _require_instance(target_observation, ScientificMeasurementObservation, "target_observation") + _require_enum(reported_status, DropJumpQualificationStatus, "reported_status") + _require_enum(value_origin, ValueOrigin, "value_origin") + if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("DJ qualification ingestion requires source/provider origin") + _require_verified_artifact(source_artifact) + _require_instance(acquisition, DropJumpAcquisitionRecord, "acquisition") + target_identity = target_observation.identity + if not isinstance(target_identity, DropJumpMeasurementIdentity): + raise ValueError("target observation requires a DJ measurement identity") + observation_id = output_observation_id or InstanceIdentifier( + "observation", f"drop-jump-qualification:{target_observation.observation_id.value}" + ) + if observation_id == target_observation.observation_id: + raise ValueError("DJ qualification observation must differ from target") + _require_tuple_items(reason_codes, str, "reason_codes") + parameters = ( + MetadataEntry("target_observation_id", target_observation.observation_id.qualified), + MetadataEntry("source_status", reported_status.value), + MetadataEntry("reason_codes", canonical_json(reason_codes)), + *( + (MetadataEntry("adjudication_rule", adjudication_rule.stable_id),) + if adjudication_rule is not None + else () + ), + ) + if adjudication_rule is not None: + _require_instance(adjudication_rule, RegistryReference, "adjudication_rule") + identity = DropJumpMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"drop-jump-qualification:{observation_id.value}", + DROP_JUMP_REGISTRY_VERSION, + ), + semantic=DropJumpSemanticIdentity( + construct=target_identity.semantic.construct, + test_family=DROP_JUMP_TEST_FAMILY, + protocol=target_identity.semantic.protocol, + measurand=DROP_JUMP_QUALIFICATION_MEASURAND, + metric_definition=DROP_JUMP_QUALIFICATION_METRIC, + protocol_identity=target_identity.semantic.protocol_identity, + ), + acquisition=DropJumpAcquisitionIdentity( + device=acquisition.device, + raw_artifact=source_artifact.artifact_id, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + acquisition_instance_id=acquisition.acquisition_id, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + ), + processing=DropJumpProcessingIdentity( + registered_operation=DROP_JUMP_SOURCE_QUALIFICATION_OPERATION, + method_parameters=parameters, + processing_state=_source_processing_state(value_origin), + provider_algorithm=adjudication_rule, + ), + version=VersionIdentity( + processing_method=DROP_JUMP_SOURCE_QUALIFICATION_OPERATION, + method_registry_version=DROP_JUMP_REGISTRY_VERSION, + software_version=DROP_JUMP_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + result = MeasurementResult( + result_id=InstanceIdentifier("result", f"drop-jump-qualification:{observation_id.value}"), + value=CategoricalValue(reported_status.value), + unit=None, + classification=ScientificClassification(value_origin, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ) + return build_drop_jump_source_observation( + observation_id=observation_id, + context=target_observation.context, + identity=identity, + result=result, + source_artifact=source_artifact, + acquisition=acquisition, + recorded_at=recorded_at, + ) + + +def normalize_drop_jump_qualification( + source_observation: ScientificMeasurementObservation, + target_observation: ScientificMeasurementObservation, +) -> DropJumpSourceQualificationEvidence: + """Normalize and validate pre-existing DJ qualification evidence.""" + + _require_instance(source_observation, ScientificMeasurementObservation, "source_observation") + _require_instance(target_observation, ScientificMeasurementObservation, "target_observation") + if source_observation.observation_id == target_observation.observation_id: + raise ValueError("DJ qualification observation must differ from target") + source_identity = source_observation.identity + target_identity = target_observation.identity + if not isinstance(source_identity, DropJumpMeasurementIdentity): + raise ValueError("DJ qualification source requires DJ identity") + if not isinstance(target_identity, DropJumpMeasurementIdentity): + raise ValueError("DJ target requires DJ identity") + if ( + source_identity.semantic.construct != target_identity.semantic.construct + or source_identity.semantic.test_family != DROP_JUMP_TEST_FAMILY + or target_identity.semantic.test_family != DROP_JUMP_TEST_FAMILY + or source_identity.semantic.protocol != target_identity.semantic.protocol + or source_identity.semantic.protocol_identity != target_identity.semantic.protocol_identity + ): + raise ValueError("DJ qualification source is bound to a different scientific target") + if source_observation.context != target_observation.context: + raise ValueError("DJ qualification source context does not match target") + target_artifacts = { + item.artifact_id: item for item in target_observation.provenance.source_artifacts + } + source_artifacts = { + item.artifact_id: item for item in source_observation.provenance.source_artifacts + } + if not source_artifacts or not set(source_artifacts).issubset(target_artifacts): + raise ValueError("DJ qualification source is not bound to target artifacts") + if any(target_artifacts[key] != value for key, value in source_artifacts.items()): + raise ValueError("DJ qualification artifact content was tampered") + target_acquisitions = { + item.acquisition_id: item for item in target_observation.provenance.acquisitions + } + source_acquisitions = { + item.acquisition_id: item for item in source_observation.provenance.acquisitions + } + if not source_acquisitions or not set(source_acquisitions).issubset(target_acquisitions): + raise ValueError("DJ qualification source is not bound to target acquisitions") + if any(target_acquisitions[key] != value for key, value in source_acquisitions.items()): + raise ValueError("DJ qualification acquisition content was tampered") + evidence = DropJumpSourceQualificationEvidence( + target_observation_id=target_observation.observation_id, + qualification_observation=source_observation, + qualification_reference=DROP_JUMP_SOURCE_QUALIFICATION_OPERATION, + ) + if evidence.status is DropJumpQualificationStatus.UNKNOWN: + raise ValueError("DJ qualification source must report QUALIFIED or REJECTED") + return evidence + + +def require_qualified_drop_jump( + evidence: DropJumpSourceQualificationEvidence, + target_observation: ScientificMeasurementObservation, +) -> None: + """Reject caller-minted or rebound DJ qualification evidence.""" + + if not isinstance(evidence, DropJumpSourceQualificationEvidence): + raise ValueError("typed DJ source qualification evidence is required") + normalized = normalize_drop_jump_qualification( + evidence.qualification_observation, target_observation + ) + if normalized != evidence: + raise ValueError("DJ source qualification evidence was rebound or tampered") + if evidence.status is not DropJumpQualificationStatus.QUALIFIED: + raise ValueError("DJ source observation is not qualified") + + +__all__ = [ + "DropJumpSourceQualificationEvidence", + "build_drop_jump_qualification_source_observation", + "build_drop_jump_source_observation", + "normalize_drop_jump_qualification", + "require_qualified_drop_jump", +] diff --git a/src/dynamislm/measurement/drop_jump/registry.py b/src/dynamislm/measurement/drop_jump/registry.py new file mode 100644 index 0000000..e61eca2 --- /dev/null +++ b/src/dynamislm/measurement/drop_jump/registry.py @@ -0,0 +1,193 @@ +"""Explicit, family-scoped registry identities for RES-68 drop jumps.""" + +from __future__ import annotations + +from dynamislm.measurement.identity import RegistryReference, ScientificIdentifier, UnitReference + +DROP_JUMP_REGISTRY_VERSION = "1.0.0" +DROP_JUMP_SOFTWARE_VERSION = "dynamislm-res68-drop-jump-1.0.0" + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference( + identifier=ScientificIdentifier("dynamislm", object_type, key, DROP_JUMP_REGISTRY_VERSION), + display_label=label, + ) + + +def _unit(key: str, label: str) -> UnitReference: + return UnitReference( + identifier=ScientificIdentifier("dynamislm", "unit", key, DROP_JUMP_REGISTRY_VERSION), + display_label=label, + ) + + +RES68_DECISION_EXPLOSIVE_TEST_FAMILY = RegistryReference( + identifier=ScientificIdentifier( + "dynamislm", + "decision-record", + "res68-explosive-test-family-closure", + DROP_JUMP_REGISTRY_VERSION, + ), + display_label="RES-68 explosive-test family closure decision", + reference_ids=("docs/decisions/RES68-DR-001-explosive-test-family-closure.md",), +) + +DROP_JUMP_TEST_FAMILY = _reference("test-family", "drop-jump", "Drop jump testing") +DROP_JUMP_CONSTRUCT = _reference( + "construct", "drop-jump-reactive-performance", "Drop-jump reactive performance construct" +) +DROP_JUMP_PROTOCOL_V1 = _reference( + "protocol", "drop-jump-explicit-identity-v1", "Drop-jump explicit protocol identity V1" +) + +DROP_JUMP_SOURCE_OBSERVATION_OPERATION = _reference( + "processing-method", "drop-jump-source-observation-v1", "Drop-jump source observation V1" +) +DROP_JUMP_SOURCE_QUALIFICATION_OPERATION = _reference( + "source-adjudication-rule", + "drop-jump-source-qualification-v1", + "Drop-jump source qualification V1", +) +DROP_JUMP_EVENT_SCHEMA = _reference( + "schema", "drop-jump-event-occurrence-v1", "Drop-jump event occurrence V1" +) +DROP_JUMP_METRIC_SCHEMA = _reference( + "schema", "drop-jump-scalar-metric-v1", "Drop-jump scalar metric V1" +) +DROP_JUMP_QUALIFICATION_MEASURAND = _reference( + "measurand", "drop-jump-source-qualification", "Drop-jump source qualification status" +) +DROP_JUMP_QUALIFICATION_METRIC = _reference( + "metric", "drop-jump-source-qualification", "Drop-jump source qualification status" +) +DROP_JUMP_SOURCE_SERIES_MEASURAND = _reference( + "measurand", "drop-jump-source-series", "Drop-jump source signal series" +) +DROP_JUMP_SOURCE_SERIES_METRIC = _reference( + "metric", "drop-jump-source-series", "Drop-jump source signal series" +) + +DROP_JUMP_TOUCHDOWN_EVENT_DEFINITION = _reference( + "event-definition", "drop-jump-touchdown", "Drop-jump touchdown" +) +DROP_JUMP_REBOUND_TAKEOFF_EVENT_DEFINITION = _reference( + "event-definition", "drop-jump-rebound-takeoff", "Drop-jump rebound takeoff" +) +DROP_JUMP_SUBSEQUENT_LANDING_EVENT_DEFINITION = _reference( + "event-definition", "drop-jump-subsequent-landing", "Drop-jump subsequent landing" +) +DROP_JUMP_TOUCHDOWN_EVENT_METHOD = _reference( + "event-method", "drop-jump-touchdown-registered-v1", "Drop-jump touchdown detector V1" +) +DROP_JUMP_REBOUND_TAKEOFF_EVENT_METHOD = _reference( + "event-method", + "drop-jump-rebound-takeoff-registered-v1", + "Drop-jump rebound-takeoff detector V1", +) +DROP_JUMP_SUBSEQUENT_LANDING_EVENT_METHOD = _reference( + "event-method", + "drop-jump-subsequent-landing-registered-v1", + "Drop-jump subsequent-landing detector V1", +) +DROP_JUMP_REGISTERED_EVENT_METHODS = ( + DROP_JUMP_TOUCHDOWN_EVENT_METHOD, + DROP_JUMP_REBOUND_TAKEOFF_EVENT_METHOD, + DROP_JUMP_SUBSEQUENT_LANDING_EVENT_METHOD, +) + +DROP_JUMP_FLIGHT_TIME_ESTIMATOR = _reference( + "estimator", + "drop-jump-flight-time-ballistic-height-v1", + "Drop-jump flight-time ballistic height", +) +DROP_JUMP_BALLISTIC_MOTION_ASSUMPTION = _reference( + "assumption", "drop-jump-ballistic-vertical-motion-v1", "Drop-jump ballistic vertical motion" +) +DROP_JUMP_TAKEOFF_LANDING_HEIGHT_APPLICABILITY = _reference( + "assumption", + "drop-jump-takeoff-landing-height-applicability-v1", + "Drop-jump takeoff/landing height applicability", +) +DROP_JUMP_NEGLIGIBLE_AIR_RESISTANCE = _reference( + "assumption", "drop-jump-negligible-air-resistance-v1", "Drop-jump negligible air resistance" +) + +DJ_GROUND_CONTACT_TIME_MEASURAND = _reference( + "measurand", "drop-jump-ground-contact-time", "Drop-jump rebound ground-contact time" +) +DJ_REBOUND_FLIGHT_TIME_MEASURAND = _reference( + "measurand", "drop-jump-rebound-flight-time", "Drop-jump rebound flight time" +) +DJ_FLIGHT_TIME_JUMP_HEIGHT_MEASURAND = _reference( + "measurand", + "drop-jump-flight-time-takeoff-to-apex-rise", + "Drop-jump flight-time takeoff-to-apex ballistic rise", +) +DJ_RSI_JH_CT_MEASURAND = _reference( + "measurand", "drop-jump-rsi-jh-over-ct", "Drop-jump jump-height/contact-time ratio" +) +DJ_RSR_FT_CT_MEASURAND = _reference( + "measurand", "drop-jump-rsr-ft-over-ct", "Drop-jump flight-time/contact-time ratio" +) + +DJ_GROUND_CONTACT_TIME_METRIC = _reference( + "metric", "drop-jump-ground-contact-time", "Drop-jump ground-contact time" +) +DJ_REBOUND_FLIGHT_TIME_METRIC = _reference( + "metric", "drop-jump-rebound-flight-time", "Drop-jump rebound flight time" +) +DJ_FLIGHT_TIME_JUMP_HEIGHT_METRIC = _reference( + "metric", "drop-jump-flight-time-jump-height", "Drop-jump flight-time jump height" +) +DJ_RSI_JH_CT_METRIC = _reference("metric", "drop-jump-rsi-jh-ct", "Drop-jump RSI JH/CT") +DJ_RSR_FT_CT_METRIC = _reference("metric", "drop-jump-rsr-ft-ct", "Drop-jump RSR FT/CT") + +DJ_GROUND_CONTACT_TIME_OPERATION = _reference( + "registered-operation", + "drop-jump-ground-contact-time-v1", + "Drop-jump ground-contact time", +) +DJ_REBOUND_FLIGHT_TIME_OPERATION = _reference( + "registered-operation", + "drop-jump-rebound-flight-time-v1", + "Drop-jump rebound flight time", +) +DJ_FLIGHT_TIME_JUMP_HEIGHT_OPERATION = _reference( + "registered-operation", + "drop-jump-flight-time-jump-height-v1", + "Drop-jump flight-time jump-height estimate", +) +DJ_RSI_JH_CT_OPERATION = _reference( + "registered-operation", "drop-jump-rsi-jh-ct-v1", "Drop-jump RSI JH/CT" +) +DJ_RSR_FT_CT_OPERATION = _reference( + "registered-operation", "drop-jump-rsr-ft-ct-v1", "Drop-jump RSR FT/CT" +) + +DROP_JUMP_COMPARABILITY_RULE = _reference( + "comparability-rule", "drop-jump-identity-v1", "Drop-jump claim-relative comparability" +) +DROP_JUMP_DEVICE_BRIDGE_RULE = _reference( + "bridge-rule", "drop-jump-device-agreement-v1", "Drop-jump device agreement bridge" +) + +METER = _unit("meter", "m") +SECOND = _unit("second", "s") +METERS_PER_SECOND = _unit("meter-per-second", "m/s") +DIMENSIONLESS = _unit("dimensionless", "1") + +# Readable aliases retain one identity and do not create a second wire object. +DJ_CONTACT_TIME_OPERATION = DJ_GROUND_CONTACT_TIME_OPERATION +DJ_FLIGHT_TIME_OPERATION = DJ_REBOUND_FLIGHT_TIME_OPERATION +DJ_JUMP_HEIGHT_OPERATION = DJ_FLIGHT_TIME_JUMP_HEIGHT_OPERATION +DJ_RSI_OPERATION = DJ_RSI_JH_CT_OPERATION +DJ_RSR_OPERATION = DJ_RSR_FT_CT_OPERATION +DJ_GROUND_CONTACT_TIME = DJ_GROUND_CONTACT_TIME_OPERATION +DJ_REBOUND_FLIGHT_TIME = DJ_REBOUND_FLIGHT_TIME_OPERATION +DJ_FLIGHT_TIME_JUMP_HEIGHT = DJ_FLIGHT_TIME_JUMP_HEIGHT_OPERATION +DJ_RSI_JH_CT = DJ_RSI_JH_CT_OPERATION +DJ_RSR_FT_CT = DJ_RSR_FT_CT_OPERATION + + +__all__ = [name for name, value in globals().items() if name.isupper() and not name.startswith("_")] diff --git a/src/dynamislm/measurement/medicine_ball_throw/__init__.py b/src/dynamislm/measurement/medicine_ball_throw/__init__.py new file mode 100644 index 0000000..bc1f1fe --- /dev/null +++ b/src/dynamislm/measurement/medicine_ball_throw/__init__.py @@ -0,0 +1,20 @@ +"""RES-68 family-scoped medicine-ball throw scientific authority.""" + +from dynamislm.measurement.medicine_ball_throw import comparability as _comparability +from dynamislm.measurement.medicine_ball_throw import identity as _identity +from dynamislm.measurement.medicine_ball_throw import metrics as _metrics +from dynamislm.measurement.medicine_ball_throw import qualification as _qualification +from dynamislm.measurement.medicine_ball_throw import registry as _registry +from dynamislm.measurement.medicine_ball_throw.comparability import * # noqa: F403 +from dynamislm.measurement.medicine_ball_throw.identity import * # noqa: F403 +from dynamislm.measurement.medicine_ball_throw.metrics import * # noqa: F403 +from dynamislm.measurement.medicine_ball_throw.qualification import * # noqa: F403 +from dynamislm.measurement.medicine_ball_throw.registry import * # noqa: F403 + +__all__ = [ + *_registry.__all__, + *_identity.__all__, + *_qualification.__all__, + *_metrics.__all__, + *_comparability.__all__, +] diff --git a/src/dynamislm/measurement/medicine_ball_throw/comparability.py b/src/dynamislm/measurement/medicine_ball_throw/comparability.py new file mode 100644 index 0000000..7bc3a39 --- /dev/null +++ b/src/dynamislm/measurement/medicine_ball_throw/comparability.py @@ -0,0 +1,370 @@ +"""Claim-relative medicine-ball throw comparability.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from dynamislm.comparability.models import ( + ComparabilityDecisionSource, + ComparabilityReasonCode, + ComparabilityRequest, + ComparabilityResult, + ComparabilityState, +) +from dynamislm.measurement.identity import InstanceIdentifier, RegistryReference, _require_instance +from dynamislm.measurement.medicine_ball_throw.identity import ( + MedicineBallThrowAcquisitionIdentity, + MedicineBallThrowMeasurementIdentity, + MedicineBallThrowProcessingIdentity, + MedicineBallThrowProtocolIdentity, +) +from dynamislm.measurement.medicine_ball_throw.metrics import MedicineBallThrowMetricResult +from dynamislm.measurement.medicine_ball_throw.registry import ( + MBT_DISTANCE_COMPARABILITY_RULE, + MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC, + MBT_RELEASE_VELOCITY_COMPARABILITY_RULE, + MBT_THROW_DISTANCE_METRIC, + MEDICINE_BALL_THROW_TEST_FAMILY, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.serialization import canonical_hash, register_serializable_type + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MBTComparabilityRequest: + request_id: InstanceIdentifier + left_observation_id: InstanceIdentifier + right_observation_id: InstanceIdentifier + claim: str + + def __post_init__(self) -> None: + if self.request_id.instance_type != "comparability-request": + raise ValueError("MBT request ID must identify a comparability request") + if self.left_observation_id == self.right_observation_id: + raise ValueError("MBT comparison requires two observations") + if not self.claim.strip(): + raise ValueError("MBT claim must not be empty") + + +def _result( + request: ComparabilityRequest, + state: ComparabilityState, + reasons: tuple[ComparabilityReasonCode, ...], + *, + missing: tuple[str, ...] = (), + conditions: tuple[str, ...] = (), + rule: RegistryReference | None = None, +) -> ComparabilityResult: + if rule is None: + rule = MBT_DISTANCE_COMPARABILITY_RULE + _require_instance(rule, RegistryReference, "rule") + digest = canonical_hash( + {"request": request, "state": state, "reasons": reasons, "missing": missing} + ).removeprefix("sha256:")[:24] + return ComparabilityResult( + result_id=InstanceIdentifier("comparability-result", f"mbt:{digest}"), + request_id=request.request_id, + state=state, + reason_codes=tuple(reason.value for reason in reasons), + conditions=conditions, + transformations_required=(), + missing_information=missing, + rule_reference=rule, + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + decided_by=ComparabilityDecisionSource.DETERMINISTIC_RULE, + ) + + +def _protocol_signature(protocol: MedicineBallThrowProtocolIdentity) -> tuple[object, ...]: + return ( + protocol.reference, + protocol.protocol_version, + protocol.throw_type, + protocol.body_posture, + protocol.support_restraint_condition, + protocol.ball_mass_kg, + protocol.countermovement, + protocol.lower_body_contribution, + protocol.throw_arm_technique, + protocol.starting_position, + protocol.release_semantics, + protocol.measurement_method, + protocol.distance_origin_convention, + protocol.distance_endpoint_convention, + protocol.first_contact_no_roll_convention, + protocol.trial_qualification, + protocol.trial_selection, + protocol.additional_attributes, + ) + + +def _acquisition_signature( + acquisition: MedicineBallThrowAcquisitionIdentity, +) -> tuple[object, ...]: + return ( + acquisition.device, + acquisition.provider, + acquisition.sensor_modality, + acquisition.sampling, + acquisition.timebase, + acquisition.axis_or_frame, + acquisition.calibration_reference, + acquisition.hardware_firmware, + ) + + +def _processing_signature( + processing: MedicineBallThrowProcessingIdentity, +) -> tuple[object, ...]: + return ( + processing.event_definitions, + processing.phase_definitions, + processing.estimator, + processing.registered_operation, + processing.filtering, + processing.differentiation_method, + processing.integration_method, + processing.unit, + processing.sign_convention, + processing.normalization, + processing.trial_selection, + processing.aggregation, + processing.timebase, + processing.processing_state, + processing.provider_algorithm, + processing.resampling, + ) + + +def compare_mbt_metric_results( + left: MedicineBallThrowMetricResult, + right: MedicineBallThrowMetricResult, + claim: str = "compare MBT metric results", +) -> ComparabilityResult: + left_id = getattr(left, "observation", None) + right_id = getattr(right, "observation", None) + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash( + { + "left": getattr(left_id, "observation_id", "invalid-left"), + "right": getattr(right_id, "observation_id", "invalid-right"), + "claim": claim, + } + ).removeprefix("sha256:")[:24], + ) + try: + _require_instance(left, MedicineBallThrowMetricResult, "left") + _require_instance(right, MedicineBallThrowMetricResult, "right") + request = ComparabilityRequest( + request_id=request_id, + left_observation_id=left.observation.observation_id, + right_observation_id=right.observation.observation_id, + claim=claim, + material_dimensions=( + "metric", + "throw_type", + "posture", + "support", + "ball_mass", + "distance_conventions", + "device", + "release_event", + ), + ) + except (AttributeError, TypeError, ValueError): + fallback = ComparabilityRequest( + request_id=request_id, + left_observation_id=InstanceIdentifier("observation", "invalid-left"), + right_observation_id=InstanceIdentifier("observation", "invalid-right"), + claim=claim or "invalid MBT comparison", + ) + if hasattr(left, "observation") and hasattr(right, "observation"): + left_observation = left.observation + right_observation = right.observation + if isinstance(left_observation, ScientificMeasurementObservation) and isinstance( + right_observation, ScientificMeasurementObservation + ): + fallback = ComparabilityRequest( + request_id=request_id, + left_observation_id=left_observation.observation_id, + right_observation_id=right_observation.observation_id, + claim=claim or "invalid MBT comparison", + ) + return _result( + fallback, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + return _result( + fallback, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("two typed MBT metric results",), + ) + if left.metric != right.metric: + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.MEASURAND_MISMATCH,), + ) + left_identity = left.observation.identity + right_identity = right.observation.identity + if not isinstance(left_identity, MedicineBallThrowMeasurementIdentity) or not isinstance( + right_identity, MedicineBallThrowMeasurementIdentity + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + if ( + left_identity.semantic.test_family != MEDICINE_BALL_THROW_TEST_FAMILY + or right_identity.semantic.test_family != MEDICINE_BALL_THROW_TEST_FAMILY + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), + ) + left_protocol = left_identity.semantic.protocol_identity + right_protocol = right_identity.semantic.protocol_identity + if left_protocol is None or right_protocol is None: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("complete MBT protocol identity",), + ) + required = ( + (left_protocol.throw_type.value, right_protocol.throw_type.value, "throw type"), + (left_protocol.body_posture.value, right_protocol.body_posture.value, "body posture"), + ( + left_protocol.support_restraint_condition.value, + right_protocol.support_restraint_condition.value, + "support/restraint", + ), + (left_protocol.ball_mass_kg, right_protocol.ball_mass_kg, "ball mass"), + ) + for left_value, right_value, label in required: + if left_value in {None, "UNKNOWN"} or right_value in {None, "UNKNOWN"}: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=(label,), + ) + if left_value != right_value: + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + ) + if left.metric == MBT_THROW_DISTANCE_METRIC: + conventions = ( + left_protocol.distance_origin_convention, + right_protocol.distance_origin_convention, + left_protocol.distance_endpoint_convention, + right_protocol.distance_endpoint_convention, + left_protocol.first_contact_no_roll_convention, + right_protocol.first_contact_no_roll_convention, + ) + if any(item is None for item in conventions): + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("origin, endpoint and first-contact/no-roll conventions",), + ) + if ( + conventions[0] != conventions[1] + or conventions[2] != conventions[3] + or conventions[4] != conventions[5] + ): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + ) + rule = MBT_DISTANCE_COMPARABILITY_RULE + elif left.metric == MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC: + if ( + left_protocol.release_semantics.value == "UNKNOWN" + or right_protocol.release_semantics.value == "UNKNOWN" + ): + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("explicit release semantics",), + rule=MBT_RELEASE_VELOCITY_COMPARABILITY_RULE, + ) + if left_protocol.release_semantics != right_protocol.release_semantics: + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + rule=MBT_RELEASE_VELOCITY_COMPARABILITY_RULE, + ) + rule = MBT_RELEASE_VELOCITY_COMPARABILITY_RULE + else: + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.METRIC_DEFINITION_MISMATCH,), + ) + if _protocol_signature(left_protocol) != _protocol_signature(right_protocol): + return _result( + request, + ComparabilityState.NOT_COMPARABLE, + (ComparabilityReasonCode.PROTOCOL_MISMATCH,), + rule=rule, + ) + if _acquisition_signature(left_identity.acquisition) != _acquisition_signature( + right_identity.acquisition + ): + if left_identity.acquisition.device is None or right_identity.acquisition.device is None: + return _result( + request, + ComparabilityState.INSUFFICIENT_INFORMATION, + (ComparabilityReasonCode.MISSING_METADATA,), + missing=("MBT device identity",), + rule=rule, + ) + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.DEVICE_MISMATCH,), + rule=rule, + ) + if _processing_signature(left_identity.processing) != _processing_signature( + right_identity.processing + ): + return _result( + request, + ComparabilityState.BRIDGE_VALIDATION_REQUIRED, + (ComparabilityReasonCode.SOURCE_PROCESSING_MISMATCH,), + rule=rule, + ) + return _result(request, ComparabilityState.COMPARABLE, (), rule=rule) + + +def assess_mbt_comparability( + left: MedicineBallThrowMetricResult, + right: MedicineBallThrowMetricResult, + claim: str = "compare MBT metric results", +) -> ComparabilityResult: + return compare_mbt_metric_results(left, right, claim) + + +compare_medicine_ball_throw_results = compare_mbt_metric_results + + +__all__ = [ + "MBTComparabilityRequest", + "assess_mbt_comparability", + "compare_mbt_metric_results", + "compare_medicine_ball_throw_results", +] diff --git a/src/dynamislm/measurement/medicine_ball_throw/identity.py b/src/dynamislm/measurement/medicine_ball_throw/identity.py new file mode 100644 index 0000000..5c4e8d0 --- /dev/null +++ b/src/dynamislm/measurement/medicine_ball_throw/identity.py @@ -0,0 +1,506 @@ +"""Typed medicine-ball throw protocol, distance and release-event identities.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum +from itertools import pairwise + +from dynamislm.measurement.identity import ( + AcquisitionIdentity, + InstanceIdentifier, + MeasurementIdentity, + MetadataEntry, + ProcessingIdentity, + RegistryReference, + SamplingCharacteristics, + SemanticIdentity, + _require_enum, + _require_instance, + _require_optional_instance, + _require_text, + _require_tuple_items, + require_tuple, +) +from dynamislm.measurement.medicine_ball_throw.registry import ( + MEDICINE_BALL_THROW_PROTOCOL_V1, + MEDICINE_BALL_THROW_TEST_FAMILY, +) +from dynamislm.provenance.models import AcquisitionRecord, SourceArtifact +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +def _finite_optional(value: object | None, field_name: str) -> float | None: + if value is None: + return None + return _finite(value, field_name) + + +class MBTThrowVariant(StrEnum): + SEATED_CHEST = "SEATED_CHEST" + STANDING_CHEST = "STANDING_CHEST" + BACKWARD_OVERHEAD = "BACKWARD_OVERHEAD" + ROTATIONAL = "ROTATIONAL" + SUPINE = "SUPINE" + MEDICINE_BALL_PUSH_PRESS = "MEDICINE_BALL_PUSH_PRESS" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class MBTBodyPosture(StrEnum): + SEATED = "SEATED" + STANDING = "STANDING" + SUPINE = "SUPINE" + KNEELING = "KNEELING" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class MBTSupportCondition(StrEnum): + BACK_SUPPORTED = "BACK_SUPPORTED" + FEET_RESTRAINED = "FEET_RESTRAINED" + FREE_STANDING = "FREE_STANDING" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class MBTAllowedState(StrEnum): + ALLOWED = "ALLOWED" + PROHIBITED = "PROHIBITED" + UNKNOWN = "UNKNOWN" + + +class MBTReleaseSemantics(StrEnum): + EXPLICIT_RELEASE_EVENT = "EXPLICIT_RELEASE_EVENT" + SOURCE_REPORTED_RELEASE = "SOURCE_REPORTED_RELEASE" + UNKNOWN = "UNKNOWN" + + +class MBTSensorModality(StrEnum): + MANUAL_DISTANCE = "MANUAL_DISTANCE" + MEASURING_TAPE = "MEASURING_TAPE" + OPTICAL = "OPTICAL" + RADAR = "RADAR" + LOCAL_POSITIONING = "LOCAL_POSITIONING" + IMU = "IMU" + OTHER_REGISTERED = "OTHER_REGISTERED" + UNKNOWN = "UNKNOWN" + + +class MBTTimebaseKind(StrEnum): + REGULAR = "REGULAR" + EXPLICIT = "EXPLICIT" + + +class MBTProcessingState(StrEnum): + RAW_ACQUIRED = "RAW_ACQUIRED" + DEVICE_PROCESSED = "DEVICE_PROCESSED" + PROVIDER_PROCESSED = "PROVIDER_PROCESSED" + DYNAMISLM_PROCESSED = "DYNAMISLM_PROCESSED" + UNKNOWN = "UNKNOWN" + + +class MBTArtifactStatus(StrEnum): + VERIFIED = "VERIFIED" + UNVERIFIED = "UNVERIFIED" + + +class MBTHashAlgorithm(StrEnum): + SHA256 = "sha256" + + +class MBTArtifactHashScope(StrEnum): + CONTENT_BYTES = "CONTENT_BYTES" + CANONICAL_SERIES_REPRESENTATION = "CANONICAL_SERIES_REPRESENTATION" + + +class MBTQualificationStatus(StrEnum): + QUALIFIED = "QUALIFIED" + REJECTED = "REJECTED" + UNKNOWN = "UNKNOWN" + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MBTTimebase: + kind: MBTTimebaseKind + sample_rate_hz: float | None = None + start_time_s: float = 0.0 + times_s: tuple[float, ...] = () + + def __post_init__(self) -> None: + _require_enum(self.kind, MBTTimebaseKind, "kind") + rate = _finite_optional(self.sample_rate_hz, "sample_rate_hz") + if rate is not None and rate <= 0: + raise ValueError("sample_rate_hz must be positive") + object.__setattr__(self, "sample_rate_hz", rate) + object.__setattr__(self, "start_time_s", _finite(self.start_time_s, "start_time_s")) + require_tuple(self.times_s, "times_s") + if self.kind is MBTTimebaseKind.REGULAR: + if rate is None: + raise ValueError("regular timebase requires sample_rate_hz") + if self.times_s: + raise ValueError("regular timebase must not carry explicit timestamps") + else: + if not self.times_s: + raise ValueError("explicit timebase requires timestamps") + normalized = tuple(_finite(value, "times_s item") for value in self.times_s) + if any(right <= left for left, right in pairwise(normalized)): + raise ValueError("explicit timestamps must be strictly increasing") + object.__setattr__(self, "times_s", normalized) + + def time_at(self, index: int) -> float: + if type(index) is not int or index < 0: + raise ValueError("MBT sample index must be a non-negative integer") + if self.kind is MBTTimebaseKind.REGULAR: + assert self.sample_rate_hz is not None + return self.start_time_s + index / self.sample_rate_hz + if index >= len(self.times_s): + raise IndexError("MBT sample index is outside explicit timebase") + return self.times_s[index] + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MedicineBallThrowProtocolIdentity: + """Exact MBT variant, posture, ball and distance convention identity.""" + + reference: RegistryReference = MEDICINE_BALL_THROW_PROTOCOL_V1 + protocol_version: str = "1.0.0" + throw_type: MBTThrowVariant = MBTThrowVariant.UNKNOWN + body_posture: MBTBodyPosture = MBTBodyPosture.UNKNOWN + support_restraint_condition: MBTSupportCondition = MBTSupportCondition.UNKNOWN + ball_mass_kg: float | None = None + countermovement: MBTAllowedState = MBTAllowedState.UNKNOWN + lower_body_contribution: MBTAllowedState = MBTAllowedState.UNKNOWN + throw_arm_technique: str | None = None + starting_position: str | None = None + release_semantics: MBTReleaseSemantics = MBTReleaseSemantics.UNKNOWN + measurement_method: RegistryReference | None = None + distance_origin_convention: RegistryReference | None = None + distance_endpoint_convention: RegistryReference | None = None + first_contact_no_roll_convention: RegistryReference | None = None + provider: str | None = None + device: RegistryReference | None = None + sensor_modality: MBTSensorModality = MBTSensorModality.UNKNOWN + sampling: SamplingCharacteristics | None = None + timebase: MBTTimebase | None = None + filtering: tuple[RegistryReference, ...] | None = None + smoothing: RegistryReference | None = None + resampling: RegistryReference | None = None + software: str | None = None + firmware: str | None = None + trial_qualification: RegistryReference | None = None + trial_selection: RegistryReference | None = None + additional_attributes: tuple[MetadataEntry, ...] = () + + def __post_init__(self) -> None: + _require_instance(self.reference, RegistryReference, "reference") + if self.reference.identifier.object_type != "protocol": + raise ValueError("MBT protocol reference must identify a protocol") + _require_text(self.protocol_version, "protocol_version") + for name, value, expected in ( + ("throw_type", self.throw_type, MBTThrowVariant), + ("body_posture", self.body_posture, MBTBodyPosture), + ("support_restraint_condition", self.support_restraint_condition, MBTSupportCondition), + ("countermovement", self.countermovement, MBTAllowedState), + ("lower_body_contribution", self.lower_body_contribution, MBTAllowedState), + ("release_semantics", self.release_semantics, MBTReleaseSemantics), + ("sensor_modality", self.sensor_modality, MBTSensorModality), + ): + _require_enum(value, expected, name) + mass = _finite_optional(self.ball_mass_kg, "ball_mass_kg") + if mass is not None and mass <= 0: + raise ValueError("ball_mass_kg must be positive") + object.__setattr__(self, "ball_mass_kg", mass) + for name in ( + "throw_arm_technique", + "starting_position", + "provider", + "software", + "firmware", + ): + text_value: object = getattr(self, name) + if text_value is not None: + assert isinstance(text_value, str) + _require_text(text_value, name) + for name in ( + "measurement_method", + "distance_origin_convention", + "distance_endpoint_convention", + "first_contact_no_roll_convention", + "device", + "smoothing", + "resampling", + "trial_qualification", + "trial_selection", + ): + _require_optional_instance(getattr(self, name), RegistryReference, name) + _require_optional_instance(self.sampling, SamplingCharacteristics, "sampling") + _require_optional_instance(self.timebase, MBTTimebase, "timebase") + if self.filtering is not None: + _require_tuple_items(self.filtering, RegistryReference, "filtering") + _require_tuple_items(self.additional_attributes, MetadataEntry, "additional_attributes") + + @property + def test_family(self) -> RegistryReference: + return MEDICINE_BALL_THROW_TEST_FAMILY + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MedicineBallThrowAcquisitionIdentity(AcquisitionIdentity): + provider: str | None = None + sensor_modality: MBTSensorModality = MBTSensorModality.UNKNOWN + timebase: MBTTimebase | None = None + axis_or_frame: RegistryReference | None = None + acquisition_instance_id: InstanceIdentifier | None = None + source_series_digest: str | None = None + + def __post_init__(self) -> None: + AcquisitionIdentity.__post_init__(self) + if self.provider is not None: + _require_text(self.provider, "provider") + _require_enum(self.sensor_modality, MBTSensorModality, "sensor_modality") + _require_optional_instance(self.timebase, MBTTimebase, "timebase") + _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") + _require_optional_instance( + self.acquisition_instance_id, InstanceIdentifier, "acquisition_instance_id" + ) + if ( + self.acquisition_instance_id is not None + and self.acquisition_instance_id.instance_type != "acquisition" + ): + raise ValueError("acquisition_instance_id must identify an acquisition") + if self.source_series_digest is not None: + _require_text(self.source_series_digest, "source_series_digest") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MedicineBallThrowProcessingIdentity(ProcessingIdentity): + timebase: MBTTimebase | None = None + processing_state: MBTProcessingState = MBTProcessingState.UNKNOWN + provider_algorithm: RegistryReference | None = None + resampling: RegistryReference | None = None + + def __post_init__(self) -> None: + ProcessingIdentity.__post_init__(self) + _require_optional_instance(self.timebase, MBTTimebase, "timebase") + _require_enum(self.processing_state, MBTProcessingState, "processing_state") + _require_optional_instance(self.provider_algorithm, RegistryReference, "provider_algorithm") + _require_optional_instance(self.resampling, RegistryReference, "resampling") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MedicineBallThrowSemanticIdentity(SemanticIdentity): + protocol_identity: MedicineBallThrowProtocolIdentity | None = None + + def __post_init__(self) -> None: + SemanticIdentity.__post_init__(self) + _require_instance( + self.protocol_identity, MedicineBallThrowProtocolIdentity, "protocol_identity" + ) + assert self.protocol_identity is not None + if self.test_family != MEDICINE_BALL_THROW_TEST_FAMILY: + raise ValueError("MBT semantic identity has the wrong test family") + if self.protocol != self.protocol_identity.reference: + raise ValueError("MBT protocol and protocol_identity references must agree") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MedicineBallThrowMeasurementIdentity(MeasurementIdentity): + semantic: MedicineBallThrowSemanticIdentity + acquisition: MedicineBallThrowAcquisitionIdentity + processing: MedicineBallThrowProcessingIdentity + + def __post_init__(self) -> None: + MeasurementIdentity.__post_init__(self) + _require_instance(self.semantic, MedicineBallThrowSemanticIdentity, "semantic") + _require_instance(self.acquisition, MedicineBallThrowAcquisitionIdentity, "acquisition") + _require_instance(self.processing, MedicineBallThrowProcessingIdentity, "processing") + if self.semantic.test_family != MEDICINE_BALL_THROW_TEST_FAMILY: + raise ValueError("MBT measurement identity has the wrong family") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MedicineBallThrowSourceArtifact(SourceArtifact): + status: MBTArtifactStatus = MBTArtifactStatus.UNVERIFIED + hash_algorithm: MBTHashAlgorithm = MBTHashAlgorithm.SHA256 + hash_scope: MBTArtifactHashScope = MBTArtifactHashScope.CONTENT_BYTES + + def __post_init__(self) -> None: + SourceArtifact.__post_init__(self) + _require_enum(self.status, MBTArtifactStatus, "status") + _require_enum(self.hash_algorithm, MBTHashAlgorithm, "hash_algorithm") + _require_enum(self.hash_scope, MBTArtifactHashScope, "hash_scope") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MedicineBallThrowAcquisitionRecord(AcquisitionRecord): + provider: str | None = None + sensor_modality: MBTSensorModality = MBTSensorModality.UNKNOWN + timebase: MBTTimebase | None = None + axis_or_frame: RegistryReference | None = None + + def __post_init__(self) -> None: + AcquisitionRecord.__post_init__(self) + if self.provider is not None: + _require_text(self.provider, "provider") + _require_enum(self.sensor_modality, MBTSensorModality, "sensor_modality") + _require_optional_instance(self.timebase, MBTTimebase, "timebase") + _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MBTCoordinateEvidence: + """Exact coordinate endpoints for one protocol-defined throw distance.""" + + source_observation_id: InstanceIdentifier + origin_coordinate_m: float + endpoint_coordinate_m: float + coordinate_frame: RegistryReference + origin_convention: RegistryReference + endpoint_convention: RegistryReference + first_contact_no_roll_convention: RegistryReference + first_contact_observed: bool + no_roll_observed: bool + source_artifact_id: InstanceIdentifier + acquisition_id: InstanceIdentifier + + def __post_init__(self) -> None: + for name, value, expected in ( + ("source_observation_id", self.source_observation_id, "observation"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("acquisition_id", self.acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, name) + if value.instance_type != expected: + raise ValueError(f"{name} must identify a {expected}") + for name in ( + "coordinate_frame", + "origin_convention", + "endpoint_convention", + "first_contact_no_roll_convention", + ): + _require_instance(getattr(self, name), RegistryReference, name) + origin = _finite(self.origin_coordinate_m, "origin_coordinate_m") + endpoint = _finite(self.endpoint_coordinate_m, "endpoint_coordinate_m") + if endpoint < origin: + raise ValueError("MBT endpoint coordinate must not precede origin coordinate") + object.__setattr__(self, "origin_coordinate_m", origin) + object.__setattr__(self, "endpoint_coordinate_m", endpoint) + if not isinstance(self.first_contact_observed, bool) or not isinstance( + self.no_roll_observed, bool + ): + raise ValueError("MBT contact/no-roll observations must be booleans") + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MBTReleaseEvent: + """Explicit release event bound to an instrumented source trajectory.""" + + event_id: InstanceIdentifier + source_observation_id: InstanceIdentifier + source_series_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + acquisition_id: InstanceIdentifier + sample_index: int + event_time_s: float + release_method: RegistryReference + coordinate_frame: RegistryReference + source_series_digest: str + + def __post_init__(self) -> None: + for name, value, expected in ( + ("event_id", self.event_id, "event-occurrence"), + ("source_observation_id", self.source_observation_id, "observation"), + ("source_series_id", self.source_series_id, "signal"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("acquisition_id", self.acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, name) + if value.instance_type != expected: + raise ValueError(f"{name} must identify a {expected}") + if type(self.sample_index) is not int or self.sample_index < 0: + raise ValueError("MBT release sample index must be non-negative") + _finite(self.event_time_s, "event_time_s") + _require_instance(self.release_method, RegistryReference, "release_method") + _require_instance(self.coordinate_frame, RegistryReference, "coordinate_frame") + _require_text(self.source_series_digest, "source_series_digest") + + +def canonical_mbt_trajectory_digest( + samples: tuple[tuple[float, float], ...], + timebase: MBTTimebase, + coordinate_frame: RegistryReference, +) -> str: + require_tuple(samples, "samples") + normalized = tuple( + (_finite(item[0], "sample time"), _finite(item[1], "sample coordinate")) for item in samples + ) + if any(right[0] <= left[0] for left, right in pairwise(normalized)): + raise ValueError("MBT trajectory timestamps must increase") + return canonical_hash( + {"samples": normalized, "timebase": timebase, "coordinate_frame": coordinate_frame} + ) + + +# Short aliases preserve one serialization identity per class. +MBTProtocolIdentity = MedicineBallThrowProtocolIdentity +MBTAcquisitionIdentity = MedicineBallThrowAcquisitionIdentity +MBTProcessingIdentity = MedicineBallThrowProcessingIdentity +MBTSemanticIdentity = MedicineBallThrowSemanticIdentity +MBTMeasurementIdentity = MedicineBallThrowMeasurementIdentity +MBTSourceArtifact = MedicineBallThrowSourceArtifact +MBTAcquisitionRecord = MedicineBallThrowAcquisitionRecord + + +__all__ = [ + "MBTAcquisitionIdentity", + "MBTAcquisitionRecord", + "MBTAllowedState", + "MBTArtifactHashScope", + "MBTArtifactStatus", + "MBTBodyPosture", + "MBTCoordinateEvidence", + "MBTHashAlgorithm", + "MBTMeasurementIdentity", + "MBTProcessingIdentity", + "MBTProcessingState", + "MBTProtocolIdentity", + "MBTQualificationStatus", + "MBTReleaseEvent", + "MBTReleaseSemantics", + "MBTSemanticIdentity", + "MBTSensorModality", + "MBTSourceArtifact", + "MBTSupportCondition", + "MBTThrowVariant", + "MBTTimebase", + "MBTTimebaseKind", + "MedicineBallThrowAcquisitionIdentity", + "MedicineBallThrowAcquisitionRecord", + "MedicineBallThrowMeasurementIdentity", + "MedicineBallThrowProcessingIdentity", + "MedicineBallThrowProtocolIdentity", + "MedicineBallThrowSemanticIdentity", + "MedicineBallThrowSourceArtifact", + "canonical_mbt_trajectory_digest", +] diff --git a/src/dynamislm/measurement/medicine_ball_throw/metrics.py b/src/dynamislm/measurement/medicine_ball_throw/metrics.py new file mode 100644 index 0000000..e3e2da7 --- /dev/null +++ b/src/dynamislm/measurement/medicine_ball_throw/metrics.py @@ -0,0 +1,581 @@ +"""Deterministic MBT distance/release-velocity operations and refusals.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + UnitReference, + VersionIdentity, + _require_instance, + _require_tuple_items, +) +from dynamislm.measurement.medicine_ball_throw.identity import ( + MBTBodyPosture, + MBTCoordinateEvidence, + MBTMeasurementIdentity, + MBTProcessingState, + MBTReleaseSemantics, + MBTSensorModality, + MBTThrowVariant, + MedicineBallThrowAcquisitionIdentity, + MedicineBallThrowMeasurementIdentity, + MedicineBallThrowProcessingIdentity, + MedicineBallThrowSemanticIdentity, +) +from dynamislm.measurement.medicine_ball_throw.qualification import ( + MBTReleaseVelocityEvidence, + MBTSourceQualificationEvidence, + require_qualified_mbt, +) +from dynamislm.measurement.medicine_ball_throw.registry import ( + MBT_DISTANCE_FROM_REGISTERED_COORDINATES_OPERATION, + MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND, + MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC, + MBT_INSTRUMENTED_RELEASE_VELOCITY_OPERATION, + MBT_REGISTRY_VERSION, + MBT_SOFTWARE_VERSION, + MBT_THROW_DISTANCE_MEASURAND, + MBT_THROW_DISTANCE_METRIC, + MEDICINE_BALL_THROW_TEST_FAMILY, + METER, + METERS_PER_SECOND, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.observation import ObservationContext, ScientificMeasurementObservation +from dynamislm.measurement.result import ( + MeasurementQuality, + MeasurementResult, + ResultStatus, + ScalarValue, + UncertaintyMetadata, + UncertaintyStatus, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ScientificRole, ValueOrigin +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, + SourceArtifact, +) +from dynamislm.refusal.models import RefusalClass, RefusalReasonCode, RefusalResult, RefusalStatus +from dynamislm.serialization import canonical_hash, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MedicineBallThrowMetricResult: + observation: ScientificMeasurementObservation + metric: RegistryReference + source_observations: tuple[ScientificMeasurementObservation, ...] + coordinate_evidence: MBTCoordinateEvidence | None = None + release_evidence: MBTReleaseVelocityEvidence | None = None + + def __post_init__(self) -> None: + _require_instance(self.observation, ScientificMeasurementObservation, "observation") + _require_instance(self.metric, RegistryReference, "metric") + _require_tuple_items( + self.source_observations, ScientificMeasurementObservation, "source_observations" + ) + if not isinstance(self.observation.identity, MBTMeasurementIdentity): + raise ValueError("MBT result requires MedicineBallThrowMeasurementIdentity") + if self.observation.identity.semantic.metric_definition != self.metric: + raise ValueError("MBT metric does not match output identity") + value = self.observation.result.value + if not isinstance(value, ScalarValue) or isinstance(value.value, bool): + raise ValueError("MBT result must contain a numeric scalar") + _finite(value.value, "MBT result") + if self.observation.result.status is not ResultStatus.VALID: + raise ValueError("MBT result must be valid") + runs = tuple( + run + for run in self.observation.provenance.processing_runs + if run.output_entity_id == self.observation.observation_id + ) + if len(runs) != 1: + raise ValueError("MBT result must preserve one output processing run") + run_id = runs[0].processing_run_id.qualified + for source in self.source_observations: + if not any( + edge.from_id == source.observation_id.qualified + and edge.to_id == run_id + and edge.relation is LineageRelation.DERIVED_FROM + for edge in self.observation.provenance.lineage_edges + ): + raise ValueError("MBT result is missing source observation lineage") + + @property + def value(self) -> float: + value = self.observation.result.value + assert isinstance(value, ScalarValue) + return float(value.value) + + @property + def unit(self) -> UnitReference | None: + return self.observation.result.unit + + @property + def value_m(self) -> float: + if self.unit != METER: + raise ValueError("MBT result is not expressed in metres") + return self.value + + @property + def value_m_per_s(self) -> float: + if self.unit != METERS_PER_SECOND: + raise ValueError("MBT result is not expressed in m/s") + return self.value + + +def _refusal( + claim: str, + reasons: tuple[RefusalReasonCode | str, ...], + missing: tuple[str, ...], + observation_ids: tuple[InstanceIdentifier, ...] = (), + *, + refusal_class: RefusalClass = RefusalClass.IDENTITY_UNRESOLVED, + safe: tuple[str, ...] = (), +) -> RefusalResult: + codes = tuple(item.value if isinstance(item, RefusalReasonCode) else item for item in reasons) + digest = canonical_hash( + {"claim": claim, "reasons": codes, "missing": missing, "observation_ids": observation_ids} + ).removeprefix("sha256:")[:24] + return RefusalResult( + refusal_id=InstanceIdentifier("refusal", f"medicine-ball-throw:{digest}"), + status=RefusalStatus.REFUSED, + refusal_class=refusal_class, + blocked_claim=claim, + reason_codes=codes, + missing_information=missing, + what_can_still_be_safely_described=safe or ("the exact MBT source observation",), + evidence_references=(RES68_DECISION_EXPLOSIVE_TEST_FAMILY,), + observation_ids=observation_ids, + ) + + +def _validate_distance_source( + source_observation: ScientificMeasurementObservation, + coordinate: MBTCoordinateEvidence, +) -> MedicineBallThrowMeasurementIdentity: + if not isinstance(source_observation, ScientificMeasurementObservation): + raise ValueError("MBT source observation is required") + identity = source_observation.identity + if not isinstance(identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT source observation requires MBT identity") + protocol = identity.semantic.protocol_identity + if protocol is None: + raise ValueError("MBT protocol identity is required") + if coordinate.source_observation_id != source_observation.observation_id: + raise ValueError("MBT coordinate evidence is bound to another observation") + if coordinate.source_artifact_id not in { + item.artifact_id for item in source_observation.provenance.source_artifacts + } or coordinate.acquisition_id not in { + item.acquisition_id for item in source_observation.provenance.acquisitions + }: + raise ValueError("MBT coordinate evidence is not bound to source provenance") + if ( + protocol.distance_origin_convention is None + or protocol.distance_endpoint_convention is None + or protocol.first_contact_no_roll_convention is None + ): + raise ValueError("MBT distance conventions are unresolved") + if ( + protocol.distance_origin_convention != coordinate.origin_convention + or protocol.distance_endpoint_convention != coordinate.endpoint_convention + or protocol.first_contact_no_roll_convention != coordinate.first_contact_no_roll_convention + ): + raise ValueError("MBT coordinate convention mismatch") + if not coordinate.first_contact_observed or not coordinate.no_roll_observed: + raise ValueError("MBT distance requires first-contact and no-roll evidence") + if ( + protocol.throw_type is MBTThrowVariant.UNKNOWN + or protocol.body_posture is MBTBodyPosture.UNKNOWN + ): + raise ValueError("MBT throw type/posture is unresolved") + if protocol.ball_mass_kg is None: + raise ValueError("MBT ball mass is required") + return identity + + +def _output_identity( + source_identity: MedicineBallThrowMeasurementIdentity, + *, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + unit: UnitReference, + parameters: tuple[MetadataEntry, ...], +) -> MedicineBallThrowMeasurementIdentity: + source_acquisition = source_identity.acquisition + return MedicineBallThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"mbt-{operation.identifier.key}-{canonical_hash(parameters).removeprefix('sha256:')[:24]}", + MBT_REGISTRY_VERSION, + ), + semantic=MedicineBallThrowSemanticIdentity( + construct=source_identity.semantic.construct, + test_family=MEDICINE_BALL_THROW_TEST_FAMILY, + protocol=source_identity.semantic.protocol, + measurand=measurand, + metric_definition=metric, + protocol_identity=source_identity.semantic.protocol_identity, + ), + acquisition=MedicineBallThrowAcquisitionIdentity( + device=source_acquisition.device, + raw_artifact=source_acquisition.raw_artifact, + sensor_channel=source_acquisition.sensor_channel, + sampling=source_acquisition.sampling, + calibration_reference=source_acquisition.calibration_reference, + hardware_firmware=source_acquisition.hardware_firmware, + provider=source_acquisition.provider, + sensor_modality=source_acquisition.sensor_modality, + timebase=source_acquisition.timebase, + axis_or_frame=source_acquisition.axis_or_frame, + acquisition_instance_id=source_acquisition.acquisition_instance_id, + source_series_digest=source_acquisition.source_series_digest, + ), + processing=MedicineBallThrowProcessingIdentity( + registered_operation=operation, + estimator=operation, + method_parameters=parameters, + unit=unit, + sign_convention=source_identity.processing.sign_convention, + normalization=source_identity.processing.normalization, + trial_selection=source_identity.processing.trial_selection, + aggregation=source_identity.processing.aggregation, + filtering=source_identity.processing.filtering, + differentiation_method=source_identity.processing.differentiation_method, + integration_method=source_identity.processing.integration_method, + timebase=source_acquisition.timebase, + processing_state=MBTProcessingState.DYNAMISLM_PROCESSED, + ), + version=VersionIdentity( + processing_method=operation, + method_registry_version=MBT_REGISTRY_VERSION, + software_version=MBT_SOFTWARE_VERSION, + hardware_firmware=source_acquisition.hardware_firmware, + ), + ) + + +def _build_derived( + *, + source_observation: ScientificMeasurementObservation, + value: float, + metric: RegistryReference, + measurand: RegistryReference, + operation: RegistryReference, + unit: UnitReference, + parameters: tuple[MetadataEntry, ...], + coordinate_evidence: MBTCoordinateEvidence | None = None, + release_evidence: MBTReleaseVelocityEvidence | None = None, +) -> MedicineBallThrowMetricResult: + if not isinstance(source_observation.identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT source identity is required") + _require_instance(source_observation.context, ObservationContext, "context") + numeric = _finite(value, "MBT derived value") + identity = _output_identity( + source_observation.identity, + metric=metric, + measurand=measurand, + operation=operation, + unit=unit, + parameters=parameters, + ) + digest = canonical_hash( + { + "operation": operation, + "metric": metric, + "value": numeric, + "parameters": parameters, + "source_observation_id": source_observation.observation_id, + } + ).removeprefix("sha256:")[:24] + output_id = InstanceIdentifier("observation", f"mbt:{operation.identifier.key}:{digest}") + output_artifact = SourceArtifact( + artifact_id=InstanceIdentifier("artifact", f"mbt-result:{digest}"), + content_digest=canonical_hash({"identity": identity, "value": numeric, "metric": metric}), + media_type="application/vnd.dynamislm.medicine-ball-throw.scalar-metric", + immutable=True, + ) + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"mbt:{operation.identifier.key}:{digest}" + ), + source_artifact_ids=tuple( + sorted( + (item.artifact_id for item in source_observation.provenance.source_artifacts), + key=lambda item: item.qualified, + ) + ), + method=operation, + parameters=parameters, + software_version=MBT_SOFTWARE_VERSION, + output_entity_id=output_id, + ) + if not run.source_artifact_ids: + raise ValueError("MBT derived result requires source artifacts") + edges = list(source_observation.provenance.lineage_edges) + for source_id in ( + source_observation.observation_id, + *(run.source_artifact_ids), + ): + edge = LineageEdge( + source_id.qualified, run.processing_run_id.qualified, LineageRelation.DERIVED_FROM + ) + if edge not in edges: + edges.append(edge) + for acquisition in source_observation.provenance.acquisitions: + edge = LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ) + if edge not in edges: + edges.append(edge) + for edge in ( + LineageEdge( + RES68_DECISION_EXPLOSIVE_TEST_FAMILY.stable_id, + run.processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge( + run.processing_run_id.qualified, + output_artifact.artifact_id.qualified, + LineageRelation.PRODUCED, + ), + LineageEdge(run.processing_run_id.qualified, output_id.qualified, LineageRelation.PRODUCED), + ): + if edge not in edges: + edges.append(edge) + provenance = Provenance( + provenance_id=InstanceIdentifier("provenance", output_id.value), + source_artifacts=tuple( + sorted( + (*source_observation.provenance.source_artifacts, output_artifact), + key=lambda item: item.artifact_id.qualified, + ) + ), + acquisitions=source_observation.provenance.acquisitions, + processing_runs=(*source_observation.provenance.processing_runs, run), + lineage_edges=tuple( + sorted(edges, key=lambda item: (item.from_id, item.to_id, item.relation.value)) + ), + evidence_references=tuple( + dict.fromkeys( + ( + *source_observation.provenance.evidence_references, + EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), + ) + ) + ), + metrological_traceability=source_observation.provenance.metrological_traceability, + recorded_at=source_observation.provenance.recorded_at + or source_observation.context.observed_at, + ) + observation = ScientificMeasurementObservation( + observation_id=output_id, + context=source_observation.context, + identity=identity, + result=MeasurementResult( + result_id=InstanceIdentifier("result", f"mbt:{operation.identifier.key}:{digest}"), + value=ScalarValue(numeric), + unit=unit, + classification=ScientificClassification( + ValueOrigin.DYNAMISLM_DERIVED, (ScientificRole.PERFORMANCE_OUTCOME,) + ), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata( + status=UncertaintyStatus.NOT_ASSESSED, + description="RES-68 deterministic MBT arithmetic; uncertainty is not assessed.", + ), + status=ResultStatus.VALID, + ), + provenance=provenance, + ) + return MedicineBallThrowMetricResult( + observation=observation, + metric=metric, + source_observations=(source_observation,), + coordinate_evidence=coordinate_evidence, + release_evidence=release_evidence, + ) + + +def calculate_mbt_throw_distance( + coordinate_evidence: MBTCoordinateEvidence, + source_observation: ScientificMeasurementObservation, + qualification: MBTSourceQualificationEvidence | None = None, +) -> MedicineBallThrowMetricResult | RefusalResult: + """Calculate exact MBT distance from registered origin and endpoint coordinates.""" + + claim = "calculate medicine-ball throw distance" + try: + _validate_distance_source(source_observation, coordinate_evidence) + if qualification is not None: + require_qualified_mbt(qualification, source_observation) + value = coordinate_evidence.endpoint_coordinate_m - coordinate_evidence.origin_coordinate_m + if value < 0 or not math.isfinite(value): + raise ValueError("MBT distance must be finite and non-negative") + parameters = ( + MetadataEntry("origin_coordinate_m", coordinate_evidence.origin_coordinate_m), + MetadataEntry("endpoint_coordinate_m", coordinate_evidence.endpoint_coordinate_m), + MetadataEntry("origin_convention", coordinate_evidence.origin_convention.stable_id), + MetadataEntry("endpoint_convention", coordinate_evidence.endpoint_convention.stable_id), + MetadataEntry("coordinate_frame", coordinate_evidence.coordinate_frame.stable_id), + MetadataEntry("first_contact_observed", coordinate_evidence.first_contact_observed), + MetadataEntry("no_roll_observed", coordinate_evidence.no_roll_observed), + ) + return _build_derived( + source_observation=source_observation, + value=value, + metric=MBT_THROW_DISTANCE_METRIC, + measurand=MBT_THROW_DISTANCE_MEASURAND, + operation=MBT_DISTANCE_FROM_REGISTERED_COORDINATES_OPERATION, + unit=METER, + parameters=parameters, + coordinate_evidence=coordinate_evidence, + ) + except (AttributeError, IndexError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.MISSING_METADATA,), + ("exact MBT protocol, origin, endpoint and first-contact/no-roll evidence",), + (source_observation.observation_id,) + if isinstance(source_observation, ScientificMeasurementObservation) + else (), + ) + + +def calculate_mbt_instrumented_release_velocity( + evidence: MBTReleaseVelocityEvidence, + qualification: MBTSourceQualificationEvidence | None = None, +) -> MedicineBallThrowMetricResult | RefusalResult: + """Select the exact qualified velocity sample at the registered release event.""" + + claim = "calculate instrumented medicine-ball release velocity" + try: + _require_instance(evidence, MBTReleaseVelocityEvidence, "evidence") + source = evidence.observation + identity = source.identity + if not isinstance(identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT release source requires MBT identity") + protocol = identity.semantic.protocol_identity + if protocol is None or protocol.release_semantics is MBTReleaseSemantics.UNKNOWN: + raise ValueError("MBT release semantics are unresolved") + if ( + identity.acquisition.device is None + or identity.acquisition.sensor_modality is MBTSensorModality.UNKNOWN + ): + raise ValueError("MBT instrumented device/modality identity is required") + if qualification is not None: + require_qualified_mbt(qualification, source) + value = evidence.release_velocity_m_per_s + parameters = ( + MetadataEntry("release_event_id", evidence.release_event.event_id.qualified), + MetadataEntry("release_sample_index", evidence.release_event.sample_index), + MetadataEntry("release_event_time_s", evidence.release_event.event_time_s), + MetadataEntry("velocity_definition", evidence.velocity_definition.stable_id), + MetadataEntry("coordinate_frame", evidence.coordinate_frame.stable_id), + MetadataEntry("source_series_digest", evidence.source_series_digest), + ) + return _build_derived( + source_observation=source, + value=value, + metric=MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC, + measurand=MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND, + operation=MBT_INSTRUMENTED_RELEASE_VELOCITY_OPERATION, + unit=METERS_PER_SECOND, + parameters=parameters, + release_evidence=evidence, + ) + except (AttributeError, IndexError, TypeError, ValueError): + return _refusal( + claim, + (RefusalReasonCode.EVENT_SOURCE_MISMATCH,), + ("qualified trajectory, exact release event, frame, timebase and velocity definition",), + (evidence.observation.observation_id,) + if isinstance(evidence, MBTReleaseVelocityEvidence) + else (), + ) + + +def _unregistered(claim: str, evidence: object | None = None) -> RefusalResult: + observation_ids = ( + (evidence.observation.observation_id,) + if isinstance(evidence, MBTReleaseVelocityEvidence | MedicineBallThrowMetricResult) + else () + ) + return _refusal( + claim, + (RefusalReasonCode.NO_REGISTERED_OPERATION, "COMPUTATION_NOT_REGISTERED"), + ("a separately registered MBT mechanical-power or normative method",), + observation_ids, + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + safe=("the exact MBT distance or release-velocity observation",), + ) + + +def calculate_mbt_distance_as_power(evidence: object | None = None) -> RefusalResult: + return _unregistered("calculate MBT power from throw distance", evidence) + + +def calculate_mbt_generic_upper_body_power(evidence: object | None = None) -> RefusalResult: + return _unregistered("calculate generic upper-body power from MBT distance", evidence) + + +def calculate_mbt_release_velocity_from_distance(evidence: object | None = None) -> RefusalResult: + return _refusal( + "infer MBT release velocity from throw distance", + (RefusalReasonCode.NO_REGISTERED_OPERATION, "DISTANCE_IS_NOT_RELEASE_VELOCITY"), + ("qualified instrumented trajectory and explicit release event",), + (), + refusal_class=RefusalClass.COMPUTATION_NOT_REGISTERED, + safe=("the exact MBT distance observation",), + ) + + +def calculate_mbt_protocol_independent_normative_score( + evidence: object | None = None, +) -> RefusalResult: + return _unregistered("calculate protocol-independent MBT normative score", evidence) + + +def compare_mbt_protocols_as_equivalent(evidence: object | None = None) -> RefusalResult: + return _unregistered("claim MBT cross-protocol equivalence", evidence) + + +calculate_medicine_ball_throw_distance = calculate_mbt_throw_distance +calculate_medicine_ball_release_velocity = calculate_mbt_instrumented_release_velocity + + +__all__ = [ + "MedicineBallThrowMetricResult", + "calculate_mbt_distance_as_power", + "calculate_mbt_generic_upper_body_power", + "calculate_mbt_instrumented_release_velocity", + "calculate_mbt_protocol_independent_normative_score", + "calculate_mbt_release_velocity_from_distance", + "calculate_mbt_throw_distance", + "calculate_medicine_ball_release_velocity", + "calculate_medicine_ball_throw_distance", + "compare_mbt_protocols_as_equivalent", +] diff --git a/src/dynamislm/measurement/medicine_ball_throw/qualification.py b/src/dynamislm/measurement/medicine_ball_throw/qualification.py new file mode 100644 index 0000000..c9911b6 --- /dev/null +++ b/src/dynamislm/measurement/medicine_ball_throw/qualification.py @@ -0,0 +1,768 @@ +"""MBT source observation, trajectory evidence and qualification boundaries.""" + +from __future__ import annotations + +import datetime as datetime_module +import math +from dataclasses import dataclass +from itertools import pairwise + +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + VersionIdentity, + _require_enum, + _require_instance, +) +from dynamislm.measurement.medicine_ball_throw.identity import ( + MBTArtifactStatus, + MBTProcessingState, + MBTQualificationStatus, + MBTReleaseEvent, + MBTSensorModality, + MBTTimebase, + MedicineBallThrowAcquisitionIdentity, + MedicineBallThrowAcquisitionRecord, + MedicineBallThrowMeasurementIdentity, + MedicineBallThrowProcessingIdentity, + MedicineBallThrowProtocolIdentity, + MedicineBallThrowSemanticIdentity, + MedicineBallThrowSourceArtifact, +) +from dynamislm.measurement.medicine_ball_throw.registry import ( + MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND, + MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC, + MBT_QUALIFICATION_MEASURAND, + MBT_QUALIFICATION_METRIC, + MBT_REGISTRY_VERSION, + MBT_RELEASE_VELOCITY_SCHEMA, + MBT_RELEASE_VELOCITY_SOURCE_OPERATION, + MBT_SOFTWARE_VERSION, + MBT_SOURCE_DISTANCE_OPERATION, + MBT_SOURCE_QUALIFICATION_OPERATION, + MBT_THROW_DISTANCE_MEASURAND, + MBT_THROW_DISTANCE_METRIC, + MEDICINE_BALL_THROW_CONSTRUCT, + MEDICINE_BALL_THROW_TEST_FAMILY, + METER, + METERS_PER_SECOND, + RES68_DECISION_EXPLOSIVE_TEST_FAMILY, +) +from dynamislm.measurement.observation import ObservationContext, ScientificMeasurementObservation +from dynamislm.measurement.result import ( + CategoricalValue, + MeasurementQuality, + MeasurementResult, + ResultStatus, + ScalarValue, + StructuredOutputReference, + UncertaintyMetadata, + UncertaintyStatus, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ValueOrigin +from dynamislm.provenance.models import ( + EvidenceReference, + LineageEdge, + LineageRelation, + ProcessingRun, + Provenance, +) +from dynamislm.serialization import canonical_hash, canonical_json, register_serializable_type + + +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + +def _require_verified_artifact(artifact: MedicineBallThrowSourceArtifact) -> None: + if not isinstance(artifact, MedicineBallThrowSourceArtifact): + raise ValueError("MBT source artifact must use its typed artifact contract") + if not artifact.immutable or artifact.status is not MBTArtifactStatus.VERIFIED: + raise ValueError("MBT source artifact must be immutable and verified") + + +def _validate_binding( + identity: MedicineBallThrowMeasurementIdentity, + artifact: MedicineBallThrowSourceArtifact, + acquisition: MedicineBallThrowAcquisitionRecord, +) -> None: + if identity.acquisition.raw_artifact != artifact.artifact_id: + raise ValueError("MBT identity raw artifact does not match source artifact") + if identity.acquisition.acquisition_instance_id != acquisition.acquisition_id: + raise ValueError("MBT identity acquisition does not match acquisition record") + if acquisition.source_artifact_id != artifact.artifact_id: + raise ValueError("MBT acquisition does not reference source artifact") + expected = identity.acquisition + if ( + expected.device != acquisition.device + or expected.sensor_channel != acquisition.sensor_channel + or expected.sampling != acquisition.sampling + or expected.calibration_reference != acquisition.calibration_reference + or expected.hardware_firmware != acquisition.hardware_firmware + or expected.provider != acquisition.provider + or expected.sensor_modality != acquisition.sensor_modality + or expected.timebase != acquisition.timebase + or expected.axis_or_frame != acquisition.axis_or_frame + ): + raise ValueError("MBT identity and acquisition record do not describe the same system") + + +def _build_source_observation( + *, + observation_id: InstanceIdentifier, + context: ObservationContext, + identity: MedicineBallThrowMeasurementIdentity, + result: MeasurementResult, + source_artifact: MedicineBallThrowSourceArtifact, + acquisition: MedicineBallThrowAcquisitionRecord, + recorded_at: datetime_module.datetime | None, +) -> ScientificMeasurementObservation: + _require_verified_artifact(source_artifact) + _require_instance(context, ObservationContext, "context") + _require_instance(identity, MedicineBallThrowMeasurementIdentity, "identity") + _require_instance(result, MeasurementResult, "result") + _require_instance(acquisition, MedicineBallThrowAcquisitionRecord, "acquisition") + if observation_id.instance_type != "observation": + raise ValueError("MBT observation ID must identify an observation") + _validate_binding(identity, source_artifact, acquisition) + protocol = identity.semantic.protocol_identity + if protocol is not None: + if protocol.device is not None and protocol.device != acquisition.device: + raise ValueError("MBT protocol device does not match acquisition") + if protocol.provider is not None and protocol.provider != acquisition.provider: + raise ValueError("MBT protocol provider does not match acquisition") + if ( + protocol.sensor_modality is not MBTSensorModality.UNKNOWN + and protocol.sensor_modality is not acquisition.sensor_modality + ): + raise ValueError("MBT protocol modality does not match acquisition") + if protocol.timebase is not None and protocol.timebase != acquisition.timebase: + raise ValueError("MBT protocol timebase does not match acquisition") + run = ProcessingRun( + processing_run_id=InstanceIdentifier( + "processing-run", f"mbt-source:{observation_id.value}" + ), + source_artifact_ids=(source_artifact.artifact_id,), + method=identity.version.processing_method, + parameters=identity.processing.method_parameters, + software_version=identity.version.software_version, + output_entity_id=observation_id, + ) + return ScientificMeasurementObservation( + observation_id=observation_id, + context=context, + identity=identity, + result=result, + provenance=Provenance( + provenance_id=InstanceIdentifier("provenance", observation_id.value), + source_artifacts=(source_artifact,), + acquisitions=(acquisition,), + processing_runs=(run,), + lineage_edges=( + LineageEdge( + source_artifact.artifact_id.qualified, + acquisition.acquisition_id.qualified, + LineageRelation.ACQUIRED_AS, + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run.processing_run_id.qualified, + LineageRelation.PROCESSED_AS, + ), + LineageEdge( + run.processing_run_id.qualified, + observation_id.qualified, + LineageRelation.PRODUCED, + ), + ), + evidence_references=(EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY),), + recorded_at=recorded_at or context.observed_at, + ), + ) + + +def build_mbt_source_distance_observation( + *, + observation_id: InstanceIdentifier, + result_id: InstanceIdentifier, + context: ObservationContext, + protocol_identity: MedicineBallThrowProtocolIdentity, + distance_m: float, + source_artifact: MedicineBallThrowSourceArtifact, + acquisition: MedicineBallThrowAcquisitionRecord, + value_origin: ValueOrigin = ValueOrigin.SOURCE_REPORTED, + method_parameters: tuple[MetadataEntry, ...] = (), + recorded_at: datetime_module.datetime | None = None, +) -> ScientificMeasurementObservation: + """Represent a source-reported/provider-derived exact MBT distance.""" + + _require_instance(protocol_identity, MedicineBallThrowProtocolIdentity, "protocol_identity") + if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("MBT source distance must remain source/provider-origin") + value = _finite(distance_m, "distance_m") + if value < 0: + raise ValueError("distance_m must not be negative") + parameters = ( + MetadataEntry("source_metric", MBT_THROW_DISTANCE_METRIC.stable_id), + *method_parameters, + ) + identity = MedicineBallThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"mbt-distance:{observation_id.value}", + MBT_REGISTRY_VERSION, + ), + semantic=MedicineBallThrowSemanticIdentity( + construct=MEDICINE_BALL_THROW_CONSTRUCT, + test_family=MEDICINE_BALL_THROW_TEST_FAMILY, + protocol=protocol_identity.reference, + measurand=MBT_THROW_DISTANCE_MEASURAND, + metric_definition=MBT_THROW_DISTANCE_METRIC, + protocol_identity=protocol_identity, + ), + acquisition=MedicineBallThrowAcquisitionIdentity( + device=acquisition.device, + raw_artifact=source_artifact.artifact_id, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + acquisition_instance_id=acquisition.acquisition_id, + ), + processing=MedicineBallThrowProcessingIdentity( + registered_operation=MBT_SOURCE_DISTANCE_OPERATION, + method_parameters=parameters, + processing_state=( + MBTProcessingState.RAW_ACQUIRED + if value_origin is ValueOrigin.SOURCE_REPORTED + else MBTProcessingState.PROVIDER_PROCESSED + ), + provider_algorithm=None, + timebase=acquisition.timebase, + ), + version=VersionIdentity( + processing_method=MBT_SOURCE_DISTANCE_OPERATION, + method_registry_version=MBT_REGISTRY_VERSION, + software_version=MBT_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + return _build_source_observation( + observation_id=observation_id, + context=context, + identity=identity, + result=MeasurementResult( + result_id=result_id, + value=ScalarValue(value), + unit=METER, + classification=ScientificClassification(value_origin, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ), + source_artifact=source_artifact, + acquisition=acquisition, + recorded_at=recorded_at, + ) + + +def _trajectory_digest( + trajectory_samples: tuple[tuple[float, float], ...], + velocity_samples: tuple[tuple[float, float], ...], + timebase: MBTTimebase, + coordinate_frame: RegistryReference, +) -> str: + return canonical_hash( + { + "trajectory_samples": trajectory_samples, + "velocity_samples": velocity_samples, + "timebase": timebase, + "coordinate_frame": coordinate_frame, + } + ) + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MBTReleaseVelocityEvidence: + """Qualified trajectory and velocity samples bound to an explicit release event.""" + + observation: ScientificMeasurementObservation + release_event: MBTReleaseEvent + trajectory_samples: tuple[tuple[float, float], ...] + velocity_samples: tuple[tuple[float, float], ...] + timebase: MBTTimebase + coordinate_frame: RegistryReference + velocity_definition: RegistryReference + source_series_digest: str + + def __post_init__(self) -> None: + _require_instance(self.observation, ScientificMeasurementObservation, "observation") + _require_instance(self.release_event, MBTReleaseEvent, "release_event") + _require_instance(self.timebase, MBTTimebase, "timebase") + _require_instance(self.coordinate_frame, RegistryReference, "coordinate_frame") + _require_instance(self.velocity_definition, RegistryReference, "velocity_definition") + if self.observation.observation_id != self.release_event.source_observation_id: + raise ValueError("release event must bind the source observation") + identity = self.observation.identity + if not isinstance(identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT release evidence requires MBT measurement identity") + if ( + identity.semantic.measurand != MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND + or identity.semantic.metric_definition != MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC + ): + raise ValueError("MBT release evidence has the wrong measurand or metric") + if identity.processing.registered_operation != MBT_RELEASE_VELOCITY_SOURCE_OPERATION: + raise ValueError("MBT release evidence has the wrong source operation") + if identity.acquisition.raw_artifact != self.release_event.source_artifact_id: + raise ValueError("MBT release evidence artifact binding is invalid") + if identity.acquisition.acquisition_instance_id != self.release_event.acquisition_id: + raise ValueError("MBT release evidence acquisition binding is invalid") + if identity.acquisition.timebase != self.timebase: + raise ValueError("MBT release evidence timebase is not preserved") + if identity.acquisition.axis_or_frame != self.coordinate_frame: + raise ValueError("MBT release evidence coordinate frame is not preserved") + if identity.acquisition.source_series_digest != self.source_series_digest: + raise ValueError("MBT release evidence series digest is not preserved in identity") + if self.release_event.source_artifact_id not in { + artifact.artifact_id for artifact in self.observation.provenance.source_artifacts + }: + raise ValueError("MBT release event artifact is absent from provenance") + if self.release_event.acquisition_id not in { + acquisition.acquisition_id for acquisition in self.observation.provenance.acquisitions + }: + raise ValueError("MBT release event acquisition is absent from provenance") + if not isinstance(self.trajectory_samples, tuple) or not isinstance( + self.velocity_samples, tuple + ): + raise ValueError("MBT trajectory/velocity samples must be immutable tuples") + if ( + len(self.trajectory_samples) != len(self.velocity_samples) + or not self.trajectory_samples + ): + raise ValueError("MBT trajectory and velocity samples must have equal non-zero length") + for samples, label in ( + (self.trajectory_samples, "trajectory"), + (self.velocity_samples, "velocity"), + ): + normalized = [] + for sample in samples: + if not isinstance(sample, tuple) or len(sample) != 2: + raise ValueError(f"MBT {label} samples must be (time, value) tuples") + normalized.append( + ( + _finite(sample[0], f"{label} sample time"), + _finite(sample[1], f"{label} sample value"), + ) + ) + if any(right[0] <= left[0] for left, right in pairwise(normalized)): + raise ValueError(f"MBT {label} timestamps must increase") + if any( + self.timebase.time_at(index) != sample[0] for index, sample in enumerate(normalized) + ): + raise ValueError(f"MBT {label} timestamps must match timebase") + object.__setattr__(self, f"{label}_samples", tuple(normalized)) + digest = _trajectory_digest( + self.trajectory_samples, self.velocity_samples, self.timebase, self.coordinate_frame + ) + if self.source_series_digest != digest or self.release_event.source_series_digest != digest: + raise ValueError("MBT release evidence has an invalid source-series digest") + index = self.release_event.sample_index + if index >= len(self.velocity_samples): + raise ValueError("MBT release index is outside source samples") + if self.velocity_samples[index][0] != self.release_event.event_time_s: + raise ValueError("MBT release event must equal an exact velocity sample timestamp") + if self.release_event.coordinate_frame != self.coordinate_frame: + raise ValueError("MBT release event frame does not match trajectory evidence") + if self.release_event.release_method != self.velocity_definition: + raise ValueError("MBT release event method does not match velocity definition") + source_artifact = next( + ( + artifact + for artifact in self.observation.provenance.source_artifacts + if artifact.artifact_id == self.release_event.source_artifact_id + ), + None, + ) + if not isinstance(source_artifact, MedicineBallThrowSourceArtifact): + raise ValueError("MBT release evidence must preserve its typed source artifact") + if source_artifact.content_digest != self.source_series_digest: + raise ValueError("MBT release source artifact digest does not match source series") + if self.observation.result.value != StructuredOutputReference( + artifact_id=self.release_event.source_artifact_id, + schema=MBT_RELEASE_VELOCITY_SCHEMA, + ): + raise ValueError( + "MBT release observation must reference the qualified trajectory artifact" + ) + if self.observation.result.unit != METERS_PER_SECOND: + raise ValueError("MBT release observation must use m/s") + if self.observation.result.classification.value_origin not in { + ValueOrigin.SOURCE_REPORTED, + ValueOrigin.PROVIDER_DERIVED, + }: + raise ValueError("MBT release source evidence must remain source/provider-origin") + if self.observation.result.status is not ResultStatus.VALID: + raise ValueError("MBT release source observation must be valid") + + @property + def release_velocity_m_per_s(self) -> float: + return self.velocity_samples[self.release_event.sample_index][1] + + +@register_serializable_type +@dataclass(frozen=True, slots=True) +class MBTSourceQualificationEvidence: + target_observation_id: InstanceIdentifier + qualification_observation: ScientificMeasurementObservation + qualification_reference: RegistryReference + + def __post_init__(self) -> None: + if self.target_observation_id.instance_type != "observation": + raise ValueError("MBT qualification target must identify an observation") + _require_instance( + self.qualification_observation, + ScientificMeasurementObservation, + "qualification_observation", + ) + if self.qualification_reference != MBT_SOURCE_QUALIFICATION_OPERATION: + raise ValueError("MBT qualification reference is not registered") + value = self.qualification_observation.result.value + if not isinstance(value, CategoricalValue): + raise ValueError("MBT qualification must be categorical") + try: + MBTQualificationStatus(value.category) + except ValueError as exc: + raise ValueError("MBT qualification category is not registered") from exc + if self.qualification_observation.result.classification.value_origin not in { + ValueOrigin.SOURCE_REPORTED, + ValueOrigin.PROVIDER_DERIVED, + }: + raise ValueError("MBT qualification must remain source/provider-origin") + if self.qualification_observation.result.status is not ResultStatus.VALID: + raise ValueError("MBT qualification observation must be valid") + identity = self.qualification_observation.identity + if not isinstance(identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT qualification requires MBT identity") + if ( + identity.semantic.measurand != MBT_QUALIFICATION_MEASURAND + or identity.semantic.metric_definition != MBT_QUALIFICATION_METRIC + ): + raise ValueError("MBT qualification has the wrong measurand/metric") + if any( + not isinstance(artifact, MedicineBallThrowSourceArtifact) + or not artifact.immutable + or artifact.status is not MBTArtifactStatus.VERIFIED + for artifact in self.qualification_observation.provenance.source_artifacts + ): + raise ValueError("MBT qualification must preserve verified source artifacts") + parameters = {entry.key: entry.value for entry in identity.processing.method_parameters} + if identity.processing.registered_operation != MBT_SOURCE_QUALIFICATION_OPERATION: + raise ValueError("MBT qualification has the wrong operation") + if parameters.get("target_observation_id") != self.target_observation_id.qualified: + raise ValueError("MBT qualification target is not preserved") + if parameters.get("source_status") != self.status.value: + raise ValueError("MBT qualification status is not preserved") + + @property + def status(self) -> MBTQualificationStatus: + value = self.qualification_observation.result.value + assert isinstance(value, CategoricalValue) + return MBTQualificationStatus(value.category) + + +def build_mbt_release_velocity_evidence( + *, + observation_id: InstanceIdentifier, + result_id: InstanceIdentifier, + context: ObservationContext, + protocol_identity: MedicineBallThrowProtocolIdentity, + release_event: MBTReleaseEvent, + trajectory_samples: tuple[tuple[float, float], ...], + velocity_samples: tuple[tuple[float, float], ...], + timebase: MBTTimebase, + coordinate_frame: RegistryReference, + velocity_definition: RegistryReference, + source_artifact: MedicineBallThrowSourceArtifact, + acquisition: MedicineBallThrowAcquisitionRecord, + value_origin: ValueOrigin = ValueOrigin.PROVIDER_DERIVED, + recorded_at: datetime_module.datetime | None = None, +) -> MBTReleaseVelocityEvidence: + """Create qualified instrumented release evidence from exact samples.""" + + _require_instance(protocol_identity, MedicineBallThrowProtocolIdentity, "protocol_identity") + _require_instance(release_event, MBTReleaseEvent, "release_event") + _require_instance(timebase, MBTTimebase, "timebase") + if release_event.source_observation_id != observation_id: + raise ValueError("MBT release event source observation must match observation_id") + if release_event.source_artifact_id != source_artifact.artifact_id: + raise ValueError("MBT release event artifact must match source artifact") + if release_event.acquisition_id != acquisition.acquisition_id: + raise ValueError("MBT release event acquisition must match acquisition record") + if release_event.release_method != velocity_definition: + raise ValueError("MBT release event method must match velocity definition") + if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("MBT release evidence must remain source/provider-origin") + digest = _trajectory_digest(trajectory_samples, velocity_samples, timebase, coordinate_frame) + if release_event.source_series_digest != digest: + raise ValueError("release event digest does not match exact instrumented samples") + _require_verified_artifact(source_artifact) + if source_artifact.content_digest != digest: + raise ValueError("MBT source artifact digest must match exact trajectory evidence") + _require_instance(acquisition, MedicineBallThrowAcquisitionRecord, "acquisition") + parameters = ( + MetadataEntry("source_series_digest", digest), + MetadataEntry("release_event_id", release_event.event_id.qualified), + MetadataEntry("velocity_definition", velocity_definition.stable_id), + ) + identity = MedicineBallThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"mbt-release:{observation_id.value}", + MBT_REGISTRY_VERSION, + ), + semantic=MedicineBallThrowSemanticIdentity( + construct=MEDICINE_BALL_THROW_CONSTRUCT, + test_family=MEDICINE_BALL_THROW_TEST_FAMILY, + protocol=protocol_identity.reference, + measurand=MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND, + metric_definition=MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC, + protocol_identity=protocol_identity, + ), + acquisition=MedicineBallThrowAcquisitionIdentity( + device=acquisition.device, + raw_artifact=source_artifact.artifact_id, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=timebase, + axis_or_frame=coordinate_frame, + acquisition_instance_id=acquisition.acquisition_id, + source_series_digest=digest, + ), + processing=MedicineBallThrowProcessingIdentity( + registered_operation=MBT_RELEASE_VELOCITY_SOURCE_OPERATION, + method_parameters=parameters, + processing_state=( + MBTProcessingState.RAW_ACQUIRED + if value_origin is ValueOrigin.SOURCE_REPORTED + else MBTProcessingState.PROVIDER_PROCESSED + ), + provider_algorithm=velocity_definition, + timebase=timebase, + ), + version=VersionIdentity( + processing_method=MBT_RELEASE_VELOCITY_SOURCE_OPERATION, + method_registry_version=MBT_REGISTRY_VERSION, + software_version=MBT_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + observation = _build_source_observation( + observation_id=observation_id, + context=context, + identity=identity, + result=MeasurementResult( + result_id=result_id, + value=StructuredOutputReference( + artifact_id=source_artifact.artifact_id, + schema=MBT_RELEASE_VELOCITY_SCHEMA, + ), + unit=METERS_PER_SECOND, + classification=ScientificClassification(value_origin, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ), + source_artifact=source_artifact, + acquisition=acquisition, + recorded_at=recorded_at, + ) + return MBTReleaseVelocityEvidence( + observation=observation, + release_event=release_event, + trajectory_samples=trajectory_samples, + velocity_samples=velocity_samples, + timebase=timebase, + coordinate_frame=coordinate_frame, + velocity_definition=velocity_definition, + source_series_digest=digest, + ) + + +def build_mbt_qualification_source_observation( + *, + target_observation: ScientificMeasurementObservation, + reported_status: MBTQualificationStatus, + source_artifact: MedicineBallThrowSourceArtifact, + acquisition: MedicineBallThrowAcquisitionRecord, + value_origin: ValueOrigin = ValueOrigin.SOURCE_REPORTED, + output_observation_id: InstanceIdentifier | None = None, + reason_codes: tuple[str, ...] = (), + recorded_at: datetime_module.datetime | None = None, +) -> ScientificMeasurementObservation: + _require_instance(target_observation, ScientificMeasurementObservation, "target_observation") + _require_enum(reported_status, MBTQualificationStatus, "reported_status") + if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("MBT qualification source origin is invalid") + _require_verified_artifact(source_artifact) + _require_instance(acquisition, MedicineBallThrowAcquisitionRecord, "acquisition") + target_identity = target_observation.identity + if not isinstance(target_identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT target requires MBT identity") + observation_id = output_observation_id or InstanceIdentifier( + "observation", f"mbt-qualification:{target_observation.observation_id.value}" + ) + parameters = ( + MetadataEntry("target_observation_id", target_observation.observation_id.qualified), + MetadataEntry("source_status", reported_status.value), + MetadataEntry("reason_codes", canonical_json(reason_codes)), + ) + identity = MedicineBallThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "dynamislm", + "measurement-identity", + f"mbt-qualification:{observation_id.value}", + MBT_REGISTRY_VERSION, + ), + semantic=MedicineBallThrowSemanticIdentity( + construct=target_identity.semantic.construct, + test_family=MEDICINE_BALL_THROW_TEST_FAMILY, + protocol=target_identity.semantic.protocol, + measurand=MBT_QUALIFICATION_MEASURAND, + metric_definition=MBT_QUALIFICATION_METRIC, + protocol_identity=target_identity.semantic.protocol_identity, + ), + acquisition=MedicineBallThrowAcquisitionIdentity( + device=acquisition.device, + raw_artifact=source_artifact.artifact_id, + sensor_channel=acquisition.sensor_channel, + sampling=acquisition.sampling, + calibration_reference=acquisition.calibration_reference, + hardware_firmware=acquisition.hardware_firmware, + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=acquisition.timebase, + axis_or_frame=acquisition.axis_or_frame, + acquisition_instance_id=acquisition.acquisition_id, + ), + processing=MedicineBallThrowProcessingIdentity( + registered_operation=MBT_SOURCE_QUALIFICATION_OPERATION, + method_parameters=parameters, + processing_state=( + MBTProcessingState.RAW_ACQUIRED + if value_origin is ValueOrigin.SOURCE_REPORTED + else MBTProcessingState.PROVIDER_PROCESSED + ), + timebase=acquisition.timebase, + ), + version=VersionIdentity( + processing_method=MBT_SOURCE_QUALIFICATION_OPERATION, + method_registry_version=MBT_REGISTRY_VERSION, + software_version=MBT_SOFTWARE_VERSION, + hardware_firmware=acquisition.hardware_firmware, + ), + ) + return _build_source_observation( + observation_id=observation_id, + context=target_observation.context, + identity=identity, + result=MeasurementResult( + result_id=InstanceIdentifier("result", f"mbt-qualification:{observation_id.value}"), + value=CategoricalValue(reported_status.value), + unit=None, + classification=ScientificClassification(value_origin, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(status=UncertaintyStatus.NOT_ASSESSED), + status=ResultStatus.VALID, + ), + source_artifact=source_artifact, + acquisition=acquisition, + recorded_at=recorded_at, + ) + + +def normalize_mbt_qualification( + source_observation: ScientificMeasurementObservation, + target_observation: ScientificMeasurementObservation, +) -> MBTSourceQualificationEvidence: + _require_instance(source_observation, ScientificMeasurementObservation, "source_observation") + _require_instance(target_observation, ScientificMeasurementObservation, "target_observation") + if source_observation.context != target_observation.context: + raise ValueError("MBT qualification context does not match target") + source_identity = source_observation.identity + target_identity = target_observation.identity + if not isinstance(source_identity, MedicineBallThrowMeasurementIdentity) or not isinstance( + target_identity, MedicineBallThrowMeasurementIdentity + ): + raise ValueError("MBT qualification and target require MBT identities") + if source_identity.semantic.protocol_identity != target_identity.semantic.protocol_identity: + raise ValueError("MBT qualification protocol does not match target") + target_artifacts = { + item.artifact_id: item for item in target_observation.provenance.source_artifacts + } + source_artifacts = { + item.artifact_id: item for item in source_observation.provenance.source_artifacts + } + if not source_artifacts or not set(source_artifacts).issubset(target_artifacts): + raise ValueError("MBT qualification artifacts are not bound to target") + if any(target_artifacts[key] != value for key, value in source_artifacts.items()): + raise ValueError("MBT qualification artifact content was tampered") + target_acquisitions = { + item.acquisition_id: item for item in target_observation.provenance.acquisitions + } + source_acquisitions = { + item.acquisition_id: item for item in source_observation.provenance.acquisitions + } + if not source_acquisitions or not set(source_acquisitions).issubset(target_acquisitions): + raise ValueError("MBT qualification acquisitions are not bound to target") + if any(target_acquisitions[key] != value for key, value in source_acquisitions.items()): + raise ValueError("MBT qualification acquisition content was tampered") + evidence = MBTSourceQualificationEvidence( + target_observation_id=target_observation.observation_id, + qualification_observation=source_observation, + qualification_reference=MBT_SOURCE_QUALIFICATION_OPERATION, + ) + if evidence.status is MBTQualificationStatus.UNKNOWN: + raise ValueError("MBT source qualification must report QUALIFIED or REJECTED") + return evidence + + +def require_qualified_mbt( + evidence: MBTSourceQualificationEvidence, + target_observation: ScientificMeasurementObservation, +) -> None: + if not isinstance(evidence, MBTSourceQualificationEvidence): + raise ValueError("typed MBT source qualification evidence is required") + normalized = normalize_mbt_qualification(evidence.qualification_observation, target_observation) + if normalized != evidence: + raise ValueError("MBT qualification evidence was rebound or tampered") + if evidence.status is not MBTQualificationStatus.QUALIFIED: + raise ValueError("MBT source observation is not qualified") + + +__all__ = [ + "MBTReleaseVelocityEvidence", + "MBTSourceQualificationEvidence", + "build_mbt_qualification_source_observation", + "build_mbt_release_velocity_evidence", + "build_mbt_source_distance_observation", + "normalize_mbt_qualification", + "require_qualified_mbt", +] diff --git a/src/dynamislm/measurement/medicine_ball_throw/registry.py b/src/dynamislm/measurement/medicine_ball_throw/registry.py new file mode 100644 index 0000000..edd6e59 --- /dev/null +++ b/src/dynamislm/measurement/medicine_ball_throw/registry.py @@ -0,0 +1,139 @@ +"""Explicit, family-scoped registry identities for RES-68 medicine-ball throws.""" + +from __future__ import annotations + +from dynamislm.measurement.identity import RegistryReference, ScientificIdentifier, UnitReference + +MBT_REGISTRY_VERSION = "1.0.0" +MBT_SOFTWARE_VERSION = "dynamislm-res68-medicine-ball-throw-1.0.0" + + +def _reference(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference( + identifier=ScientificIdentifier("dynamislm", object_type, key, MBT_REGISTRY_VERSION), + display_label=label, + ) + + +def _unit(key: str, label: str) -> UnitReference: + return UnitReference( + identifier=ScientificIdentifier("dynamislm", "unit", key, MBT_REGISTRY_VERSION), + display_label=label, + ) + + +RES68_DECISION_EXPLOSIVE_TEST_FAMILY = RegistryReference( + identifier=ScientificIdentifier( + "dynamislm", "decision-record", "res68-explosive-test-family-closure", MBT_REGISTRY_VERSION + ), + display_label="RES-68 explosive-test family closure decision", + reference_ids=("docs/decisions/RES68-DR-001-explosive-test-family-closure.md",), +) + +MEDICINE_BALL_THROW_TEST_FAMILY = _reference( + "test-family", "medicine-ball-throw", "Medicine-ball throw testing" +) +MEDICINE_BALL_THROW_CONSTRUCT = _reference( + "construct", "medicine-ball-throw-performance", "Medicine-ball throw performance construct" +) +MEDICINE_BALL_THROW_PROTOCOL_V1 = _reference( + "protocol", + "medicine-ball-throw-explicit-identity-v1", + "Medicine-ball throw explicit protocol identity V1", +) +MBT_SEATED_CHEST_PROTOCOL = _reference( + "protocol-variant", "medicine-ball-seated-chest-v1", "Seated chest medicine-ball throw" +) +MBT_STANDING_CHEST_PROTOCOL = _reference( + "protocol-variant", "medicine-ball-standing-chest-v1", "Standing chest medicine-ball throw" +) +MBT_BACKWARD_OVERHEAD_PROTOCOL = _reference( + "protocol-variant", + "medicine-ball-backward-overhead-v1", + "Backward overhead medicine-ball throw", +) +MBT_ROTATIONAL_PROTOCOL = _reference( + "protocol-variant", "medicine-ball-rotational-v1", "Rotational medicine-ball throw" +) +MBT_SUPINE_PROTOCOL = _reference( + "protocol-variant", "medicine-ball-supine-v1", "Supine medicine-ball throw" +) +MBT_PUSH_PRESS_PROTOCOL = _reference( + "protocol-variant", "medicine-ball-push-press-v1", "Medicine-ball push press" +) + +MBT_SOURCE_DISTANCE_OPERATION = _reference( + "processing-method", "medicine-ball-throw-source-distance-v1", "MBT source distance V1" +) +MBT_DISTANCE_FROM_REGISTERED_COORDINATES_OPERATION = _reference( + "registered-operation", + "medicine-ball-throw-distance-from-registered-coordinates-v1", + "MBT distance from registered coordinates", +) +MBT_RELEASE_VELOCITY_SOURCE_OPERATION = _reference( + "processing-method", + "medicine-ball-throw-release-velocity-source-v1", + "MBT release-velocity source V1", +) +MBT_INSTRUMENTED_RELEASE_VELOCITY_OPERATION = _reference( + "registered-operation", + "medicine-ball-throw-instrumented-release-velocity-v1", + "MBT instrumented release velocity", +) +MBT_SOURCE_QUALIFICATION_OPERATION = _reference( + "source-adjudication-rule", + "medicine-ball-throw-source-qualification-v1", + "MBT source qualification V1", +) +MBT_QUALIFICATION_MEASURAND = _reference( + "measurand", "medicine-ball-throw-source-qualification", "MBT source qualification status" +) +MBT_QUALIFICATION_METRIC = _reference( + "metric", "medicine-ball-throw-source-qualification", "MBT source qualification status" +) +MBT_DISTANCE_SCHEMA = _reference("schema", "medicine-ball-throw-distance-v1", "MBT distance V1") +MBT_RELEASE_VELOCITY_SCHEMA = _reference( + "schema", "medicine-ball-throw-release-velocity-v1", "MBT release velocity V1" +) + +MBT_THROW_DISTANCE_MEASURAND = _reference( + "measurand", "medicine-ball-throw-distance", "Medicine-ball throw distance" +) +MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND = _reference( + "measurand", + "medicine-ball-instrumented-release-velocity", + "Instrumented medicine-ball release velocity", +) +MBT_THROW_DISTANCE_METRIC = _reference( + "metric", "medicine-ball-throw-distance", "Medicine-ball throw distance" +) +MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC = _reference( + "metric", + "medicine-ball-instrumented-release-velocity", + "Instrumented medicine-ball release velocity", +) + +MBT_DISTANCE_COMPARABILITY_RULE = _reference( + "comparability-rule", + "medicine-ball-throw-distance-identity-v1", + "MBT distance claim-relative comparability", +) +MBT_RELEASE_VELOCITY_COMPARABILITY_RULE = _reference( + "comparability-rule", + "medicine-ball-release-velocity-identity-v1", + "MBT release-velocity comparability", +) +MBT_DEVICE_BRIDGE_RULE = _reference( + "bridge-rule", "medicine-ball-throw-device-agreement-v1", "MBT device agreement bridge" +) + +METER = _unit("meter", "m") +METERS_PER_SECOND = _unit("meter-per-second", "m/s") +KILOGRAM = _unit("kilogram", "kg") +SECOND = _unit("second", "s") + +MBT_THROW_DISTANCE = MBT_DISTANCE_FROM_REGISTERED_COORDINATES_OPERATION +MBT_INSTRUMENTED_RELEASE_VELOCITY = MBT_INSTRUMENTED_RELEASE_VELOCITY_OPERATION + + +__all__ = [name for name, value in globals().items() if name.isupper() and not name.startswith("_")] diff --git a/tests/test_explosive_test_families.py b/tests/test_explosive_test_families.py new file mode 100644 index 0000000..a66658e --- /dev/null +++ b/tests/test_explosive_test_families.py @@ -0,0 +1,1057 @@ +"""Synthetic RES-68 contract tests; no empirical source bytes are committed.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime +from typing import cast + +import pytest + +from dynamislm.comparability.models import ComparabilityState +from dynamislm.measurement.bench_press_throw import ( + BenchPressThrowAcquisitionIdentity, + BenchPressThrowAcquisitionRecord, + BenchPressThrowMeasurementIdentity, + BenchPressThrowMetricResult, + BenchPressThrowMetricSupport, + BenchPressThrowProcessingIdentity, + BenchPressThrowProtocolIdentity, + BenchPressThrowSemanticIdentity, + BenchPressThrowSourceArtifact, + BenchPressThrowVelocitySeries, + BenchPressThrowVelocitySeriesEvidence, + BPTArtifactHashScope, + BPTArtifactStatus, + BPTCatchSemantics, + BPTCounterbalanceStatus, + BPTLoadIdentity, + BPTLoadKind, + BPTMachineType, + BPTMovementPattern, + BPTPauseTouchBounce, + BPTProcessingState, + BPTProviderMetricResult, + BPTQualificationStatus, + BPTReleaseSemantics, + BPTSensorModality, + BPTSourceQualificationEvidence, + BPTTimebase, + BPTTimebaseKind, + build_bpt_provider_metric_observation, + build_bpt_qualification_source_observation, + calculate_bpt_dynamislm_time_weighted_mean_bar_velocity, + calculate_bpt_load_times_velocity_power, + calculate_bpt_mean_power, + calculate_bpt_mean_propulsive_velocity, + calculate_bpt_sampled_maximum_bar_velocity, + compare_bpt_metric_results, + create_bpt_velocity_series_evidence, + normalize_bpt_qualification, + wrap_bpt_provider_metric, +) +from dynamislm.measurement.bench_press_throw.registry import ( + BPT_BAR_VELOCITY_MEASURAND, + BPT_CONSTRUCT, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_REGISTRY_VERSION, + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_SOURCE_VELOCITY_SERIES_OPERATION, + BPT_TEST_FAMILY, + BPT_VELOCITY_SERIES_METRIC, + KILOGRAM, + METER_PER_SECOND, +) +from dynamislm.measurement.cmj.weighing import STANDARD_GRAVITY +from dynamislm.measurement.drop_jump import ( + DropJumpAcquisitionIdentity, + DropJumpAcquisitionRecord, + DropJumpArmCondition, + DropJumpArtifactHashScope, + DropJumpArtifactStatus, + DropJumpEventDetectorParameters, + DropJumpEventLabel, + DropJumpEventOccurrence, + DropJumpFlightTimeApplicability, + DropJumpInitiationMode, + DropJumpMeasurementIdentity, + DropJumpMetricResult, + DropJumpProcessingIdentity, + DropJumpProcessingState, + DropJumpProtocolIdentity, + DropJumpQualificationStatus, + DropJumpReboundStrategy, + DropJumpSemanticIdentity, + DropJumpSensorModality, + DropJumpSourceArtifact, + DropJumpSourceQualificationEvidence, + DropJumpTimebase, + DropJumpTimebaseKind, + build_drop_jump_event_occurrence, + build_drop_jump_qualification_source_observation, + build_drop_jump_source_observation, + calculate_drop_jump_contact_time, + calculate_drop_jump_flight_time_jump_height, + calculate_drop_jump_rebound_flight_time, + calculate_drop_jump_rsi_jh_ct, + calculate_drop_jump_rsr_ft_ct, + compare_drop_jump_metric_results, + normalize_drop_jump_qualification, + validate_drop_jump_event_order, +) +from dynamislm.measurement.drop_jump.registry import ( + DROP_JUMP_CONSTRUCT, + DROP_JUMP_EVENT_SCHEMA, + DROP_JUMP_PROTOCOL_V1, + DROP_JUMP_SOURCE_OBSERVATION_OPERATION, + DROP_JUMP_SOURCE_SERIES_MEASURAND, + DROP_JUMP_SOURCE_SERIES_METRIC, + DROP_JUMP_TEST_FAMILY, +) +from dynamislm.measurement.identity import ( + InstanceIdentifier, + MetadataEntry, + RegistryReference, + ScientificIdentifier, + SignConvention, + VersionIdentity, +) +from dynamislm.measurement.medicine_ball_throw import ( + MBTAllowedState, + MBTArtifactHashScope, + MBTArtifactStatus, + MBTBodyPosture, + MBTCoordinateEvidence, + MBTQualificationStatus, + MBTReleaseEvent, + MBTReleaseSemantics, + MBTReleaseVelocityEvidence, + MBTSensorModality, + MBTSourceQualificationEvidence, + MBTSupportCondition, + MBTThrowVariant, + MBTTimebase, + MBTTimebaseKind, + MedicineBallThrowAcquisitionRecord, + MedicineBallThrowMetricResult, + MedicineBallThrowProtocolIdentity, + MedicineBallThrowSourceArtifact, + build_mbt_qualification_source_observation, + build_mbt_release_velocity_evidence, + build_mbt_source_distance_observation, + calculate_mbt_distance_as_power, + calculate_mbt_instrumented_release_velocity, + calculate_mbt_release_velocity_from_distance, + calculate_mbt_throw_distance, + compare_mbt_metric_results, + normalize_mbt_qualification, +) +from dynamislm.measurement.observation import ObservationContext, ScientificMeasurementObservation +from dynamislm.measurement.result import ( + MeasurementQuality, + MeasurementResult, + StructuredOutputReference, + UncertaintyMetadata, +) +from dynamislm.measurement.taxonomy import ScientificClassification, ValueOrigin +from dynamislm.refusal.models import RefusalResult +from dynamislm.serialization import ( + SERIALIZATION_VERSION, + canonical_hash, + canonical_json, + from_canonical_json, +) + + +def _ref(object_type: str, key: str, label: str) -> RegistryReference: + return RegistryReference(ScientificIdentifier("synthetic", object_type, key, "1.0.0"), label) + + +def _context(prefix: str) -> ObservationContext: + return ObservationContext( + context_id=InstanceIdentifier("context", prefix), + athlete_id=InstanceIdentifier("athlete", "synthetic-athlete"), + session_id=InstanceIdentifier("session", "synthetic-session"), + test_instance_id=InstanceIdentifier("test-instance", prefix), + trial_id=InstanceIdentifier("trial", prefix), + observed_at=datetime(2026, 1, 1, tzinfo=UTC), + population_context="synthetic-only trained adult team-sport athlete", + ) + + +def _dj_result(value: DropJumpMetricResult | RefusalResult) -> DropJumpMetricResult: + assert isinstance(value, DropJumpMetricResult) + return value + + +def _bpt_result(value: BenchPressThrowMetricResult | RefusalResult) -> BenchPressThrowMetricResult: + assert isinstance(value, BenchPressThrowMetricResult) + return value + + +def _bpt_provider_result(value: object) -> BPTProviderMetricResult: + assert isinstance(value, BPTProviderMetricResult) + return value + + +def _mbt_result( + value: MedicineBallThrowMetricResult | RefusalResult, +) -> MedicineBallThrowMetricResult: + assert isinstance(value, MedicineBallThrowMetricResult) + return value + + +def _refused(value: object) -> RefusalResult: + assert isinstance(value, RefusalResult) + return value + + +def _dj_fixture( + *, + actual_drop_height_m: float | None = None, + arms: DropJumpArmCondition = DropJumpArmCondition.PROHIBITED, + prefix: str = "synthetic-dj", +) -> tuple[ + ScientificMeasurementObservation, + dict[DropJumpEventLabel, DropJumpEventOccurrence], + DropJumpFlightTimeApplicability, +]: + timebase = DropJumpTimebase(DropJumpTimebaseKind.REGULAR, sample_rate_hz=1000.0) + samples = tuple(0.0 for _ in range(1000)) + series_digest = canonical_hash({"samples": samples, "timebase": timebase, "channel": "force"}) + artifact = DropJumpSourceArtifact( + artifact_id=InstanceIdentifier("artifact", prefix), + content_digest=series_digest, + media_type="application/vnd.synthetic.drop-jump", + hash_scope=DropJumpArtifactHashScope.CANONICAL_SERIES_REPRESENTATION, + status=DropJumpArtifactStatus.VERIFIED, + ) + acquisition_id = InstanceIdentifier("acquisition", prefix) + device = _ref("device", "synthetic-force-platform", "Synthetic force platform") + acquisition = DropJumpAcquisitionRecord( + acquisition_id=acquisition_id, + device=device, + source_artifact_id=artifact.artifact_id, + sensor_channel="vertical-force", + provider="synthetic-provider", + sensor_modality=DropJumpSensorModality.FORCE_PLATFORM, + timebase=timebase, + ) + protocol = DropJumpProtocolIdentity( + reference=DROP_JUMP_PROTOCOL_V1, + protocol_version="1.0.0", + nominal_box_height_m=0.30, + actual_drop_height_m=actual_drop_height_m, + actual_drop_height_method=( + _ref( + "measurement-method", + "synthetic-drop-height", + "Synthetic independent drop-height method", + ) + if actual_drop_height_m is not None + else None + ), + initiation_mode=DropJumpInitiationMode.STEP_OFF, + pre_drop_posture="quiet standing on box", + pre_drop_stillness=True, + step_off_leg="RIGHT", + arms_condition=arms, + rebound_strategy=DropJumpReboundStrategy.COMBINED, + explicit_cue="step off, rebound immediately", + support_platform_configuration="single synthetic force platform", + landing_rebound_technique="continuous rebound; controlled subsequent landing", + pause_continuity_semantics="no pause between touchdown and rebound takeoff", + ) + identity = DropJumpMeasurementIdentity( + identity_id=ScientificIdentifier("synthetic", "measurement-identity", "dj-source", "1.0.0"), + semantic=DropJumpSemanticIdentity( + construct=DROP_JUMP_CONSTRUCT, + test_family=DROP_JUMP_TEST_FAMILY, + protocol=protocol.reference, + measurand=DROP_JUMP_SOURCE_SERIES_MEASURAND, + metric_definition=DROP_JUMP_SOURCE_SERIES_METRIC, + protocol_identity=protocol, + ), + acquisition=DropJumpAcquisitionIdentity( + device=device, + raw_artifact=artifact.artifact_id, + acquisition_instance_id=acquisition_id, + sensor_channel="vertical-force", + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=timebase, + source_series_digest=series_digest, + ), + processing=DropJumpProcessingIdentity( + registered_operation=DROP_JUMP_SOURCE_OBSERVATION_OPERATION, + timebase=timebase, + processing_state=DropJumpProcessingState.RAW_ACQUIRED, + ), + version=VersionIdentity( + processing_method=DROP_JUMP_SOURCE_OBSERVATION_OPERATION, + method_registry_version="1.0.0", + software_version="synthetic-source-v1", + ), + ) + context = _context(prefix) + observation_id = InstanceIdentifier("observation", prefix) + source = build_drop_jump_source_observation( + observation_id=observation_id, + context=context, + identity=identity, + result=MeasurementResult( + result_id=InstanceIdentifier("result", prefix), + value=StructuredOutputReference(artifact.artifact_id, DROP_JUMP_EVENT_SCHEMA), + unit=None, + classification=ScientificClassification(ValueOrigin.SOURCE_REPORTED, ()), + quality=MeasurementQuality(), + uncertainty=UncertaintyMetadata(), + ), + source_artifact=artifact, + acquisition=acquisition, + ) + events = { + label: build_drop_jump_event_occurrence( + source_observation=source, + label=label, + sample_index=index, + source_sample_count=len(samples), + source_series_digest=series_digest, + detector_parameters=DropJumpEventDetectorParameters( + (MetadataEntry("threshold_semantics", "exact source detector output"),) + ), + ) + for label, index in ( + (DropJumpEventLabel.TOUCHDOWN, 100), + (DropJumpEventLabel.REBOUND_TAKEOFF, 250), + (DropJumpEventLabel.SUBSEQUENT_LANDING, 750), + ) + } + applicability = DropJumpFlightTimeApplicability( + source_observation_id=source.observation_id, + rebound_takeoff_event_id=events[DropJumpEventLabel.REBOUND_TAKEOFF].occurrence_id, + subsequent_landing_event_id=events[DropJumpEventLabel.SUBSEQUENT_LANDING].occurrence_id, + ballistic_vertical_motion=True, + takeoff_landing_height_equivalence=True, + negligible_air_resistance=True, + ) + return source, events, applicability + + +def test_dj_gold_metrics_and_identity_separation() -> None: + source, events, applicability = _dj_fixture() + contact = _dj_result( + calculate_drop_jump_contact_time( + events[DropJumpEventLabel.TOUCHDOWN], + events[DropJumpEventLabel.REBOUND_TAKEOFF], + source, + ) + ) + flight = _dj_result( + calculate_drop_jump_rebound_flight_time( + events[DropJumpEventLabel.REBOUND_TAKEOFF], + events[DropJumpEventLabel.SUBSEQUENT_LANDING], + source, + ) + ) + height = _dj_result( + calculate_drop_jump_flight_time_jump_height( + events[DropJumpEventLabel.REBOUND_TAKEOFF], + events[DropJumpEventLabel.SUBSEQUENT_LANDING], + STANDARD_GRAVITY, + source, + applicability, + ) + ) + assert contact.value_s == pytest.approx(0.15) + assert flight.value_s == pytest.approx(0.5) + assert height.value_m == pytest.approx(9.80665 * 0.5**2 / 8.0) + rsi = _dj_result(calculate_drop_jump_rsi_jh_ct(height, contact)) + rsr = _dj_result(calculate_drop_jump_rsr_ft_ct(flight, contact)) + assert rsi.value_m_per_s == pytest.approx(height.value_m / 0.15) + assert rsr.value_ratio == pytest.approx(0.5 / 0.15) + assert rsi.metric != rsr.metric + assert ( + rsi.observation.identity.semantic.measurand != rsr.observation.identity.semantic.measurand + ) + + +def test_dj_unknown_actual_height_does_not_poison_rebound_metrics() -> None: + source, events, applicability = _dj_fixture() + contact = _dj_result( + calculate_drop_jump_contact_time( + events[DropJumpEventLabel.TOUCHDOWN], events[DropJumpEventLabel.REBOUND_TAKEOFF], source + ) + ) + height = _dj_result( + calculate_drop_jump_flight_time_jump_height( + events[DropJumpEventLabel.REBOUND_TAKEOFF], + events[DropJumpEventLabel.SUBSEQUENT_LANDING], + STANDARD_GRAVITY, + source, + applicability, + ) + ) + assert contact.value_s == pytest.approx(0.15) + assert height.value_m > 0 + assert isinstance(source.identity, DropJumpMeasurementIdentity) + assert source.identity.semantic.protocol_identity is not None + assert source.identity.semantic.protocol_identity.actual_drop_height_m is None + + +def test_dj_nominal_actual_height_and_claim_relative_comparability() -> None: + source_a, events_a, applicability_a = _dj_fixture( + actual_drop_height_m=0.27, prefix="synthetic-dj-a" + ) + source_b, events_b, applicability_b = _dj_fixture( + actual_drop_height_m=0.35, prefix="synthetic-dj-b" + ) + height_a = _dj_result( + calculate_drop_jump_flight_time_jump_height( + events_a[DropJumpEventLabel.REBOUND_TAKEOFF], + events_a[DropJumpEventLabel.SUBSEQUENT_LANDING], + STANDARD_GRAVITY, + source_a, + applicability_a, + ) + ) + height_b = _dj_result( + calculate_drop_jump_flight_time_jump_height( + events_b[DropJumpEventLabel.REBOUND_TAKEOFF], + events_b[DropJumpEventLabel.SUBSEQUENT_LANDING], + STANDARD_GRAVITY, + source_b, + applicability_b, + ) + ) + assert isinstance(source_a.identity, DropJumpMeasurementIdentity) + assert source_a.identity.semantic.protocol_identity is not None + assert ( + source_a.identity.semantic.protocol_identity.nominal_box_height_m + != source_a.identity.semantic.protocol_identity.actual_drop_height_m + ) + assert ( + compare_drop_jump_metric_results(height_a, height_b).state + is ComparabilityState.BRIDGE_VALIDATION_REQUIRED + ) + source_unknown, events_unknown, _ = _dj_fixture(prefix="synthetic-dj-unknown") + contact_unknown = _dj_result( + calculate_drop_jump_contact_time( + events_unknown[DropJumpEventLabel.TOUCHDOWN], + events_unknown[DropJumpEventLabel.REBOUND_TAKEOFF], + source_unknown, + ) + ) + assert contact_unknown.value_s == pytest.approx(0.15) + source_unknown_b, events_unknown_b, _ = _dj_fixture(prefix="synthetic-dj-unknown-b") + contact_unknown_b = _dj_result( + calculate_drop_jump_contact_time( + events_unknown_b[DropJumpEventLabel.TOUCHDOWN], + events_unknown_b[DropJumpEventLabel.REBOUND_TAKEOFF], + source_unknown_b, + ) + ) + assert ( + compare_drop_jump_metric_results(contact_unknown, contact_unknown_b).state + is ComparabilityState.COMPARABLE + ) + assert ( + compare_drop_jump_metric_results( + contact_unknown, + contact_unknown_b, + "compare actual drop exposure", + ).state + is ComparabilityState.INSUFFICIENT_INFORMATION + ) + + +def test_dj_event_order_source_and_missing_landing_fail_closed() -> None: + source, events, _ = _dj_fixture() + ordered = ( + events[DropJumpEventLabel.TOUCHDOWN], + events[DropJumpEventLabel.REBOUND_TAKEOFF], + events[DropJumpEventLabel.SUBSEQUENT_LANDING], + ) + assert validate_drop_jump_event_order(ordered) is None + assert validate_drop_jump_event_order(tuple(reversed(ordered))) is not None + contact = _dj_result(calculate_drop_jump_contact_time(ordered[0], ordered[1], source)) + missing_landing = _refused( + calculate_drop_jump_flight_time_jump_height( + ordered[1], + cast(DropJumpEventOccurrence, None), + STANDARD_GRAVITY, + source, + None, + ) + ) + assert contact.value_s == pytest.approx(0.15) + assert missing_landing.blocks_claim + assert "DJ flight-time ballistic jump height" in missing_landing.blocked_claim + assert _refused( + calculate_drop_jump_contact_time( + ordered[0], ordered[1], cast(ScientificMeasurementObservation, object()) + ) + ).blocks_claim + assert _refused( + calculate_drop_jump_contact_time( + cast(DropJumpEventOccurrence, "not-events"), ordered[1], source + ) + ).blocks_claim + + +def test_dj_cmj_event_is_not_accepted_and_v3_roundtrip_is_stable() -> None: + source, events, applicability = _dj_fixture() + encoded = canonical_json(events[DropJumpEventLabel.TOUCHDOWN]) + decoded = from_canonical_json(encoded, type(events[DropJumpEventLabel.TOUCHDOWN])) + assert decoded == events[DropJumpEventLabel.TOUCHDOWN] + assert canonical_hash(decoded) == canonical_hash(events[DropJumpEventLabel.TOUCHDOWN]) + assert SERIALIZATION_VERSION == 3 + assert _refused( + calculate_drop_jump_flight_time_jump_height( + cast(DropJumpEventOccurrence, object()), + events[DropJumpEventLabel.SUBSEQUENT_LANDING], + STANDARD_GRAVITY, + source, + applicability, + ) + ).blocks_claim + + +def test_source_qualification_is_upstream_typed_and_cannot_be_minted() -> None: + source, events, _ = _dj_fixture(prefix="synthetic-dj-qualified") + dj_artifact = next( + artifact + for artifact in source.provenance.source_artifacts + if isinstance(artifact, DropJumpSourceArtifact) + ) + dj_acquisition = next( + acquisition + for acquisition in source.provenance.acquisitions + if isinstance(acquisition, DropJumpAcquisitionRecord) + ) + dj_qualification_source = build_drop_jump_qualification_source_observation( + target_observation=source, + reported_status=DropJumpQualificationStatus.QUALIFIED, + source_artifact=dj_artifact, + acquisition=dj_acquisition, + ) + dj_qualification = normalize_drop_jump_qualification(dj_qualification_source, source) + assert dj_qualification.status is DropJumpQualificationStatus.QUALIFIED + contact = _dj_result( + calculate_drop_jump_contact_time( + events[DropJumpEventLabel.TOUCHDOWN], + events[DropJumpEventLabel.REBOUND_TAKEOFF], + source, + dj_qualification, + ) + ) + assert dj_qualification_source.observation_id in { + item.observation_id for item in contact.source_observations + } + assert _refused( + calculate_drop_jump_contact_time( + events[DropJumpEventLabel.TOUCHDOWN], + events[DropJumpEventLabel.REBOUND_TAKEOFF], + source, + cast(DropJumpSourceQualificationEvidence, True), + ) + ).blocks_claim + + bpt_evidence, bpt_protocol, _, _, bpt_acquisition, bpt_artifact = _bpt_fixture() + bpt_qualification_source = build_bpt_qualification_source_observation( + target_observation=bpt_evidence.observation, + reported_status=BPTQualificationStatus.QUALIFIED, + source_artifact=bpt_artifact, + acquisition=bpt_acquisition, + ) + bpt_qualification = normalize_bpt_qualification( + bpt_qualification_source, bpt_evidence.observation + ) + assert bpt_qualification.status is BPTQualificationStatus.QUALIFIED + assert _bpt_result( + calculate_bpt_sampled_maximum_bar_velocity(bpt_evidence, bpt_qualification) + ).value_m_per_s == pytest.approx(1.0) + assert _refused( + calculate_bpt_sampled_maximum_bar_velocity( + bpt_evidence, + cast(BPTSourceQualificationEvidence, True), + ) + ).blocks_claim + assert bpt_protocol.test_family == BPT_TEST_FAMILY + + mbt_source, mbt_coordinate, _, mbt_artifact, mbt_acquisition, _ = _mbt_distance_fixture( + prefix="synthetic-mbt-qualified" + ) + mbt_qualification_source = build_mbt_qualification_source_observation( + target_observation=mbt_source, + reported_status=MBTQualificationStatus.QUALIFIED, + source_artifact=mbt_artifact, + acquisition=mbt_acquisition, + ) + mbt_qualification = normalize_mbt_qualification(mbt_qualification_source, mbt_source) + assert mbt_qualification.status is MBTQualificationStatus.QUALIFIED + assert _mbt_result( + calculate_mbt_throw_distance(mbt_coordinate, mbt_source, mbt_qualification) + ).value_m == pytest.approx(4.5) + assert _refused( + calculate_mbt_throw_distance( + mbt_coordinate, + mbt_source, + cast(MBTSourceQualificationEvidence, True), + ) + ).blocks_claim + + +def _bpt_fixture( + *, + movement: BPTMovementPattern = BPTMovementPattern.CONCENTRIC_ONLY, + prefix: str = "synthetic-bpt", + explicit_timebase: bool = False, + counterbalance_status: BPTCounterbalanceStatus = BPTCounterbalanceStatus.ABSENT, +) -> tuple[ + BenchPressThrowVelocitySeriesEvidence, + BenchPressThrowProtocolIdentity, + BenchPressThrowMetricSupport, + str, + BenchPressThrowAcquisitionRecord, + BenchPressThrowSourceArtifact, +]: + timebase = ( + BPTTimebase(BPTTimebaseKind.EXPLICIT, times_s=(0.0, 0.1, 0.35, 0.5, 0.9)) + if explicit_timebase + else BPTTimebase(BPTTimebaseKind.REGULAR, sample_rate_hz=10.0) + ) + samples = ( + ((0.0, 0.1), (0.1, 0.2), (0.35, 1.0), (0.5, 0.5), (0.9, 0.0)) + if explicit_timebase + else ((0.0, 0.1), (0.1, 0.2), (0.2, 1.0), (0.3, 0.5), (0.4, 0.0)) + ) + frame = _ref("frame", "bar-vertical-up", "Vertical bar-up frame") + sign = SignConvention(reference=_ref("sign", "bar-up-positive", "Bar upward positive")) + series_digest = canonical_hash( + { + "samples": samples, + "timebase": timebase, + "unit": METER_PER_SECOND, + "velocity_frame": frame, + "sign_convention": sign, + "processing_state": BPTProcessingState.PROVIDER_PROCESSED, + } + ) + artifact = BenchPressThrowSourceArtifact( + artifact_id=InstanceIdentifier("artifact", prefix), + content_digest=series_digest, + media_type="application/vnd.synthetic.bpt-series", + status=BPTArtifactStatus.VERIFIED, + hash_scope=BPTArtifactHashScope.CANONICAL_SERIES_REPRESENTATION, + ) + device = _ref("device", "synthetic-t-force", "Synthetic velocity device") + acquisition_id = InstanceIdentifier("acquisition", prefix) + acquisition = BenchPressThrowAcquisitionRecord( + acquisition_id=acquisition_id, + device=device, + source_artifact_id=artifact.artifact_id, + sensor_channel="bar", + provider="synthetic-provider", + sensor_modality=BPTSensorModality.LINEAR_POSITION_TRANSDUCER, + timebase=timebase, + axis_or_frame=frame, + ) + load = BPTLoadIdentity( + kind=BPTLoadKind.PHYSICAL, + load_value=20.0, + load_unit=KILOGRAM, + bar_mass_kg=10.0, + added_load_kg=10.0, + counterweight_mass_kg=0.0, + moving_system_load_semantics="bar plus added load; counterweight status explicit", + effective_resistance_semantics="physical moving-system load", + ) + protocol = BenchPressThrowProtocolIdentity( + machine_type=BPTMachineType.FIXED_VERTICAL_SMITH, + counterbalance_status=counterbalance_status, + load_identity=load, + concentric_pattern=movement, + pause_touch_bounce=BPTPauseTouchBounce.TOUCH_AND_GO, + grip="medium pronated", + range_of_motion="registered chest-to-throw ROM", + feet_body_support="supine bench with feet supported", + release_semantics=BPTReleaseSemantics.RELEASED, + catch_semantics=BPTCatchSemantics.NOT_CAUGHT, + provider="synthetic-provider", + device=device, + sensor_modality=BPTSensorModality.LINEAR_POSITION_TRANSDUCER, + timebase=timebase, + source_series_digest=series_digest, + operation_support_definition="support supplied separately per metric", + ) + identity = BenchPressThrowMeasurementIdentity( + identity_id=ScientificIdentifier( + "synthetic", "measurement-identity", "bpt-source", "1.0.0" + ), + semantic=BenchPressThrowSemanticIdentity( + construct=BPT_CONSTRUCT, + test_family=BPT_TEST_FAMILY, + protocol=protocol.reference, + measurand=BPT_BAR_VELOCITY_MEASURAND, + metric_definition=BPT_VELOCITY_SERIES_METRIC, + protocol_identity=protocol, + ), + acquisition=BenchPressThrowAcquisitionIdentity( + device=device, + raw_artifact=artifact.artifact_id, + acquisition_instance_id=acquisition_id, + sensor_channel="bar", + provider=acquisition.provider, + sensor_modality=acquisition.sensor_modality, + timebase=timebase, + axis_or_frame=frame, + source_series_digest=series_digest, + ), + processing=BenchPressThrowProcessingIdentity( + registered_operation=BPT_SOURCE_VELOCITY_SERIES_OPERATION, + timebase=timebase, + sign_convention=sign, + processing_state=BPTProcessingState.PROVIDER_PROCESSED, + ), + version=VersionIdentity( + processing_method=BPT_SOURCE_VELOCITY_SERIES_OPERATION, + method_registry_version=BPT_REGISTRY_VERSION, + software_version="synthetic-bpt-source-v1", + ), + ) + series = BenchPressThrowVelocitySeries( + series_id=InstanceIdentifier("signal", f"{prefix}-series"), + source_artifact_id=artifact.artifact_id, + acquisition_id=acquisition_id, + source_measurement_identity_id=identity.identity_id, + samples=samples, + timebase=timebase, + unit=METER_PER_SECOND, + velocity_frame=frame, + sign_convention=sign, + processing_state=BPTProcessingState.PROVIDER_PROCESSED, + ) + support = BenchPressThrowMetricSupport( + support_id=InstanceIdentifier("support", f"{prefix}-vmax"), + metric=BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + start_index=0, + end_index=4, + start_time_s=0.0, + end_time_s=samples[-1][0], + support_definition=( + "first positive bar velocity through explicit release/maximum-height support" + ), + source_series_digest=series_digest, + includes_post_release_samples=True, + ) + evidence = create_bpt_velocity_series_evidence( + observation_id=InstanceIdentifier("observation", f"{prefix}-series"), + result_id=InstanceIdentifier("result", f"{prefix}-series"), + context=_context(prefix), + identity=identity, + series=series, + source_artifact=artifact, + acquisition=acquisition, + support=support, + ) + return evidence, protocol, support, series_digest, acquisition, artifact + + +def test_bpt_vmax_time_weighted_mean_and_post_release_support() -> None: + evidence, protocol, _, _, _, _ = _bpt_fixture() + vmax = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(evidence)) + assert vmax.value_m_per_s == pytest.approx(1.0) + mean_support = replace( + evidence.support, + support_id=InstanceIdentifier("support", "synthetic-bpt-mean"), + metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + ) + mean_evidence = replace(evidence, support=mean_support) + mean = _bpt_result(calculate_bpt_dynamislm_time_weighted_mean_bar_velocity(mean_evidence)) + assert mean.value_m_per_s == pytest.approx(0.4375) + assert ( + mean.observation.identity.semantic.metric_definition + == BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC + ) + assert protocol.release_semantics is BPTReleaseSemantics.RELEASED + + +def test_bpt_metric_support_and_provider_origin_are_distinct() -> None: + evidence, protocol, _, _, acquisition, artifact = _bpt_fixture() + provider = build_bpt_provider_metric_observation( + observation_id=InstanceIdentifier("observation", "synthetic-bpt-provider-mv"), + result_id=InstanceIdentifier("result", "synthetic-bpt-provider-mv"), + context=_context("synthetic-bpt-provider"), + protocol_identity=protocol, + metric=BPT_PROVIDER_MEAN_VELOCITY_METRIC, + value=0.4375, + unit=METER_PER_SECOND, + source_artifact=artifact, + acquisition=acquisition, + value_origin=ValueOrigin.PROVIDER_DERIVED, + provider_algorithm=_ref("provider-algorithm", "synthetic-mv", "Synthetic provider MV"), + ) + derived = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(evidence)) + wrapped = _bpt_provider_result( + wrap_bpt_provider_metric(provider, BPT_PROVIDER_MEAN_VELOCITY_METRIC) + ) + assert provider.result.classification.value_origin is ValueOrigin.PROVIDER_DERIVED + assert derived.metric != BPT_PROVIDER_MEAN_VELOCITY_METRIC + assert ( + compare_bpt_metric_results(derived, wrapped).state + is ComparabilityState.BRIDGE_VALIDATION_REQUIRED + ) + + +def test_bpt_metric_specific_refusals_and_serialization() -> None: + evidence, _, _, _, _, _ = _bpt_fixture() + assert calculate_bpt_mean_propulsive_velocity(evidence).blocks_claim + assert calculate_bpt_mean_power(evidence).blocks_claim + assert calculate_bpt_load_times_velocity_power(20.0, 1.0).blocks_claim + assert _refused( + calculate_bpt_sampled_maximum_bar_velocity(evidence, start_index=1) + ).blocks_claim + result = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(evidence)) + encoded = canonical_json(result) + decoded = from_canonical_json(encoded, type(result)) + assert canonical_hash(decoded) == canonical_hash(result) + assert result.observation.result.classification.value_origin is ValueOrigin.DYNAMISLM_DERIVED + + +def test_bpt_irregular_timebase_and_same_method_comparability() -> None: + explicit, _, _, _, _, _ = _bpt_fixture(prefix="synthetic-bpt-explicit", explicit_timebase=True) + mean_support = replace( + explicit.support, + support_id=InstanceIdentifier("support", "synthetic-bpt-explicit-mean"), + metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + ) + mean = _bpt_result( + calculate_bpt_dynamislm_time_weighted_mean_bar_velocity( + replace(explicit, support=mean_support) + ) + ) + assert mean.value_m_per_s == pytest.approx(0.3775 / 0.9) + other, _, _, _, _, _ = _bpt_fixture(prefix="synthetic-bpt-other", explicit_timebase=True) + other_vmax = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(other)) + assert ( + compare_bpt_metric_results( + _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(explicit)), + other_vmax, + ).state + is ComparabilityState.COMPARABLE + ) + + +def test_bpt_unresolved_counterbalance_and_movement_mismatch_refuse_or_bridge() -> None: + unresolved, _, _, _, _, _ = _bpt_fixture( + prefix="synthetic-bpt-unresolved", + counterbalance_status=BPTCounterbalanceStatus.UNKNOWN, + ) + assert _refused(calculate_bpt_sampled_maximum_bar_velocity(unresolved)).blocks_claim + concentric, _, _, _, _, _ = _bpt_fixture(prefix="synthetic-bpt-concentric") + eccentric, _, _, _, _, _ = _bpt_fixture( + prefix="synthetic-bpt-eccentric", + movement=BPTMovementPattern.ECCENTRIC_CONCENTRIC, + ) + assert ( + compare_bpt_metric_results( + _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(concentric)), + _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(eccentric)), + ).state + is ComparabilityState.BRIDGE_VALIDATION_REQUIRED + ) + + +def _mbt_distance_fixture( + *, + throw_type: MBTThrowVariant = MBTThrowVariant.SEATED_CHEST, + prefix: str = "synthetic-mbt-distance", +) -> tuple[ + ScientificMeasurementObservation, + MBTCoordinateEvidence, + MedicineBallThrowProtocolIdentity, + MedicineBallThrowSourceArtifact, + MedicineBallThrowAcquisitionRecord, + RegistryReference, +]: + origin = _ref("distance-origin", "pelvis-projection", "Projected starting origin") + endpoint = _ref("distance-endpoint", "first-contact", "First ball-floor contact") + no_roll = _ref("distance-endpoint-rule", "first-contact-no-roll", "First contact; no roll") + frame = _ref("frame", "horizontal-forward", "Horizontal forward axis") + device = _ref("device", "synthetic-measuring-tape", "Synthetic measuring tape") + protocol = MedicineBallThrowProtocolIdentity( + throw_type=throw_type, + body_posture=MBTBodyPosture.SEATED, + support_restraint_condition=MBTSupportCondition.BACK_SUPPORTED, + ball_mass_kg=3.0, + countermovement=MBTAllowedState.PROHIBITED, + lower_body_contribution=MBTAllowedState.PROHIBITED, + throw_arm_technique="two-hand chest pass", + starting_position="ball at chest; back against support", + release_semantics=MBTReleaseSemantics.SOURCE_REPORTED_RELEASE, + measurement_method=_ref( + "measurement-method", "synthetic-tape", "Synthetic tape measurement" + ), + distance_origin_convention=origin, + distance_endpoint_convention=endpoint, + first_contact_no_roll_convention=no_roll, + provider="synthetic-provider", + device=device, + sensor_modality=MBTSensorModality.MEASURING_TAPE, + ) + artifact = MedicineBallThrowSourceArtifact( + artifact_id=InstanceIdentifier("artifact", prefix), + content_digest=f"sha256:{prefix}", + media_type="application/vnd.synthetic.mbt-distance", + status=MBTArtifactStatus.VERIFIED, + hash_scope=MBTArtifactHashScope.CONTENT_BYTES, + ) + acquisition = MedicineBallThrowAcquisitionRecord( + acquisition_id=InstanceIdentifier("acquisition", prefix), + device=device, + source_artifact_id=artifact.artifact_id, + provider="synthetic-provider", + sensor_modality=MBTSensorModality.MEASURING_TAPE, + axis_or_frame=frame, + ) + source = build_mbt_source_distance_observation( + observation_id=InstanceIdentifier("observation", prefix), + result_id=InstanceIdentifier("result", prefix), + context=_context(prefix), + protocol_identity=protocol, + distance_m=4.5, + source_artifact=artifact, + acquisition=acquisition, + ) + coordinate = MBTCoordinateEvidence( + source_observation_id=source.observation_id, + origin_coordinate_m=1.0, + endpoint_coordinate_m=5.5, + coordinate_frame=frame, + origin_convention=origin, + endpoint_convention=endpoint, + first_contact_no_roll_convention=no_roll, + first_contact_observed=True, + no_roll_observed=True, + source_artifact_id=artifact.artifact_id, + acquisition_id=acquisition.acquisition_id, + ) + return source, coordinate, protocol, artifact, acquisition, frame + + +def test_mbt_exact_distance_and_power_refusals() -> None: + source, coordinate, _, _, _, _ = _mbt_distance_fixture() + distance = _mbt_result(calculate_mbt_throw_distance(coordinate, source)) + assert distance.value_m == pytest.approx(4.5) + assert calculate_mbt_distance_as_power(distance).blocks_claim + assert calculate_mbt_release_velocity_from_distance(distance).blocks_claim + assert canonical_hash( + from_canonical_json(canonical_json(distance), type(distance)) + ) == canonical_hash(distance) + + +def test_mbt_protocol_conventions_and_variant_comparability() -> None: + source, coordinate, _, _, _, _ = _mbt_distance_fixture() + distance = _mbt_result(calculate_mbt_throw_distance(coordinate, source)) + standing_source, standing_coordinate, _, _, _, _ = _mbt_distance_fixture( + throw_type=MBTThrowVariant.STANDING_CHEST, + prefix="synthetic-mbt-standing-distance", + ) + standing_distance = _mbt_result( + calculate_mbt_throw_distance(standing_coordinate, standing_source) + ) + assert ( + compare_mbt_metric_results(distance, standing_distance).state + is ComparabilityState.NOT_COMPARABLE + ) + bad_coordinate = replace( + coordinate, + endpoint_convention=_ref("distance-endpoint", "rolled", "Rolled endpoint"), + ) + assert _refused(calculate_mbt_throw_distance(bad_coordinate, source)).blocks_claim + + +def test_mbt_instrumented_release_velocity_requires_qualified_event_evidence() -> None: + _, _, _, _, _, _ = _mbt_distance_fixture() + frame = _ref("frame", "ball-forward", "Ball forward frame") + device = _ref("device", "synthetic-radar", "Synthetic radar") + protocol = MedicineBallThrowProtocolIdentity( + throw_type=MBTThrowVariant.STANDING_CHEST, + body_posture=MBTBodyPosture.STANDING, + support_restraint_condition=MBTSupportCondition.FREE_STANDING, + ball_mass_kg=2.0, + countermovement=MBTAllowedState.ALLOWED, + lower_body_contribution=MBTAllowedState.ALLOWED, + throw_arm_technique="two-hand chest throw", + starting_position="ball at chest", + release_semantics=MBTReleaseSemantics.EXPLICIT_RELEASE_EVENT, + measurement_method=_ref( + "measurement-method", "synthetic-radar", "Synthetic trajectory/radar" + ), + provider="synthetic-provider", + device=device, + sensor_modality=MBTSensorModality.RADAR, + ) + timebase = MBTTimebase(MBTTimebaseKind.EXPLICIT, times_s=(0.0, 0.1, 0.2)) + trajectory = ((0.0, 0.0), (0.1, 0.2), (0.2, 0.5)) + velocity = ((0.0, 0.0), (0.1, 2.0), (0.2, 3.5)) + velocity_definition = _ref( + "velocity-definition", "release-sample", "Exact release sample velocity" + ) + digest = canonical_hash( + { + "trajectory_samples": trajectory, + "velocity_samples": velocity, + "timebase": timebase, + "coordinate_frame": frame, + } + ) + artifact = MedicineBallThrowSourceArtifact( + artifact_id=InstanceIdentifier("artifact", "synthetic-mbt-release"), + content_digest=digest, + media_type="application/vnd.synthetic.mbt-trajectory", + status=MBTArtifactStatus.VERIFIED, + hash_scope=MBTArtifactHashScope.CANONICAL_SERIES_REPRESENTATION, + ) + acquisition = MedicineBallThrowAcquisitionRecord( + acquisition_id=InstanceIdentifier("acquisition", "synthetic-mbt-release"), + device=device, + source_artifact_id=artifact.artifact_id, + provider="synthetic-provider", + sensor_modality=MBTSensorModality.RADAR, + timebase=timebase, + axis_or_frame=frame, + ) + event = MBTReleaseEvent( + event_id=InstanceIdentifier("event-occurrence", "synthetic-mbt-release"), + source_observation_id=InstanceIdentifier("observation", "synthetic-mbt-release"), + source_series_id=InstanceIdentifier("signal", "synthetic-mbt-release"), + source_artifact_id=artifact.artifact_id, + acquisition_id=acquisition.acquisition_id, + sample_index=2, + event_time_s=0.2, + release_method=velocity_definition, + coordinate_frame=frame, + source_series_digest=digest, + ) + evidence = build_mbt_release_velocity_evidence( + observation_id=event.source_observation_id, + result_id=InstanceIdentifier("result", "synthetic-mbt-release"), + context=_context("synthetic-mbt-release"), + protocol_identity=protocol, + release_event=event, + trajectory_samples=trajectory, + velocity_samples=velocity, + timebase=timebase, + coordinate_frame=frame, + velocity_definition=velocity_definition, + source_artifact=artifact, + acquisition=acquisition, + ) + result = _mbt_result(calculate_mbt_instrumented_release_velocity(evidence)) + assert result.value_m_per_s == pytest.approx(3.5) + assert _refused( + calculate_mbt_instrumented_release_velocity(cast(MBTReleaseVelocityEvidence, object())) + ).blocks_claim From 8e2f7d63efa28a1be9a23426da73046cfbab3a6f Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:41:07 +0000 Subject: [PATCH 2/4] docs(res68): bind receipt to review state --- docs/decisions/RES68-RECEIPT.json | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/decisions/RES68-RECEIPT.json b/docs/decisions/RES68-RECEIPT.json index 8f0c713..e81ee84 100644 --- a/docs/decisions/RES68-RECEIPT.json +++ b/docs/decisions/RES68-RECEIPT.json @@ -2,9 +2,11 @@ "mission": "RES-68-DESIGN-FIX-AND-IMPLEMENTATION-001", "linear_issue": "RES-68", "project": "DynamisLM", - "status": "IMPLEMENTED_LOCAL_QA_PASS", + "status": "READY_FOR_REVIEW", "decision_record": "docs/decisions/RES68-DR-001-explosive-test-family-closure.md", "entry_head": "cb1a6e8631d1d42ba108d02a247e39688658efb5", + "achievement_commit_sha": "6bea4abd85e71201413e7ec040684bc7db2adb4a", + "pr_head_at_creation": "6bea4abd85e71201413e7ec040684bc7db2adb4a", "branch": "work/res-68-explosive-test-family-closure", "phase_a_design_fix": "PASS", "phase_b_implementation": "PASS", @@ -64,21 +66,26 @@ "mypy": "PASS", "repository_policy": "PASS", "pytest": "PASS", + "hosted_ci": "PASS", + "hosted_ci_run": "https://github.com/Litju/DynamisLM/actions/runs/35153538223", + "advisory_review": "PENDING", "test_count": 742, "tracked_mutation": "NONE", "git_diff_check": "PASS", "command": "./scripts/ci.sh" }, "git": { - "commit_created": false, - "pushed_existing_branch": false, - "pr_created": false, - "pr_number": null, - "pr_head": null + "commit_created": true, + "pushed_existing_branch": true, + "pr_created": true, + "pr_number": 30, + "pr_head": "6bea4abd85e71201413e7ec040684bc7db2adb4a", + "changed_files": 22, + "unrelated_tracked_mutation": "NONE" }, - "linear_res68_status": "IN_PROGRESS", + "linear_res68_status": "IN_REVIEW", "scientific_review": "PENDING_FINAL_REVIEW", "unresolved_scientific_blockers": [], - "merge_readiness": "NOT_READY", + "merge_readiness": "READY_FOR_REVIEW", "next": "RES-68-FINAL-REVIEW-001" } From f26a79a0641f17acd81ac05f3e12a744d459103f Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:34:34 +0000 Subject: [PATCH 3/4] fix(res68): close evidence authority and fail-closed seams --- ...68-DR-001-explosive-test-family-closure.md | 37 + docs/decisions/RES68-RECEIPT.json | 38 +- .../bench_press_throw/comparability.py | 88 +- .../measurement/bench_press_throw/identity.py | 19 +- .../measurement/bench_press_throw/metrics.py | 11 + .../bench_press_throw/qualification.py | 387 +++++- .../measurement/bench_press_throw/registry.py | 15 + .../measurement/drop_jump/comparability.py | 88 +- src/dynamislm/measurement/drop_jump/events.py | 522 ++++--- .../measurement/drop_jump/metrics.py | 13 + .../measurement/drop_jump/qualification.py | 4 +- .../medicine_ball_throw/comparability.py | 88 +- .../medicine_ball_throw/identity.py | 197 ++- .../medicine_ball_throw/metrics.py | 74 +- .../medicine_ball_throw/qualification.py | 471 ++++++- .../medicine_ball_throw/registry.py | 20 + tests/test_explosive_test_families.py | 1214 ++++++++++++++++- 17 files changed, 2916 insertions(+), 370 deletions(-) diff --git a/docs/decisions/RES68-DR-001-explosive-test-family-closure.md b/docs/decisions/RES68-DR-001-explosive-test-family-closure.md index d7c23a0..50b297d 100644 --- a/docs/decisions/RES68-DR-001-explosive-test-family-closure.md +++ b/docs/decisions/RES68-DR-001-explosive-test-family-closure.md @@ -263,6 +263,39 @@ source series digest, start/end indices and times, velocity unit/frame/sign, and method semantics. A support may explicitly include post-release samples when its metric definition does. There is no generic post-release invalidation. +### Authority boundary correction + +```text +TYPED_OBJECT != SCIENTIFIC_ADJUDICATION_AUTHORITY +``` + +The family builders do not treat a caller-constructed dataclass, index pair, +boolean, status, or matching digest as scientific adjudication. Source/provider +normalization is the authority boundary and must preserve the upstream +processing run, exact source bindings, source/provider origin, and the +registered method parameters before a downstream result is minted. + +For DJ, `DropJumpEventSourceEvidence` binds the event label, exact sample and +time, source series/artifact/acquisition, detector parameters, event status and +QC codes to the upstream event-processing provenance. The normalized +`DropJumpEventOccurrence` preserves that upstream provenance; DynamisLM does +not claim to have run the detector. + +For BPT, `BPTMetricSupportSourceEvidence` binds the exact source series, +metric, inclusive support indices/timestamps, support definition, boundary +method/convention, post-release policy, protocol context, source/provider +origin and upstream processing provenance. Only its normalized +`BenchPressThrowMetricSupport` may feed Vmax or DynamisLM time-weighted MV. + +For MBT, `MBTCoordinateSourceEvidence` binds origin/endpoint coordinates, +coordinate frame, origin/endpoint and first-contact/no-roll conventions, +source artifact/acquisition, protocol context, provider/origin and producing +provenance before coordinate distance is derived. `MBTReleaseEventSourceEvidence` +binds the release sample/time, trajectory digest, frame, release method, +protocol context, source artifact/acquisition, provider/origin and producing +provenance before release velocity is selected. Source-reported distance +remains source-reported and does not require redundant coordinate derivation. + ## K. Source qualification requirements Source/provider qualification is an upstream categorical observation, not a @@ -314,17 +347,21 @@ requirements are met; it blocks only actual-drop-dependent claims. | Refusal condition | Blocked claim | Safe description retained | | --- | --- | --- | | DJ event absent/malformed/wrong family/source/protocol | Requested DJ derived metric | Independently valid typed event or earlier duration | +| DJ caller-selected index/status without upstream event evidence | DJ event authority | Source observation and any independently preserved upstream event data | | DJ event order or duration non-positive/non-finite | Contact/flight/derived ratio as applicable | Event identities and recorded times | | DJ invalid/missing gravity or unresolved flight assumptions | DJ flight-time height | Contact and flight time where independently valid | | DJ JH/CT requested without valid jump height or denominator | RSI JH/CT | Contact time and any valid JH/flight output | | DJ RSR requested without valid flight time or denominator | RSR FT/CT | Contact time and any valid flight output | | Actual drop height missing for actual-drop claim | Equal exposure/normalization/mechanics claim | Rebound metrics not requiring actual height | | BPT raw caller support indices | Any derived BPT support metric | Source series/provider values | +| BPT support without source/provider-qualified support evidence | Any derived BPT support metric | Exact source series/provider values | | BPT missing counterbalance/load system/provider/device/support identity | Claim requiring that dimension | Source observation without the unsupported comparison/derivation | | BPT provider MPV/power requested as DynamisLM arithmetic | Requested computation | Provider-reported value with origin/method if present | | BPT generic load × velocity power | Generic power claim | Bar velocity and load as separately identified values | | MBT scalar distance supplied as release velocity | Release velocity claim | Exact distance observation | | MBT missing origin/end/first-contact convention or variant | Distance comparison/derivation | Source value and known protocol fields | +| MBT caller coordinates/endpoint booleans without coordinate source evidence | Derived MBT distance | Source-reported distance, if present | +| MBT caller-selected release sample without release-event source evidence | Instrumented release velocity | Qualified trajectory without an emitted release velocity | | MBT distance relabelled power/normative score | Power/score claim | Exact distance | | Any missing source/provenance/processing lineage | Derived/qualified claim | Independently describable source observations | diff --git a/docs/decisions/RES68-RECEIPT.json b/docs/decisions/RES68-RECEIPT.json index e81ee84..b1cf781 100644 --- a/docs/decisions/RES68-RECEIPT.json +++ b/docs/decisions/RES68-RECEIPT.json @@ -1,12 +1,14 @@ { - "mission": "RES-68-DESIGN-FIX-AND-IMPLEMENTATION-001", + "mission": "RES-68-REVIEW-FIX-001", "linear_issue": "RES-68", "project": "DynamisLM", - "status": "READY_FOR_REVIEW", + "status": "READY_FOR_FINAL_REVIEW", "decision_record": "docs/decisions/RES68-DR-001-explosive-test-family-closure.md", - "entry_head": "cb1a6e8631d1d42ba108d02a247e39688658efb5", - "achievement_commit_sha": "6bea4abd85e71201413e7ec040684bc7db2adb4a", - "pr_head_at_creation": "6bea4abd85e71201413e7ec040684bc7db2adb4a", + "base_main": "cb1a6e8631d1d42ba108d02a247e39688658efb5", + "entry_head": "8e2f7d63efa28a1be9a23426da73046cfbab3a6f", + "reviewed_head": "8e2f7d63efa28a1be9a23426da73046cfbab3a6f", + "historical_achievement_commit_sha": "6bea4abd85e71201413e7ec040684bc7db2adb4a", + "historical_pr_head_at_creation": "6bea4abd85e71201413e7ec040684bc7db2adb4a", "branch": "work/res-68-explosive-test-family-closure", "phase_a_design_fix": "PASS", "phase_b_implementation": "PASS", @@ -15,9 +17,9 @@ "codebase_memory": { "used": true, "project": "home-litju-projects-DynamisLM", - "initial_map": "Kernel observation/result/identity/provenance, CMJ event and flight-height authority, RES-66 velocity-series support, RES-67 source qualification, registries, V3 serialization, refusal/comparability and downstream patterns were mapped before edits.", - "changed_symbol_audit": "PASS", - "unexpected_downstream_impact": "NONE; exact direct production call/import audit found no external consumers of the new symbols; inbound consumers are the RES-68 synthetic tests.", + "initial_map": "Codebase Memory mapped all RES-68 DJ/BPT/MBT public entry points and consumers, generic ComparabilityRequest invariants, provenance, V3 serialization, RES-66 VBT phase authority, RES-67 source qualification, tests, decision record and receipt before edits.", + "changed_symbol_audit": "PASS; exact reindexed symbol lookup and caller traces completed after repair", + "unexpected_downstream_impact": "NONE; all changed production symbols remain within RES-68 family modules and their tests", "graph_note": "Similarity and short-name graph edges into existing families were treated as non-authoritative false positives and checked against exact source paths." }, "dispositions": { @@ -66,26 +68,30 @@ "mypy": "PASS", "repository_policy": "PASS", "pytest": "PASS", - "hosted_ci": "PASS", + "hosted_ci": "HISTORICAL_PASS_ONLY", "hosted_ci_run": "https://github.com/Litju/DynamisLM/actions/runs/35153538223", + "hosted_ci_tested_commit": "8e2f7d63efa28a1be9a23426da73046cfbab3a6f", + "fresh_hosted_ci": "REQUIRED_ON_REPAIR_HEAD", "advisory_review": "PENDING", - "test_count": 742, + "test_count": 749, "tracked_mutation": "NONE", "git_diff_check": "PASS", - "command": "./scripts/ci.sh" + "command": "./scripts/ci.sh", + "local_qa_tested_commit": "review-fix working tree before final commit" }, "git": { "commit_created": true, "pushed_existing_branch": true, - "pr_created": true, + "pr_created_by_this_mission": false, "pr_number": 30, - "pr_head": "6bea4abd85e71201413e7ec040684bc7db2adb4a", - "changed_files": 22, + "historical_pr_head": "6bea4abd85e71201413e7ec040684bc7db2adb4a", + "repair_head": "reported in final handoff; not a current-head assertion in this receipt", + "changed_files_in_review_repair": 17, "unrelated_tracked_mutation": "NONE" }, "linear_res68_status": "IN_REVIEW", "scientific_review": "PENDING_FINAL_REVIEW", "unresolved_scientific_blockers": [], - "merge_readiness": "READY_FOR_REVIEW", - "next": "RES-68-FINAL-REVIEW-001" + "merge_readiness": "READY_FOR_FINAL_REVIEW", + "next": "RES-68-FINAL-REVIEW-002" } diff --git a/src/dynamislm/measurement/bench_press_throw/comparability.py b/src/dynamislm/measurement/bench_press_throw/comparability.py index 79032c0..ddc8864 100644 --- a/src/dynamislm/measurement/bench_press_throw/comparability.py +++ b/src/dynamislm/measurement/bench_press_throw/comparability.py @@ -84,6 +84,46 @@ def _observation( return item.observation +def _fallback_observation_id( + item: object, + side: str, +) -> InstanceIdentifier: + if isinstance(item, BenchPressThrowMetricResult | BPTProviderMetricResult): + try: + observation = item.observation + if isinstance(observation, ScientificMeasurementObservation) and isinstance( + observation.observation_id, InstanceIdentifier + ): + return observation.observation_id + except (AttributeError, TypeError, ValueError): + pass + return InstanceIdentifier("observation", f"invalid-bpt-{side}") + + +def _fallback_request( + left: object, + right: object, + claim: object, +) -> ComparabilityRequest: + safe_claim = claim if isinstance(claim, str) and claim.strip() else "invalid BPT comparison" + left_id = _fallback_observation_id(left, "left") + right_id = _fallback_observation_id(right, "right") + if left_id == right_id: + right_id = InstanceIdentifier("observation", f"{right_id.value}:right-envelope") + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash({"left": left_id, "right": right_id, "claim": safe_claim}).removeprefix( + "sha256:" + )[:24], + ) + return ComparabilityRequest( + request_id=request_id, + left_observation_id=left_id, + right_observation_id=right_id, + claim=safe_claim, + ) + + def _protocol_signature(protocol: BPTProtocolIdentity) -> tuple[object, ...]: """Compare method/mechanics, not source-instance or provider metadata.""" @@ -149,6 +189,9 @@ def _support_signature(result: BenchPressThrowMetricResult) -> tuple[object, ... return ( support.metric, support.support_definition, + support.boundary_method, + support.boundary_convention, + support.boundary_parameters, support.includes_post_release_samples, evidence.series.timebase, evidence.series.velocity_frame, @@ -163,21 +206,23 @@ def compare_bpt_metric_results( ) -> ComparabilityResult: """Adjudicate BPT comparisons using exact metric and mechanical identity.""" - left_observation = _observation(left) - right_observation = _observation(right) - left_id = left_observation.observation_id - right_id = right_observation.observation_id - request_id = InstanceIdentifier( - "comparability-request", - canonical_hash({"left": left_id, "right": right_id, "claim": claim}).removeprefix( - "sha256:" - )[:24], - ) try: + if not isinstance(claim, str) or not claim.strip(): + raise ValueError("BPT comparison claim must be non-empty text") if not isinstance( left, BenchPressThrowMetricResult | BPTProviderMetricResult ) or not isinstance(right, BenchPressThrowMetricResult | BPTProviderMetricResult): raise ValueError("typed BPT metric results are required") + left_observation = _observation(left) + right_observation = _observation(right) + left_id = left_observation.observation_id + right_id = right_observation.observation_id + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash({"left": left_id, "right": right_id, "claim": claim}).removeprefix( + "sha256:" + )[:24], + ) request = ComparabilityRequest( request_id=request_id, left_observation_id=left.observation.observation_id, @@ -195,24 +240,13 @@ def compare_bpt_metric_results( ), ) except (AttributeError, TypeError, ValueError): - fallback = ComparabilityRequest( - request_id=request_id, - left_observation_id=left_id, - right_observation_id=right_id, - claim=claim or "invalid BPT comparison", - ) - if hasattr(left, "observation") and hasattr(right, "observation"): - return _result( - fallback, - ComparabilityState.NOT_COMPARABLE, - (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), - ) - return _result( - fallback, - ComparabilityState.INSUFFICIENT_INFORMATION, - (ComparabilityReasonCode.MISSING_METADATA,), - missing=("two typed BPT metric results",), + fallback = _fallback_request(left, right, claim) + missing = ( + ("two distinct observations",) + if fallback.right_observation_id.value.endswith(":right-envelope") + else ("two typed BPT metric results",) ) + return ComparabilityResult.insufficient(fallback, missing_information=missing) left_metric = left.metric right_metric = right.metric diff --git a/src/dynamislm/measurement/bench_press_throw/identity.py b/src/dynamislm/measurement/bench_press_throw/identity.py index 0a62b3a..d2101a7 100644 --- a/src/dynamislm/measurement/bench_press_throw/identity.py +++ b/src/dynamislm/measurement/bench_press_throw/identity.py @@ -530,7 +530,7 @@ def canonical_content_digest(self) -> str: @register_serializable_type @dataclass(frozen=True, slots=True) class BenchPressThrowMetricSupport: - """Exact inclusive support for one BPT metric method.""" + """Exact inclusive support; authority is supplied by upstream evidence.""" support_id: InstanceIdentifier metric: RegistryReference @@ -541,6 +541,10 @@ class BenchPressThrowMetricSupport: support_definition: str source_series_digest: str includes_post_release_samples: bool = False + boundary_method: RegistryReference | None = None + boundary_convention: RegistryReference | None = None + boundary_parameters: tuple[MetadataEntry, ...] = () + authority_source_id: InstanceIdentifier | None = None def __post_init__(self) -> None: if self.support_id.instance_type != "support": @@ -564,6 +568,19 @@ def __post_init__(self) -> None: _require_text(self.source_series_digest, "source_series_digest") if not isinstance(self.includes_post_release_samples, bool): raise ValueError("includes_post_release_samples must be a boolean") + _require_optional_instance(self.boundary_method, RegistryReference, "boundary_method") + _require_optional_instance( + self.boundary_convention, RegistryReference, "boundary_convention" + ) + _require_tuple_items(self.boundary_parameters, MetadataEntry, "boundary_parameters") + _require_optional_instance( + self.authority_source_id, InstanceIdentifier, "authority_source_id" + ) + if ( + self.authority_source_id is not None + and self.authority_source_id.instance_type != "support" + ): + raise ValueError("authority_source_id must identify a support") # Short names are aliases to the same registered classes, not new wire types. diff --git a/src/dynamislm/measurement/bench_press_throw/metrics.py b/src/dynamislm/measurement/bench_press_throw/metrics.py index ecc5694..607c92f 100644 --- a/src/dynamislm/measurement/bench_press_throw/metrics.py +++ b/src/dynamislm/measurement/bench_press_throw/metrics.py @@ -104,6 +104,17 @@ def __post_init__(self) -> None: _finite(value.value, "BPT result") if self.observation.result.status is not ResultStatus.VALID: raise ValueError("BPT result must be valid") + implemented_metrics = { + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + } + if self.metric in implemented_metrics and self.source_evidence is None: + raise ValueError("implemented BPT result must preserve source-series evidence") + if ( + self.source_evidence is not None + and self.source_evidence.observation not in self.source_observations + ): + raise ValueError("BPT result must preserve its source observation") runs = tuple( run for run in self.observation.provenance.processing_runs diff --git a/src/dynamislm/measurement/bench_press_throw/qualification.py b/src/dynamislm/measurement/bench_press_throw/qualification.py index d79c056..d45089a 100644 --- a/src/dynamislm/measurement/bench_press_throw/qualification.py +++ b/src/dynamislm/measurement/bench_press_throw/qualification.py @@ -3,7 +3,8 @@ from __future__ import annotations import datetime as datetime_module -from dataclasses import dataclass +import math +from dataclasses import dataclass, replace from dynamislm.measurement.bench_press_throw.identity import ( BenchPressThrowAcquisitionRecord, @@ -19,12 +20,18 @@ BPTProcessingState, BPTQualificationStatus, BPTSensorModality, + BPTTimebase, ) from dynamislm.measurement.bench_press_throw.registry import ( BPT_BAR_VELOCITY_MEASURAND, BPT_CONSTRUCT, BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, BPT_MEAN_POWER_MEASURAND, + BPT_MEAN_PROPULSIVE_VELOCITY_MEASURAND, + BPT_METRIC_SUPPORT_BOUNDARY_CONVENTION, + BPT_METRIC_SUPPORT_METHOD, + BPT_METRIC_SUPPORT_QUALIFICATION_RULE, + BPT_METRIC_SUPPORT_SOURCE_OPERATION, BPT_PEAK_POWER_MEASURAND, BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, BPT_PROVIDER_MEAN_POWER_METRIC, @@ -54,6 +61,8 @@ UnitReference, VersionIdentity, _require_instance, + _require_text, + _require_tuple_items, ) from dynamislm.measurement.observation import ( ObservationContext, @@ -80,6 +89,15 @@ from dynamislm.serialization import canonical_json, register_serializable_type +def _finite(value: object, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"{field_name} must be numeric") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{field_name} must be finite") + return result + + def _origin_for_series(state: BPTProcessingState) -> ValueOrigin: if state is BPTProcessingState.RAW_ACQUIRED: return ValueOrigin.SOURCE_REPORTED @@ -123,6 +141,12 @@ def _validate_acquisition_binding( if series is not None: if ( identity.acquisition.source_series_digest != series.canonical_content_digest() + or ( + identity.semantic.protocol_identity is not None + and identity.semantic.protocol_identity.source_series_digest is not None + and identity.semantic.protocol_identity.source_series_digest + != series.canonical_content_digest() + ) or identity.acquisition.timebase != series.timebase or identity.acquisition.device != acquisition.device ): @@ -153,6 +177,7 @@ class BenchPressThrowVelocitySeriesEvidence: source_artifact: BenchPressThrowSourceArtifact acquisition: BenchPressThrowAcquisitionRecord support: BenchPressThrowMetricSupport + support_source_evidence: BPTMetricSupportSourceEvidence | None = None def __post_init__(self) -> None: _require_instance(self.observation, ScientificMeasurementObservation, "observation") @@ -161,6 +186,9 @@ def __post_init__(self) -> None: _require_verified_artifact(self.source_artifact) _require_instance(self.acquisition, BenchPressThrowAcquisitionRecord, "acquisition") _require_instance(self.support, BenchPressThrowMetricSupport, "support") + support_source_evidence = self.support_source_evidence + if not isinstance(support_source_evidence, BPTMetricSupportSourceEvidence): + raise ValueError("support_source_evidence must be BPTMetricSupportSourceEvidence") if self.observation.identity != self.identity: raise ValueError("BPT evidence observation and identity do not agree") if self.identity.semantic.measurand != BPT_BAR_VELOCITY_MEASURAND: @@ -207,12 +235,330 @@ def __post_init__(self) -> None: ) if len(output_runs) != 1 or output_runs[0].method != BPT_SOURCE_VELOCITY_SERIES_OPERATION: raise ValueError("BPT source evidence must preserve its registered processing run") + normalized_support = normalize_bpt_metric_support( + support_source_evidence, + self.observation, + self.series, + self.source_artifact, + self.acquisition, + ) + if self.support != normalized_support: + raise ValueError("BPT metric support does not preserve upstream support authority") @property def source_series_digest(self) -> str: return self.series.canonical_content_digest() +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class BPTMetricSupportSourceEvidence: + """Upstream declaration that gives one BPT support interval authority.""" + + support_id: InstanceIdentifier + source_context: ObservationContext + source_observation_id: InstanceIdentifier + source_series_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + source_acquisition_id: InstanceIdentifier + source_measurement_identity_id: ScientificIdentifier + source_series_digest: str + source_timebase: BPTTimebase + source_sample_count: int + metric: RegistryReference + start_index: int + end_index: int + start_time_s: float + end_time_s: float + support_definition: str + boundary_method: RegistryReference = BPT_METRIC_SUPPORT_METHOD + boundary_convention: RegistryReference = BPT_METRIC_SUPPORT_BOUNDARY_CONVENTION + boundary_parameters: tuple[MetadataEntry, ...] = () + includes_post_release_samples: bool = False + value_origin: ValueOrigin = ValueOrigin.PROVIDER_DERIVED + upstream_processing_run_id: InstanceIdentifier + provenance: Provenance + + def __post_init__(self) -> None: + _require_instance(self.support_id, InstanceIdentifier, "support_id") + if self.support_id.instance_type != "support": + raise ValueError("support_id must identify a support") + _require_instance(self.source_context, ObservationContext, "source_context") + for field_name, value, expected in ( + ("source_observation_id", self.source_observation_id, "observation"), + ("source_series_id", self.source_series_id, "signal"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("source_acquisition_id", self.source_acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, field_name) + if value.instance_type != expected: + raise ValueError(f"{field_name} must identify a {expected}") + _require_instance( + self.source_measurement_identity_id, + ScientificIdentifier, + "source_measurement_identity_id", + ) + if self.source_measurement_identity_id.object_type != "measurement-identity": + raise ValueError("source_measurement_identity_id must identify a measurement") + _require_text(self.source_series_digest, "source_series_digest") + _require_instance(self.source_timebase, BPTTimebase, "source_timebase") + if type(self.source_sample_count) is not int or self.source_sample_count < 1: + raise ValueError("source_sample_count must be positive") + _require_instance(self.metric, RegistryReference, "metric") + if self.metric not in { + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + }: + raise ValueError("BPT support source must identify an implemented metric") + if ( + type(self.start_index) is not int + or type(self.end_index) is not int + or self.start_index < 0 + or self.end_index < self.start_index + ): + raise ValueError("BPT support source must satisfy 0 <= start <= end") + _finite(self.start_time_s, "support source start time") + _finite(self.end_time_s, "support source end time") + if self.end_time_s < self.start_time_s: + raise ValueError("support source end time must not precede start time") + _require_text(self.support_definition, "support_definition") + _require_instance(self.boundary_method, RegistryReference, "boundary_method") + _require_instance(self.boundary_convention, RegistryReference, "boundary_convention") + _require_tuple_items(self.boundary_parameters, MetadataEntry, "boundary_parameters") + if not isinstance(self.includes_post_release_samples, bool): + raise ValueError("includes_post_release_samples must be a boolean") + _require_instance(self.value_origin, ValueOrigin, "value_origin") + if self.value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: + raise ValueError("BPT support source must remain source/provider-origin") + _require_instance( + self.upstream_processing_run_id, + InstanceIdentifier, + "upstream_processing_run_id", + ) + if self.upstream_processing_run_id.instance_type != "processing-run": + raise ValueError("upstream_processing_run_id must identify a processing run") + _require_instance(self.provenance, Provenance, "provenance") + + +def _support_source_parameters( + source: BPTMetricSupportSourceEvidence, +) -> tuple[MetadataEntry, ...]: + return ( + MetadataEntry("support_id", source.support_id.qualified), + MetadataEntry("source_context_id", source.source_context.context_id.qualified), + MetadataEntry("source_observation_id", source.source_observation_id.qualified), + MetadataEntry("source_series_id", source.source_series_id.qualified), + MetadataEntry("source_artifact_id", source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", source.source_acquisition_id.qualified), + MetadataEntry( + "source_measurement_identity_id", source.source_measurement_identity_id.stable_id + ), + MetadataEntry("source_series_digest", source.source_series_digest), + MetadataEntry("source_timebase", canonical_json(source.source_timebase)), + MetadataEntry("source_sample_count", source.source_sample_count), + MetadataEntry("metric", source.metric.stable_id), + MetadataEntry("start_index", source.start_index), + MetadataEntry("end_index", source.end_index), + MetadataEntry("start_time_s", source.start_time_s), + MetadataEntry("end_time_s", source.end_time_s), + MetadataEntry("support_definition", source.support_definition), + MetadataEntry("boundary_method", source.boundary_method.stable_id), + MetadataEntry("boundary_convention", source.boundary_convention.stable_id), + MetadataEntry("boundary_parameters", canonical_json(source.boundary_parameters)), + MetadataEntry("includes_post_release_samples", source.includes_post_release_samples), + MetadataEntry("value_origin", source.value_origin.value), + ) + + +def _validate_support_source_for_evidence( + source: BPTMetricSupportSourceEvidence, + observation: ScientificMeasurementObservation, + series: BenchPressThrowVelocitySeries, + source_artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, +) -> None: + _require_instance(source, BPTMetricSupportSourceEvidence, "support_source_evidence") + _require_instance(observation, ScientificMeasurementObservation, "observation") + _require_instance(series, BenchPressThrowVelocitySeries, "series") + _require_verified_artifact(source_artifact) + _require_instance(acquisition, BenchPressThrowAcquisitionRecord, "acquisition") + identity = observation.identity + if not isinstance(identity, BenchPressThrowMeasurementIdentity): + raise ValueError("BPT support source requires BPT measurement identity") + expected_digest = series.canonical_content_digest() + if ( + source.source_context != observation.context + or source.source_observation_id != observation.observation_id + or source.source_series_id != series.series_id + or source.source_artifact_id != source_artifact.artifact_id + or source.source_acquisition_id != acquisition.acquisition_id + or source.source_measurement_identity_id != identity.identity_id + or source.source_series_digest != expected_digest + or source.source_timebase != series.timebase + or source.source_sample_count != len(series.samples) + ): + raise ValueError("BPT support source is not bound to the exact series observation") + if source_artifact.content_digest != expected_digest: + raise ValueError("BPT support source artifact digest does not match series") + if source.metric not in { + BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + }: + raise ValueError("BPT support source metric is not implemented") + if source.boundary_method != BPT_METRIC_SUPPORT_METHOD: + raise ValueError("BPT support boundary method is not registered") + if source.boundary_convention != BPT_METRIC_SUPPORT_BOUNDARY_CONVENTION: + raise ValueError("BPT support boundary convention is not registered") + if source.end_index >= len(series.samples): + raise ValueError("BPT support source end index is outside the series") + if ( + source.start_time_s != series.samples[source.start_index][0] + or source.end_time_s != series.samples[source.end_index][0] + ): + raise ValueError("BPT support source times must equal exact sample timestamps") + if identity.semantic.metric_definition != BPT_VELOCITY_SERIES_METRIC: + raise ValueError("BPT support source observation is not a velocity series") + if identity.processing.registered_operation != BPT_SOURCE_VELOCITY_SERIES_OPERATION: + raise ValueError("BPT support source observation uses the wrong operation") + if observation.result.status is not ResultStatus.VALID: + raise ValueError("BPT support source observation must be valid") + if observation.result.classification.value_origin is not source.value_origin: + raise ValueError("BPT support source origin does not match observation") + provenance = source.provenance + artifact_items = tuple( + item + for item in provenance.source_artifacts + if item.artifact_id == source_artifact.artifact_id + ) + acquisition_items = tuple( + item + for item in provenance.acquisitions + if item.acquisition_id == acquisition.acquisition_id + ) + if artifact_items != (source_artifact,) or acquisition_items != (acquisition,): + raise ValueError("BPT support source provenance does not preserve artifact/acquisition") + run = next( + (item for item in provenance.processing_runs if item.output_entity_id == source.support_id), + None, + ) + if run is None or run.processing_run_id != source.upstream_processing_run_id: + raise ValueError("BPT support source provenance must preserve its producing run") + if run.method != BPT_METRIC_SUPPORT_SOURCE_OPERATION: + raise ValueError("BPT support source run uses an unregistered method") + if run.parameters != _support_source_parameters(source): + raise ValueError("BPT support source run parameters do not reproduce") + if source_artifact.artifact_id not in run.source_artifact_ids: + raise ValueError("BPT support source run omits source artifact") + required_edges = ( + (source.source_observation_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_series_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_artifact_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_acquisition_id.qualified, LineageRelation.DERIVED_FROM), + (BPT_METRIC_SUPPORT_QUALIFICATION_RULE.stable_id, LineageRelation.SUPPORTED_BY), + ) + for from_id, relation in required_edges: + if not any( + edge.from_id == from_id + and edge.to_id == run.processing_run_id.qualified + and edge.relation is relation + for edge in provenance.lineage_edges + ): + raise ValueError("BPT support source provenance omits an authority lineage edge") + if not any( + edge.from_id == run.processing_run_id.qualified + and edge.to_id == source.support_id.qualified + and edge.relation is LineageRelation.PRODUCED + for edge in provenance.lineage_edges + ): + raise ValueError("BPT support source provenance omits the produced support edge") + + +def normalize_bpt_metric_support( + source: BPTMetricSupportSourceEvidence, + observation: ScientificMeasurementObservation, + series: BenchPressThrowVelocitySeries, + source_artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, +) -> BenchPressThrowMetricSupport: + """Normalize upstream support authority into the metric-support identity.""" + + _validate_support_source_for_evidence(source, observation, series, source_artifact, acquisition) + return BenchPressThrowMetricSupport( + support_id=source.support_id, + metric=source.metric, + start_index=source.start_index, + end_index=source.end_index, + start_time_s=source.start_time_s, + end_time_s=source.end_time_s, + support_definition=source.support_definition, + source_series_digest=source.source_series_digest, + includes_post_release_samples=source.includes_post_release_samples, + boundary_method=source.boundary_method, + boundary_convention=source.boundary_convention, + boundary_parameters=source.boundary_parameters, + authority_source_id=source.support_id, + ) + + +def _merge_provenance(left: Provenance, right: Provenance) -> Provenance: + artifacts = list(left.source_artifacts) + for artifact_item in right.source_artifacts: + prior_artifact = next( + (value for value in artifacts if value.artifact_id == artifact_item.artifact_id), + None, + ) + if prior_artifact is not None and prior_artifact != artifact_item: + raise ValueError("BPT provenance contains conflicting source artifacts") + if prior_artifact is None: + artifacts.append(artifact_item) + acquisitions = list(left.acquisitions) + for acquisition_item in right.acquisitions: + prior_acquisition = next( + ( + value + for value in acquisitions + if value.acquisition_id == acquisition_item.acquisition_id + ), + None, + ) + if prior_acquisition is not None and prior_acquisition != acquisition_item: + raise ValueError("BPT provenance contains conflicting acquisitions") + if prior_acquisition is None: + acquisitions.append(acquisition_item) + runs = list(left.processing_runs) + for run_item in right.processing_runs: + prior_run = next( + (value for value in runs if value.processing_run_id == run_item.processing_run_id), + None, + ) + if prior_run is not None and prior_run != run_item: + raise ValueError("BPT provenance contains conflicting processing runs") + if prior_run is None: + runs.append(run_item) + edges = list(left.lineage_edges) + for edge_item in right.lineage_edges: + if edge_item not in edges: + edges.append(edge_item) + evidence = list(left.evidence_references) + for evidence_item in right.evidence_references: + if evidence_item not in evidence: + evidence.append(evidence_item) + traceability = list(left.metrological_traceability) + for traceability_item in right.metrological_traceability: + if traceability_item not in traceability: + traceability.append(traceability_item) + return Provenance( + provenance_id=left.provenance_id, + source_artifacts=tuple(artifacts), + acquisitions=tuple(acquisitions), + processing_runs=tuple(runs), + lineage_edges=tuple(edges), + evidence_references=tuple(evidence), + metrological_traceability=tuple(traceability), + recorded_at=left.recorded_at or right.recorded_at, + ) + + def create_bpt_velocity_series_evidence( *, observation_id: InstanceIdentifier, @@ -222,7 +568,7 @@ def create_bpt_velocity_series_evidence( series: BenchPressThrowVelocitySeries, source_artifact: BenchPressThrowSourceArtifact, acquisition: BenchPressThrowAcquisitionRecord, - support: BenchPressThrowMetricSupport, + support: BPTMetricSupportSourceEvidence | BenchPressThrowMetricSupport, evidence_references: tuple[EvidenceReference, ...] = (), recorded_at: datetime_module.datetime | None = None, ) -> BenchPressThrowVelocitySeriesEvidence: @@ -233,6 +579,11 @@ def create_bpt_velocity_series_evidence( _require_instance(series, BenchPressThrowVelocitySeries, "series") _require_verified_artifact(source_artifact) _require_instance(acquisition, BenchPressThrowAcquisitionRecord, "acquisition") + if not isinstance(support, BPTMetricSupportSourceEvidence): + raise ValueError( + "typed upstream BPT metric support source evidence is required; " + "raw support is not authority" + ) if observation_id.instance_type != "observation" or result_id.instance_type != "result": raise ValueError("BPT evidence requires observation and result identifiers") if identity.processing.registered_operation != BPT_SOURCE_VELOCITY_SERIES_OPERATION: @@ -315,13 +666,21 @@ def create_bpt_velocity_series_evidence( ), provenance=provenance, ) + normalized_support = normalize_bpt_metric_support( + support, observation, series, source_artifact, acquisition + ) + observation = replace( + observation, + provenance=_merge_provenance(observation.provenance, support.provenance), + ) return BenchPressThrowVelocitySeriesEvidence( observation=observation, identity=identity, series=series, source_artifact=source_artifact, acquisition=acquisition, - support=support, + support=normalized_support, + support_source_evidence=support, ) @@ -371,13 +730,17 @@ def build_bpt_provider_metric_observation( raise ValueError("BPT provider power must use watts") if metric not in power_metrics and unit != METER_PER_SECOND: raise ValueError("BPT provider velocity must use m/s") - measurand = ( - BPT_BAR_VELOCITY_MEASURAND - if unit == METER_PER_SECOND - else BPT_MEAN_POWER_MEASURAND - if metric == BPT_PROVIDER_MEAN_POWER_METRIC - else BPT_PEAK_POWER_MEASURAND - ) + if metric in { + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + }: + measurand = BPT_BAR_VELOCITY_MEASURAND + elif metric == BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC: + measurand = BPT_MEAN_PROPULSIVE_VELOCITY_MEASURAND + elif metric == BPT_PROVIDER_MEAN_POWER_METRIC: + measurand = BPT_MEAN_POWER_MEASURAND + else: + measurand = BPT_PEAK_POWER_MEASURAND parameters = ( MetadataEntry("provider_metric", metric.stable_id), MetadataEntry("value_origin", value_origin.value), @@ -567,6 +930,8 @@ def build_bpt_qualification_source_observation( observation_id = output_observation_id or InstanceIdentifier( "observation", f"bpt-qualification:{target_observation.observation_id.value}" ) + if observation_id == target_observation.observation_id: + raise ValueError("BPT qualification observation must differ from target") parameters = ( MetadataEntry("target_observation_id", target_observation.observation_id.qualified), MetadataEntry("source_status", reported_status.value), @@ -757,11 +1122,13 @@ def require_qualified_bpt( __all__ = [ + "BPTMetricSupportSourceEvidence", "BPTSourceQualificationEvidence", "BenchPressThrowVelocitySeriesEvidence", "build_bpt_provider_metric_observation", "build_bpt_qualification_source_observation", "create_bpt_velocity_series_evidence", + "normalize_bpt_metric_support", "normalize_bpt_qualification", "require_qualified_bpt", ] diff --git a/src/dynamislm/measurement/bench_press_throw/registry.py b/src/dynamislm/measurement/bench_press_throw/registry.py index 5a82893..042a378 100644 --- a/src/dynamislm/measurement/bench_press_throw/registry.py +++ b/src/dynamislm/measurement/bench_press_throw/registry.py @@ -54,6 +54,16 @@ def _unit(key: str, label: str) -> UnitReference: "bench-press-throw-source-qualification-v1", "BPT source qualification V1", ) +BPT_METRIC_SUPPORT_SOURCE_OPERATION = _reference( + "processing-method", + "bench-press-throw-metric-support-source-v1", + "BPT metric support source V1", +) +BPT_METRIC_SUPPORT_QUALIFICATION_RULE = _reference( + "source-adjudication-rule", + "bench-press-throw-metric-support-qualification-v1", + "BPT metric support qualification V1", +) BPT_VELOCITY_SERIES_SCHEMA = _reference( "schema", "bench-press-throw-velocity-series-v1", "BPT velocity series V1" ) @@ -139,6 +149,11 @@ def _unit(key: str, label: str) -> UnitReference: BPT_METRIC_SUPPORT_METHOD = _reference( "support-method", "bench-press-throw-metric-specific-support-v1", "BPT metric-specific support" ) +BPT_METRIC_SUPPORT_BOUNDARY_CONVENTION = _reference( + "boundary-convention", + "bench-press-throw-metric-support-inclusive-v1", + "BPT inclusive metric support boundary convention", +) BPT_TIME_WEIGHTED_TRAPEZOIDAL_METHOD = _reference( "integration-method", "bench-press-throw-sample-attached-trapezoidal-v1", diff --git a/src/dynamislm/measurement/drop_jump/comparability.py b/src/dynamislm/measurement/drop_jump/comparability.py index f3d8fd6..38bd576 100644 --- a/src/dynamislm/measurement/drop_jump/comparability.py +++ b/src/dynamislm/measurement/drop_jump/comparability.py @@ -61,6 +61,37 @@ def generic(self) -> ComparabilityRequest: ) +def _fallback_observation_id(item: object, side: str) -> InstanceIdentifier: + if isinstance(item, DropJumpMetricResult): + try: + observation_id = item.observation.observation_id + if isinstance(observation_id, InstanceIdentifier): + return observation_id + except (AttributeError, TypeError, ValueError): + pass + return InstanceIdentifier("observation", f"invalid-dj-{side}") + + +def _fallback_request(left: object, right: object, claim: object) -> ComparabilityRequest: + safe_claim = claim if isinstance(claim, str) and claim.strip() else "invalid DJ comparison" + left_id = _fallback_observation_id(left, "left") + right_id = _fallback_observation_id(right, "right") + if left_id == right_id: + right_id = InstanceIdentifier("observation", f"{right_id.value}:right-envelope") + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash({"left": left_id, "right": right_id, "claim": safe_claim}).removeprefix( + "sha256:" + )[:24], + ) + return ComparabilityRequest( + request_id=request_id, + left_observation_id=left_id, + right_observation_id=right_id, + claim=safe_claim, + ) + + def _result( request: ComparabilityRequest, state: ComparabilityState, @@ -184,29 +215,23 @@ def compare_drop_jump_metric_results( ) -> ComparabilityResult: """Adjudicate two DJ outputs without accepting a manual override.""" - left_token = ( - left.observation.observation_id - if isinstance(left, DropJumpMetricResult) - else InstanceIdentifier("observation", "invalid-left") - ) - right_token = ( - right.observation.observation_id - if isinstance(right, DropJumpMetricResult) - else InstanceIdentifier("observation", "invalid-right") - ) - request_id = InstanceIdentifier( - "comparability-request", - canonical_hash( - { - "left": left_token, - "right": right_token, - "claim": claim, - } - ).removeprefix("sha256:")[:24], - ) try: + if not isinstance(claim, str) or not claim.strip(): + raise ValueError("DJ comparison claim must be non-empty text") _require_instance(left, DropJumpMetricResult, "left") _require_instance(right, DropJumpMetricResult, "right") + left_token = left.observation.observation_id + right_token = right.observation.observation_id + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash( + { + "left": left_token, + "right": right_token, + "claim": claim, + } + ).removeprefix("sha256:")[:24], + ) request = ComparabilityRequest( request_id=request_id, left_observation_id=left.observation.observation_id, @@ -222,24 +247,13 @@ def compare_drop_jump_metric_results( ), ) except (AttributeError, TypeError, ValueError): - fallback = ComparabilityRequest( - request_id=request_id, - left_observation_id=left_token, - right_observation_id=right_token, - claim=claim or "invalid DJ comparison", - ) - if hasattr(left, "observation") and hasattr(right, "observation"): - return _result( - fallback, - ComparabilityState.NOT_COMPARABLE, - (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), - ) - return _result( - fallback, - ComparabilityState.INSUFFICIENT_INFORMATION, - (ComparabilityReasonCode.MISSING_METADATA,), - missing=("two typed DJ metric results",), + fallback = _fallback_request(left, right, claim) + missing = ( + ("two distinct observations",) + if fallback.right_observation_id.value.endswith(":right-envelope") + else ("two typed DJ metric results",) ) + return ComparabilityResult.insufficient(fallback, missing_information=missing) left_identity = left.observation.identity right_identity = right.observation.identity diff --git a/src/dynamislm/measurement/drop_jump/events.py b/src/dynamislm/measurement/drop_jump/events.py index 9f459f4..45ff413 100644 --- a/src/dynamislm/measurement/drop_jump/events.py +++ b/src/dynamislm/measurement/drop_jump/events.py @@ -7,15 +7,17 @@ from enum import StrEnum from dynamislm.measurement.drop_jump.identity import ( + DropJumpAcquisitionRecord, DropJumpMeasurementIdentity, + DropJumpSourceArtifact, DropJumpTimebase, ) from dynamislm.measurement.drop_jump.registry import ( DROP_JUMP_REBOUND_TAKEOFF_EVENT_DEFINITION, DROP_JUMP_REBOUND_TAKEOFF_EVENT_METHOD, - DROP_JUMP_SOFTWARE_VERSION, DROP_JUMP_SUBSEQUENT_LANDING_EVENT_DEFINITION, DROP_JUMP_SUBSEQUENT_LANDING_EVENT_METHOD, + DROP_JUMP_TEST_FAMILY, DROP_JUMP_TOUCHDOWN_EVENT_DEFINITION, DROP_JUMP_TOUCHDOWN_EVENT_METHOD, RES68_DECISION_EXPLOSIVE_TEST_FAMILY, @@ -30,12 +32,10 @@ _require_tuple_items, require_tuple, ) -from dynamislm.measurement.observation import ScientificMeasurementObservation +from dynamislm.measurement.observation import ObservationContext, ScientificMeasurementObservation +from dynamislm.measurement.taxonomy import ValueOrigin from dynamislm.provenance.models import ( - EvidenceReference, - LineageEdge, LineageRelation, - ProcessingRun, Provenance, ) from dynamislm.refusal.models import RefusalClass, RefusalReasonCode, RefusalResult, RefusalStatus @@ -147,10 +147,291 @@ def __post_init__(self) -> None: } +@register_serializable_type +@dataclass(frozen=True, slots=True) +class DropJumpEventSourceEvidence: + """Upstream declaration that gives one DJ event occurrence authority.""" + + source_event_id: InstanceIdentifier + source_context: ObservationContext + source_observation_id: InstanceIdentifier + source_signal_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + source_acquisition_id: InstanceIdentifier + source_measurement_identity: DropJumpMeasurementIdentity + source_timebase: DropJumpTimebase + source_series_digest: str + source_sample_count: int + label: DropJumpEventLabel + sample_index: int + event_time_s: float + detector_method: DropJumpEventDetectorMethod + detector_parameters: DropJumpEventDetectorParameters + status: DropJumpEventOccurrenceStatus + qc_codes: tuple[str, ...] + source_value_origin: ValueOrigin + upstream_processing_run_id: InstanceIdentifier + provenance: Provenance + preceding_event_id: InstanceIdentifier | None = None + + def __post_init__(self) -> None: + _require_instance(self.source_event_id, InstanceIdentifier, "source_event_id") + if self.source_event_id.instance_type != "event-occurrence": + raise ValueError("source_event_id must identify an event occurrence") + _require_instance(self.source_context, ObservationContext, "source_context") + for field_name, value, expected in ( + ("source_observation_id", self.source_observation_id, "observation"), + ("source_signal_id", self.source_signal_id, "signal"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("source_acquisition_id", self.source_acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, field_name) + if value.instance_type != expected: + raise ValueError(f"{field_name} must identify a {expected}") + _require_instance( + self.source_measurement_identity, + DropJumpMeasurementIdentity, + "source_measurement_identity", + ) + _require_instance(self.source_timebase, DropJumpTimebase, "source_timebase") + _require_text(self.source_series_digest, "source_series_digest") + if type(self.source_sample_count) is not int or self.source_sample_count < 1: + raise ValueError("source_sample_count must be positive") + _require_enum(self.label, DropJumpEventLabel, "label") + if ( + type(self.sample_index) is not int + or self.sample_index < 0 + or self.sample_index >= self.source_sample_count + ): + raise ValueError("source event sample_index must be inside source sample support") + _finite(self.event_time_s, "source event time") + if self.source_timebase.time_at(self.sample_index) != self.event_time_s: + raise ValueError("source event time must equal the exact source timebase sample") + _require_instance(self.detector_method, DropJumpEventDetectorMethod, "detector_method") + if self.detector_method != _DETECTOR_BY_LABEL[self.label]: + raise ValueError("source event detector method is not the registered DJ method") + if self.detector_method.event_definition.label is not self.label: + raise ValueError("source event label does not match detector method") + _require_instance( + self.detector_parameters, + DropJumpEventDetectorParameters, + "detector_parameters", + ) + _require_enum(self.status, DropJumpEventOccurrenceStatus, "status") + _require_tuple_items(self.qc_codes, str, "qc_codes") + _require_enum(self.source_value_origin, ValueOrigin, "source_value_origin") + if self.source_value_origin not in { + ValueOrigin.SOURCE_REPORTED, + ValueOrigin.PROVIDER_DERIVED, + }: + raise ValueError("DJ event source origin must remain source/provider-origin") + _require_instance( + self.upstream_processing_run_id, + InstanceIdentifier, + "upstream_processing_run_id", + ) + if self.upstream_processing_run_id.instance_type != "processing-run": + raise ValueError("upstream_processing_run_id must identify a processing run") + _require_instance(self.provenance, Provenance, "provenance") + expected_signal_id = InstanceIdentifier( + "signal", f"drop-jump:{self.source_series_digest.removeprefix('sha256:')[:24]}" + ) + if self.source_signal_id != expected_signal_id: + raise ValueError("source event signal does not preserve source-series identity") + source_artifacts = tuple( + item + for item in self.provenance.source_artifacts + if item.artifact_id == self.source_artifact_id + ) + source_acquisitions = tuple( + item + for item in self.provenance.acquisitions + if item.acquisition_id == self.source_acquisition_id + ) + if ( + len(source_artifacts) != 1 + or not isinstance(source_artifacts[0], DropJumpSourceArtifact) + or not source_artifacts[0].immutable + or source_artifacts[0].status.value != "VERIFIED" + or source_artifacts[0].content_digest != self.source_series_digest + or len(source_acquisitions) != 1 + or not isinstance(source_acquisitions[0], DropJumpAcquisitionRecord) + ): + raise ValueError("source event provenance must preserve a verified source series") + run = next( + ( + item + for item in self.provenance.processing_runs + if item.output_entity_id == self.source_event_id + ), + None, + ) + if run is None or run.processing_run_id != self.upstream_processing_run_id: + raise ValueError("source event provenance must preserve its producing run") + if run.method != self.detector_method.reference: + raise ValueError("source event producing run method does not match detector") + if run.parameters != _event_source_parameters(self): + raise ValueError("source event producing run parameters do not reproduce") + if self.source_artifact_id not in run.source_artifact_ids: + raise ValueError("source event producing run omits source artifact") + for from_id, relation in ( + (self.source_observation_id.qualified, LineageRelation.DERIVED_FROM), + (self.source_signal_id.qualified, LineageRelation.DERIVED_FROM), + (self.source_artifact_id.qualified, LineageRelation.DERIVED_FROM), + (self.source_acquisition_id.qualified, LineageRelation.DERIVED_FROM), + (self.detector_method.decision_reference.stable_id, LineageRelation.SUPPORTED_BY), + ): + if not any( + edge.from_id == from_id + and edge.to_id == run.processing_run_id.qualified + and edge.relation is relation + for edge in self.provenance.lineage_edges + ): + raise ValueError("source event provenance omits an authority lineage edge") + if not any( + edge.from_id == run.processing_run_id.qualified + and edge.to_id == self.source_event_id.qualified + and edge.relation is LineageRelation.PRODUCED + for edge in self.provenance.lineage_edges + ): + raise ValueError("source event provenance omits the produced event edge") + if self.preceding_event_id is not None: + _require_instance(self.preceding_event_id, InstanceIdentifier, "preceding_event_id") + if self.preceding_event_id.instance_type != "event-occurrence": + raise ValueError("preceding_event_id must identify an event occurrence") + + +def _event_source_parameters( + source: DropJumpEventSourceEvidence, +) -> tuple[MetadataEntry, ...]: + return ( + MetadataEntry("source_event_id", source.source_event_id.qualified), + MetadataEntry("source_observation_id", source.source_observation_id.qualified), + MetadataEntry("source_signal_id", source.source_signal_id.qualified), + MetadataEntry("source_artifact_id", source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", source.source_acquisition_id.qualified), + MetadataEntry("source_series_digest", source.source_series_digest), + MetadataEntry("source_sample_count", source.source_sample_count), + MetadataEntry("label", source.label.value), + MetadataEntry("sample_index", source.sample_index), + MetadataEntry("event_time_s", source.event_time_s), + MetadataEntry("detector_parameters", canonical_hash(source.detector_parameters)), + MetadataEntry("status", source.status.value), + MetadataEntry("qc_codes", canonical_hash(source.qc_codes)), + MetadataEntry("source_value_origin", source.source_value_origin.value), + MetadataEntry( + "preceding_event_id", + source.preceding_event_id.qualified if source.preceding_event_id is not None else None, + ), + ) + + +def _validate_event_source_evidence( + source: DropJumpEventSourceEvidence, + source_observation: ScientificMeasurementObservation, +) -> None: + _require_instance(source, DropJumpEventSourceEvidence, "source_event_evidence") + _require_instance(source_observation, ScientificMeasurementObservation, "source_observation") + identity = source_observation.identity + if not isinstance(identity, DropJumpMeasurementIdentity): + raise ValueError("DJ event source observation requires DJ identity") + if ( + source.source_context != source_observation.context + or source.source_observation_id != source_observation.observation_id + or source.source_measurement_identity != identity + or source.source_timebase != identity.acquisition.timebase + ): + raise ValueError("DJ event source is not bound to the source observation") + if source_observation.result.status.name != "VALID": + raise ValueError("DJ event source observation must be valid") + if source_observation.result.classification.value_origin is not source.source_value_origin: + raise ValueError("DJ event source origin does not match source observation") + if ( + identity.semantic.test_family != DROP_JUMP_TEST_FAMILY + or source.source_measurement_identity.semantic.test_family != DROP_JUMP_TEST_FAMILY + ): + raise ValueError("DJ event source identity has an inconsistent family") + source_artifacts = tuple( + item + for item in source.provenance.source_artifacts + if item.artifact_id == source.source_artifact_id + ) + source_acquisitions = tuple( + item + for item in source.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ) + if len(source_artifacts) != 1 or not isinstance(source_artifacts[0], DropJumpSourceArtifact): + raise ValueError("DJ event source provenance must preserve a typed source artifact") + if ( + not source_artifacts[0].immutable + or source_artifacts[0].status.value != "VERIFIED" + or len(source_acquisitions) != 1 + or not isinstance(source_acquisitions[0], DropJumpAcquisitionRecord) + ): + raise ValueError("DJ event source provenance must preserve verified artifact/acquisition") + observation_artifacts = tuple( + item + for item in source_observation.provenance.source_artifacts + if item.artifact_id == source.source_artifact_id + ) + observation_acquisitions = tuple( + item + for item in source_observation.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ) + if observation_artifacts != source_artifacts or observation_acquisitions != source_acquisitions: + raise ValueError("DJ event source is not bound to source provenance") + if source_artifacts[0].content_digest != source.source_series_digest: + raise ValueError("DJ event source digest does not match source artifact") + if ( + identity.acquisition.source_series_digest is not None + and identity.acquisition.source_series_digest != source.source_series_digest + ): + raise ValueError("DJ event source digest does not match source identity") + run = next( + ( + item + for item in source.provenance.processing_runs + if item.output_entity_id == source.source_event_id + ), + None, + ) + if run is None or run.processing_run_id != source.upstream_processing_run_id: + raise ValueError("DJ event source provenance must preserve its producing run") + if run.method != source.detector_method.reference: + raise ValueError("DJ event source run method does not match detector method") + if run.parameters != _event_source_parameters(source): + raise ValueError("DJ event source run parameters do not reproduce") + if source.source_artifact_id not in run.source_artifact_ids: + raise ValueError("DJ event source run omits source artifact") + for from_id, relation in ( + (source.source_observation_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_signal_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_artifact_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_acquisition_id.qualified, LineageRelation.DERIVED_FROM), + (source.detector_method.decision_reference.stable_id, LineageRelation.SUPPORTED_BY), + ): + if not any( + edge.from_id == from_id + and edge.to_id == run.processing_run_id.qualified + and edge.relation is relation + for edge in source.provenance.lineage_edges + ): + raise ValueError("DJ event source provenance omits an authority lineage edge") + if not any( + edge.from_id == run.processing_run_id.qualified + and edge.to_id == source.source_event_id.qualified + and edge.relation is LineageRelation.PRODUCED + for edge in source.provenance.lineage_edges + ): + raise ValueError("DJ event source provenance omits the produced event edge") + + @register_serializable_type @dataclass(frozen=True, slots=True) class DropJumpEventOccurrence: - """Sample-attached DJ event with complete source and detector lineage.""" + """Sample-attached DJ event preserving upstream detector lineage.""" occurrence_id: InstanceIdentifier definition: DropJumpEventDefinition @@ -170,6 +451,7 @@ class DropJumpEventOccurrence: qc_codes: tuple[str, ...] provenance: Provenance preceding_event_id: InstanceIdentifier | None = None + source_evidence: DropJumpEventSourceEvidence | None = None def __post_init__(self) -> None: if self.occurrence_id.instance_type != "event-occurrence": @@ -210,6 +492,30 @@ def __post_init__(self) -> None: _require_enum(self.status, DropJumpEventOccurrenceStatus, "status") _require_tuple_items(self.qc_codes, str, "qc_codes") _require_instance(self.provenance, Provenance, "provenance") + source_evidence = self.source_evidence + if not isinstance(source_evidence, DropJumpEventSourceEvidence): + raise ValueError("source_evidence must be DropJumpEventSourceEvidence") + if ( + source_evidence.source_event_id != self.occurrence_id + or source_evidence.source_observation_id != self.source_observation_id + or source_evidence.source_signal_id != self.source_signal_id + or source_evidence.source_artifact_id != self.source_artifact_id + or source_evidence.source_acquisition_id != self.source_acquisition_id + or source_evidence.source_measurement_identity != self.source_measurement_identity + or source_evidence.source_timebase != self.source_timebase + or source_evidence.source_series_digest != self.source_series_digest + or source_evidence.source_sample_count != self.source_sample_count + or source_evidence.label is not self.label + or source_evidence.sample_index != self.sample_index + or source_evidence.event_time_s != self.event_time_s + or source_evidence.detector_method != self.detector_method + or source_evidence.detector_parameters != self.detector_parameters + or source_evidence.status is not self.status + or source_evidence.qc_codes != self.qc_codes + or source_evidence.preceding_event_id != self.preceding_event_id + or self.provenance != source_evidence.provenance + ): + raise ValueError("DJ event occurrence does not preserve upstream source evidence") matching_runs = tuple( run for run in self.provenance.processing_runs @@ -225,175 +531,60 @@ def label(self) -> DropJumpEventLabel: return self.definition.label -def _event_provenance( - source: Provenance, - *, - occurrence_id: InstanceIdentifier, - source_observation_id: InstanceIdentifier, - detector: DropJumpEventDetectorMethod, - parameters: DropJumpEventDetectorParameters, -) -> Provenance: - run = ProcessingRun( - processing_run_id=InstanceIdentifier("processing-run", occurrence_id.value), - source_artifact_ids=tuple( - sorted( - (item.artifact_id for item in source.source_artifacts), - key=lambda item: item.qualified, - ) - ), - method=detector.reference, - parameters=parameters.parameters, - software_version=DROP_JUMP_SOFTWARE_VERSION, - output_entity_id=occurrence_id, - ) - if not run.source_artifact_ids: - raise ValueError("DJ event requires source artifact provenance") - edges = list(source.lineage_edges) - for artifact_id in run.source_artifact_ids: - edge = LineageEdge( - artifact_id.qualified, - run.processing_run_id.qualified, - LineageRelation.DERIVED_FROM, - ) - if edge not in edges: - edges.append(edge) - for source_id in ( - source_observation_id, - *tuple(item.artifact_id for item in source.source_artifacts), - *tuple(item.acquisition_id for item in source.acquisitions), - ): - edge = LineageEdge( - source_id.qualified, - run.processing_run_id.qualified, - LineageRelation.DERIVED_FROM, - ) - if edge not in edges: - edges.append(edge) - for evidence in (EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY),): - edge = LineageEdge( - evidence.reference.stable_id, - run.processing_run_id.qualified, - LineageRelation.SUPPORTED_BY, - ) - if edge not in edges: - edges.append(edge) - output_edge = LineageEdge( - run.processing_run_id.qualified, - occurrence_id.qualified, - LineageRelation.PRODUCED, - ) - if output_edge not in edges: - edges.append(output_edge) - return Provenance( - provenance_id=InstanceIdentifier("provenance", occurrence_id.value), - source_artifacts=source.source_artifacts, - acquisitions=source.acquisitions, - processing_runs=(*source.processing_runs, run), - lineage_edges=tuple(edges), - evidence_references=( - *source.evidence_references, - EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), - ), - metrological_traceability=source.metrological_traceability, - recorded_at=source.recorded_at, - ) - - def build_drop_jump_event_occurrence( *, source_observation: ScientificMeasurementObservation, - label: DropJumpEventLabel, - sample_index: int, - source_sample_count: int, - source_series_digest: str, + label: DropJumpEventLabel | None = None, + sample_index: int | None = None, + source_sample_count: int | None = None, + source_series_digest: str | None = None, detector_parameters: DropJumpEventDetectorParameters | None = None, - status: DropJumpEventOccurrenceStatus = DropJumpEventOccurrenceStatus.VALID, - qc_codes: tuple[str, ...] = (), + status: DropJumpEventOccurrenceStatus | None = None, + qc_codes: tuple[str, ...] | None = None, occurrence_id: InstanceIdentifier | None = None, preceding_event_id: InstanceIdentifier | None = None, + source_evidence: DropJumpEventSourceEvidence | None = None, ) -> DropJumpEventOccurrence: - """Bind an already-detected sample to a registered DJ event method. - - This constructor records the exact supplied event; it does not sort, - interpolate, backshift, or repair a caller's event. - """ - - if not isinstance(source_observation, ScientificMeasurementObservation): - raise ValueError("source_observation must be a scientific observation") - if not isinstance(source_observation.identity, DropJumpMeasurementIdentity): - raise ValueError("source observation must carry DropJumpMeasurementIdentity") - _require_enum(label, DropJumpEventLabel, "label") - if type(sample_index) is not int or sample_index < 0: - raise ValueError("sample_index must be a non-negative integer") - if type(source_sample_count) is not int or source_sample_count < 1: - raise ValueError("source_sample_count must be a positive integer") - if sample_index >= source_sample_count: - raise ValueError("sample_index must be inside source sample support") - _require_text(source_series_digest, "source_series_digest") - timebase = source_observation.identity.acquisition.timebase - if timebase is None: - raise ValueError("DJ event source requires an explicit acquisition timebase") - if source_observation.identity.acquisition.raw_artifact is None: - raise ValueError("DJ event source requires a raw artifact identity") - if source_observation.identity.acquisition.acquisition_instance_id is None: - raise ValueError("DJ event source requires an acquisition identity") - if source_observation.identity.acquisition.raw_artifact not in { - artifact.artifact_id for artifact in source_observation.provenance.source_artifacts - }: - raise ValueError("DJ event source artifact is absent from source provenance") - if source_observation.identity.acquisition.acquisition_instance_id not in { - acquisition.acquisition_id for acquisition in source_observation.provenance.acquisitions - }: - raise ValueError("DJ event source acquisition is absent from source provenance") - if ( - source_observation.identity.acquisition.source_series_digest is not None - and source_observation.identity.acquisition.source_series_digest != source_series_digest - ): - raise ValueError("DJ event digest does not match source identity") - detector = _DETECTOR_BY_LABEL[label] - parameters = detector_parameters or DropJumpEventDetectorParameters() - event_time = timebase.time_at(sample_index) - digest = canonical_hash( - { - "label": label, - "sample_index": sample_index, - "source_observation_id": source_observation.observation_id, - "source_series_digest": source_series_digest, - "detector": detector, - "parameters": parameters, - } - ).removeprefix("sha256:")[:24] - output_id = occurrence_id or InstanceIdentifier("event-occurrence", f"drop-jump:{digest}") - if output_id.instance_type != "event-occurrence": - raise ValueError("occurrence_id must identify an event occurrence") - signal_id = InstanceIdentifier( - "signal", f"drop-jump:{source_series_digest.removeprefix('sha256:')[:24]}" + """Normalize an upstream DJ event declaration without claiming detection.""" + + if source_evidence is None: + raise ValueError("typed upstream DJ event source evidence is required") + _validate_event_source_evidence(source_evidence, source_observation) + supplied = ( + ("label", label, source_evidence.label), + ("sample_index", sample_index, source_evidence.sample_index), + ("source_sample_count", source_sample_count, source_evidence.source_sample_count), + ("source_series_digest", source_series_digest, source_evidence.source_series_digest), + ("detector_parameters", detector_parameters, source_evidence.detector_parameters), + ("status", status, source_evidence.status), + ("qc_codes", qc_codes, source_evidence.qc_codes), + ("preceding_event_id", preceding_event_id, source_evidence.preceding_event_id), ) + if any(value is not None and value != expected for _, value, expected in supplied): + raise ValueError("raw DJ event fields must match upstream source evidence") + output_id = occurrence_id or source_evidence.source_event_id + if output_id != source_evidence.source_event_id: + raise ValueError("occurrence_id must preserve the upstream event identity") return DropJumpEventOccurrence( occurrence_id=output_id, - definition=detector.event_definition, - source_observation_id=source_observation.observation_id, - source_signal_id=signal_id, - source_artifact_id=source_observation.identity.acquisition.raw_artifact, - source_acquisition_id=source_observation.identity.acquisition.acquisition_instance_id, - source_measurement_identity=source_observation.identity, - source_timebase=timebase, - source_series_digest=source_series_digest, - detector_method=detector, - detector_parameters=parameters, - source_sample_count=source_sample_count, - sample_index=sample_index, - event_time_s=event_time, - status=status, - qc_codes=qc_codes, - provenance=_event_provenance( - source_observation.provenance, - occurrence_id=output_id, - source_observation_id=source_observation.observation_id, - detector=detector, - parameters=parameters, - ), - preceding_event_id=preceding_event_id, + definition=source_evidence.detector_method.event_definition, + source_observation_id=source_evidence.source_observation_id, + source_signal_id=source_evidence.source_signal_id, + source_artifact_id=source_evidence.source_artifact_id, + source_acquisition_id=source_evidence.source_acquisition_id, + source_measurement_identity=source_evidence.source_measurement_identity, + source_timebase=source_evidence.source_timebase, + source_series_digest=source_evidence.source_series_digest, + detector_method=source_evidence.detector_method, + detector_parameters=source_evidence.detector_parameters, + source_sample_count=source_evidence.source_sample_count, + sample_index=source_evidence.sample_index, + event_time_s=source_evidence.event_time_s, + status=source_evidence.status, + qc_codes=source_evidence.qc_codes, + provenance=source_evidence.provenance, + preceding_event_id=source_evidence.preceding_event_id, + source_evidence=source_evidence, ) @@ -496,6 +687,7 @@ def validate_drop_jump_event_order( "DropJumpEventLabel", "DropJumpEventOccurrence", "DropJumpEventOccurrenceStatus", + "DropJumpEventSourceEvidence", "build_drop_jump_event_occurrence", "validate_drop_jump_event_order", ] diff --git a/src/dynamislm/measurement/drop_jump/metrics.py b/src/dynamislm/measurement/drop_jump/metrics.py index 6b18d48..a6bd077 100644 --- a/src/dynamislm/measurement/drop_jump/metrics.py +++ b/src/dynamislm/measurement/drop_jump/metrics.py @@ -12,6 +12,7 @@ DropJumpEventLabel, DropJumpEventOccurrence, DropJumpEventOccurrenceStatus, + _validate_event_source_evidence, ) from dynamislm.measurement.drop_jump.identity import ( DropJumpAcquisitionIdentity, @@ -128,6 +129,14 @@ def __post_init__(self) -> None: _finite(value.value, "DJ metric result") if self.observation.result.status is not ResultStatus.VALID: raise ValueError("DJ metric result must be valid") + if not self.source_events: + raise ValueError("DJ metric result must preserve source event evidence") + source_observation_ids = {item.observation_id for item in self.source_observations} + if any( + event.source_observation_id not in source_observation_ids + for event in self.source_events + ): + raise ValueError("DJ metric result must preserve event source observations") runs = tuple( run for run in self.observation.provenance.processing_runs @@ -277,6 +286,10 @@ def _validate_source( for event in events ): raise ValueError("DJ event and source observation lineage do not match") + for event in events: + if event.source_evidence is None: + raise ValueError("DJ event must preserve typed source evidence") + _validate_event_source_evidence(event.source_evidence, source_observation) if source_observation.result.status is not ResultStatus.VALID: raise ValueError("DJ source observation must be valid") if qualification is not None: diff --git a/src/dynamislm/measurement/drop_jump/qualification.py b/src/dynamislm/measurement/drop_jump/qualification.py index 3dbea59..1b39877 100644 --- a/src/dynamislm/measurement/drop_jump/qualification.py +++ b/src/dynamislm/measurement/drop_jump/qualification.py @@ -262,6 +262,8 @@ def build_drop_jump_qualification_source_observation( raise ValueError("DJ qualification ingestion requires source/provider origin") _require_verified_artifact(source_artifact) _require_instance(acquisition, DropJumpAcquisitionRecord, "acquisition") + if adjudication_rule is not None: + _require_instance(adjudication_rule, RegistryReference, "adjudication_rule") target_identity = target_observation.identity if not isinstance(target_identity, DropJumpMeasurementIdentity): raise ValueError("target observation requires a DJ measurement identity") @@ -281,8 +283,6 @@ def build_drop_jump_qualification_source_observation( else () ), ) - if adjudication_rule is not None: - _require_instance(adjudication_rule, RegistryReference, "adjudication_rule") identity = DropJumpMeasurementIdentity( identity_id=ScientificIdentifier( "dynamislm", diff --git a/src/dynamislm/measurement/medicine_ball_throw/comparability.py b/src/dynamislm/measurement/medicine_ball_throw/comparability.py index 7bc3a39..350cd3d 100644 --- a/src/dynamislm/measurement/medicine_ball_throw/comparability.py +++ b/src/dynamislm/measurement/medicine_ball_throw/comparability.py @@ -27,7 +27,6 @@ MEDICINE_BALL_THROW_TEST_FAMILY, RES68_DECISION_EXPLOSIVE_TEST_FAMILY, ) -from dynamislm.measurement.observation import ScientificMeasurementObservation from dynamislm.serialization import canonical_hash, register_serializable_type @@ -48,6 +47,37 @@ def __post_init__(self) -> None: raise ValueError("MBT claim must not be empty") +def _fallback_observation_id(item: object, side: str) -> InstanceIdentifier: + if isinstance(item, MedicineBallThrowMetricResult): + try: + observation_id = item.observation.observation_id + if isinstance(observation_id, InstanceIdentifier): + return observation_id + except (AttributeError, TypeError, ValueError): + pass + return InstanceIdentifier("observation", f"invalid-mbt-{side}") + + +def _fallback_request(left: object, right: object, claim: object) -> ComparabilityRequest: + safe_claim = claim if isinstance(claim, str) and claim.strip() else "invalid MBT comparison" + left_id = _fallback_observation_id(left, "left") + right_id = _fallback_observation_id(right, "right") + if left_id == right_id: + right_id = InstanceIdentifier("observation", f"{right_id.value}:right-envelope") + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash({"left": left_id, "right": right_id, "claim": safe_claim}).removeprefix( + "sha256:" + )[:24], + ) + return ComparabilityRequest( + request_id=request_id, + left_observation_id=left_id, + right_observation_id=right_id, + claim=safe_claim, + ) + + def _result( request: ComparabilityRequest, state: ComparabilityState, @@ -143,21 +173,19 @@ def compare_mbt_metric_results( right: MedicineBallThrowMetricResult, claim: str = "compare MBT metric results", ) -> ComparabilityResult: - left_id = getattr(left, "observation", None) - right_id = getattr(right, "observation", None) - request_id = InstanceIdentifier( - "comparability-request", - canonical_hash( - { - "left": getattr(left_id, "observation_id", "invalid-left"), - "right": getattr(right_id, "observation_id", "invalid-right"), - "claim": claim, - } - ).removeprefix("sha256:")[:24], - ) try: + if not isinstance(claim, str) or not claim.strip(): + raise ValueError("MBT comparison claim must be non-empty text") _require_instance(left, MedicineBallThrowMetricResult, "left") _require_instance(right, MedicineBallThrowMetricResult, "right") + left_id = left.observation.observation_id + right_id = right.observation.observation_id + request_id = InstanceIdentifier( + "comparability-request", + canonical_hash({"left": left_id, "right": right_id, "claim": claim}).removeprefix( + "sha256:" + )[:24], + ) request = ComparabilityRequest( request_id=request_id, left_observation_id=left.observation.observation_id, @@ -175,35 +203,13 @@ def compare_mbt_metric_results( ), ) except (AttributeError, TypeError, ValueError): - fallback = ComparabilityRequest( - request_id=request_id, - left_observation_id=InstanceIdentifier("observation", "invalid-left"), - right_observation_id=InstanceIdentifier("observation", "invalid-right"), - claim=claim or "invalid MBT comparison", - ) - if hasattr(left, "observation") and hasattr(right, "observation"): - left_observation = left.observation - right_observation = right.observation - if isinstance(left_observation, ScientificMeasurementObservation) and isinstance( - right_observation, ScientificMeasurementObservation - ): - fallback = ComparabilityRequest( - request_id=request_id, - left_observation_id=left_observation.observation_id, - right_observation_id=right_observation.observation_id, - claim=claim or "invalid MBT comparison", - ) - return _result( - fallback, - ComparabilityState.NOT_COMPARABLE, - (ComparabilityReasonCode.METRIC_FAMILY_MISMATCH,), - ) - return _result( - fallback, - ComparabilityState.INSUFFICIENT_INFORMATION, - (ComparabilityReasonCode.MISSING_METADATA,), - missing=("two typed MBT metric results",), + fallback = _fallback_request(left, right, claim) + missing = ( + ("two distinct observations",) + if fallback.right_observation_id.value.endswith(":right-envelope") + else ("two typed MBT metric results",) ) + return ComparabilityResult.insufficient(fallback, missing_information=missing) if left.metric != right.metric: return _result( request, diff --git a/src/dynamislm/measurement/medicine_ball_throw/identity.py b/src/dynamislm/measurement/medicine_ball_throw/identity.py index 5c4e8d0..a5ff8f3 100644 --- a/src/dynamislm/measurement/medicine_ball_throw/identity.py +++ b/src/dynamislm/measurement/medicine_ball_throw/identity.py @@ -15,6 +15,7 @@ ProcessingIdentity, RegistryReference, SamplingCharacteristics, + ScientificIdentifier, SemanticIdentity, _require_enum, _require_instance, @@ -24,10 +25,12 @@ require_tuple, ) from dynamislm.measurement.medicine_ball_throw.registry import ( + MBT_COORDINATE_SOURCE_OPERATION, + MBT_RELEASE_EVENT_SOURCE_OPERATION, MEDICINE_BALL_THROW_PROTOCOL_V1, MEDICINE_BALL_THROW_TEST_FAMILY, ) -from dynamislm.provenance.models import AcquisitionRecord, SourceArtifact +from dynamislm.provenance.models import AcquisitionRecord, Provenance, SourceArtifact from dynamislm.serialization import canonical_hash, register_serializable_type @@ -366,10 +369,91 @@ def __post_init__(self) -> None: _require_optional_instance(self.axis_or_frame, RegistryReference, "axis_or_frame") +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MBTCoordinateSourceEvidence: + """Upstream coordinate/contact adjudication bound to one MBT observation.""" + + source_coordinate_id: InstanceIdentifier + source_context_id: InstanceIdentifier + source_observation_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + source_acquisition_id: InstanceIdentifier + source_measurement_identity_id: ScientificIdentifier + protocol_reference: RegistryReference + origin_coordinate_m: float + endpoint_coordinate_m: float + coordinate_frame: RegistryReference + origin_convention: RegistryReference + endpoint_convention: RegistryReference + first_contact_no_roll_convention: RegistryReference + first_contact_observed: bool + no_roll_observed: bool + source_content_digest: str + provider: str | None + processing_method: RegistryReference = MBT_COORDINATE_SOURCE_OPERATION + method_parameters: tuple[MetadataEntry, ...] = () + source_value_origin: str = "SOURCE_REPORTED" + upstream_processing_run_id: InstanceIdentifier + provenance: Provenance + + def __post_init__(self) -> None: + for field_name, value, expected in ( + ("source_coordinate_id", self.source_coordinate_id, "coordinate-evidence"), + ("source_context_id", self.source_context_id, "context"), + ("source_observation_id", self.source_observation_id, "observation"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("source_acquisition_id", self.source_acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, field_name) + if value.instance_type != expected: + raise ValueError(f"{field_name} must identify a {expected}") + _require_instance( + self.source_measurement_identity_id, + ScientificIdentifier, + "source_measurement_identity_id", + ) + if self.source_measurement_identity_id.object_type != "measurement-identity": + raise ValueError("source_measurement_identity_id must identify a measurement") + _require_instance(self.protocol_reference, RegistryReference, "protocol_reference") + origin = _finite(self.origin_coordinate_m, "origin_coordinate_m") + endpoint = _finite(self.endpoint_coordinate_m, "endpoint_coordinate_m") + if endpoint < origin: + raise ValueError("MBT endpoint coordinate must not precede origin coordinate") + object.__setattr__(self, "origin_coordinate_m", origin) + object.__setattr__(self, "endpoint_coordinate_m", endpoint) + for field_name in ( + "coordinate_frame", + "origin_convention", + "endpoint_convention", + "first_contact_no_roll_convention", + ): + _require_instance(getattr(self, field_name), RegistryReference, field_name) + if not isinstance(self.first_contact_observed, bool) or not isinstance( + self.no_roll_observed, bool + ): + raise ValueError("MBT coordinate adjudications must be booleans") + _require_text(self.source_content_digest, "source_content_digest") + if self.provider is not None: + _require_text(self.provider, "provider") + _require_instance(self.processing_method, RegistryReference, "processing_method") + _require_tuple_items(self.method_parameters, MetadataEntry, "method_parameters") + if self.source_value_origin not in {"SOURCE_REPORTED", "PROVIDER_DERIVED"}: + raise ValueError("MBT coordinate source origin must remain source/provider-origin") + _require_instance( + self.upstream_processing_run_id, + InstanceIdentifier, + "upstream_processing_run_id", + ) + if self.upstream_processing_run_id.instance_type != "processing-run": + raise ValueError("upstream_processing_run_id must identify a processing run") + _require_instance(self.provenance, Provenance, "provenance") + + @register_serializable_type @dataclass(frozen=True, slots=True) class MBTCoordinateEvidence: - """Exact coordinate endpoints for one protocol-defined throw distance.""" + """Exact coordinate endpoints with optional upstream authority evidence.""" source_observation_id: InstanceIdentifier origin_coordinate_m: float @@ -382,6 +466,7 @@ class MBTCoordinateEvidence: no_roll_observed: bool source_artifact_id: InstanceIdentifier acquisition_id: InstanceIdentifier + source_evidence: MBTCoordinateSourceEvidence | None = None def __post_init__(self) -> None: for name, value, expected in ( @@ -409,12 +494,99 @@ def __post_init__(self) -> None: self.no_roll_observed, bool ): raise ValueError("MBT contact/no-roll observations must be booleans") + _require_optional_instance( + self.source_evidence, MBTCoordinateSourceEvidence, "source_evidence" + ) + if self.source_evidence is not None and ( + self.source_evidence.source_observation_id != self.source_observation_id + or self.source_evidence.source_artifact_id != self.source_artifact_id + or self.source_evidence.source_acquisition_id != self.acquisition_id + or self.source_evidence.origin_coordinate_m != self.origin_coordinate_m + or self.source_evidence.endpoint_coordinate_m != self.endpoint_coordinate_m + or self.source_evidence.coordinate_frame != self.coordinate_frame + or self.source_evidence.origin_convention != self.origin_convention + or self.source_evidence.endpoint_convention != self.endpoint_convention + or self.source_evidence.first_contact_no_roll_convention + != self.first_contact_no_roll_convention + or self.source_evidence.first_contact_observed != self.first_contact_observed + or self.source_evidence.no_roll_observed != self.no_roll_observed + ): + raise ValueError("MBT coordinate does not preserve upstream coordinate evidence") + + +@register_serializable_type +@dataclass(frozen=True, slots=True, kw_only=True) +class MBTReleaseEventSourceEvidence: + """Upstream release-event adjudication bound to one exact MBT sample.""" + + source_event_id: InstanceIdentifier + source_context_id: InstanceIdentifier + source_observation_id: InstanceIdentifier + source_series_id: InstanceIdentifier + source_artifact_id: InstanceIdentifier + source_acquisition_id: InstanceIdentifier + source_measurement_identity_id: ScientificIdentifier + source_series_digest: str + source_timebase: MBTTimebase + sample_index: int + event_time_s: float + release_method: RegistryReference + coordinate_frame: RegistryReference + protocol_reference: RegistryReference + provider: str | None + method_parameters: tuple[MetadataEntry, ...] = () + source_value_origin: str = "PROVIDER_DERIVED" + processing_method: RegistryReference = MBT_RELEASE_EVENT_SOURCE_OPERATION + upstream_processing_run_id: InstanceIdentifier + provenance: Provenance + + def __post_init__(self) -> None: + for field_name, value, expected in ( + ("source_event_id", self.source_event_id, "event-occurrence"), + ("source_context_id", self.source_context_id, "context"), + ("source_observation_id", self.source_observation_id, "observation"), + ("source_series_id", self.source_series_id, "signal"), + ("source_artifact_id", self.source_artifact_id, "artifact"), + ("source_acquisition_id", self.source_acquisition_id, "acquisition"), + ): + _require_instance(value, InstanceIdentifier, field_name) + if value.instance_type != expected: + raise ValueError(f"{field_name} must identify a {expected}") + _require_instance( + self.source_measurement_identity_id, + ScientificIdentifier, + "source_measurement_identity_id", + ) + if self.source_measurement_identity_id.object_type != "measurement-identity": + raise ValueError("source_measurement_identity_id must identify a measurement") + _require_text(self.source_series_digest, "source_series_digest") + _require_instance(self.source_timebase, MBTTimebase, "source_timebase") + if type(self.sample_index) is not int or self.sample_index < 0: + raise ValueError("MBT release source sample index must be non-negative") + _finite(self.event_time_s, "event_time_s") + _require_instance(self.release_method, RegistryReference, "release_method") + _require_instance(self.coordinate_frame, RegistryReference, "coordinate_frame") + _require_instance(self.protocol_reference, RegistryReference, "protocol_reference") + if self.provider is not None: + _require_text(self.provider, "provider") + _require_tuple_items(self.method_parameters, MetadataEntry, "method_parameters") + if self.source_value_origin not in {"SOURCE_REPORTED", "PROVIDER_DERIVED"}: + raise ValueError("MBT release source origin must remain source/provider-origin") + _require_instance(self.processing_method, RegistryReference, "processing_method") + _require_instance( + self.upstream_processing_run_id, + InstanceIdentifier, + "upstream_processing_run_id", + ) + if self.upstream_processing_run_id.instance_type != "processing-run": + raise ValueError("upstream_processing_run_id must identify a processing run") + _require_instance(self.provenance, Provenance, "provenance") @register_serializable_type @dataclass(frozen=True, slots=True) class MBTReleaseEvent: - """Explicit release event bound to an instrumented source trajectory.""" + """Explicit release event; authoritative use requires source evidence.""" event_id: InstanceIdentifier source_observation_id: InstanceIdentifier @@ -426,6 +598,7 @@ class MBTReleaseEvent: release_method: RegistryReference coordinate_frame: RegistryReference source_series_digest: str + source_evidence: MBTReleaseEventSourceEvidence | None = None def __post_init__(self) -> None: for name, value, expected in ( @@ -444,6 +617,22 @@ def __post_init__(self) -> None: _require_instance(self.release_method, RegistryReference, "release_method") _require_instance(self.coordinate_frame, RegistryReference, "coordinate_frame") _require_text(self.source_series_digest, "source_series_digest") + _require_optional_instance( + self.source_evidence, MBTReleaseEventSourceEvidence, "source_evidence" + ) + if self.source_evidence is not None and ( + self.source_evidence.source_event_id != self.event_id + or self.source_evidence.source_observation_id != self.source_observation_id + or self.source_evidence.source_series_id != self.source_series_id + or self.source_evidence.source_artifact_id != self.source_artifact_id + or self.source_evidence.source_acquisition_id != self.acquisition_id + or self.source_evidence.sample_index != self.sample_index + or self.source_evidence.event_time_s != self.event_time_s + or self.source_evidence.release_method != self.release_method + or self.source_evidence.coordinate_frame != self.coordinate_frame + or self.source_evidence.source_series_digest != self.source_series_digest + ): + raise ValueError("MBT release event does not preserve upstream event evidence") def canonical_mbt_trajectory_digest( @@ -480,6 +669,7 @@ def canonical_mbt_trajectory_digest( "MBTArtifactStatus", "MBTBodyPosture", "MBTCoordinateEvidence", + "MBTCoordinateSourceEvidence", "MBTHashAlgorithm", "MBTMeasurementIdentity", "MBTProcessingIdentity", @@ -487,6 +677,7 @@ def canonical_mbt_trajectory_digest( "MBTProtocolIdentity", "MBTQualificationStatus", "MBTReleaseEvent", + "MBTReleaseEventSourceEvidence", "MBTReleaseSemantics", "MBTSemanticIdentity", "MBTSensorModality", diff --git a/src/dynamislm/measurement/medicine_ball_throw/metrics.py b/src/dynamislm/measurement/medicine_ball_throw/metrics.py index e3e2da7..b7d3dca 100644 --- a/src/dynamislm/measurement/medicine_ball_throw/metrics.py +++ b/src/dynamislm/measurement/medicine_ball_throw/metrics.py @@ -31,6 +31,8 @@ from dynamislm.measurement.medicine_ball_throw.qualification import ( MBTReleaseVelocityEvidence, MBTSourceQualificationEvidence, + _merge_provenance, + normalize_mbt_coordinate_evidence, require_qualified_mbt, ) from dynamislm.measurement.medicine_ball_throw.registry import ( @@ -103,6 +105,35 @@ def __post_init__(self) -> None: _finite(value.value, "MBT result") if self.observation.result.status is not ResultStatus.VALID: raise ValueError("MBT result must be valid") + if ( + self.coordinate_evidence is not None + and self.coordinate_evidence.source_evidence is None + ): + raise ValueError("MBT coordinate result must preserve upstream coordinate evidence") + if self.metric == MBT_THROW_DISTANCE_METRIC and self.coordinate_evidence is None: + raise ValueError("MBT distance result must preserve coordinate evidence") + if self.metric == MBT_THROW_DISTANCE_METRIC: + assert self.coordinate_evidence is not None + coordinate_source = self.coordinate_evidence.source_evidence + assert coordinate_source is not None + source_observation = next( + ( + item + for item in self.source_observations + if item.observation_id == coordinate_source.source_observation_id + ), + None, + ) + if source_observation is None: + raise ValueError("MBT distance result is missing coordinate source observation") + normalize_mbt_coordinate_evidence( + coordinate_source, source_observation, self.coordinate_evidence + ) + if self.metric == MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC and ( + self.release_evidence is None + or self.release_evidence.release_event.source_evidence is None + ): + raise ValueError("MBT release result must preserve release-event evidence") runs = tuple( run for run in self.observation.provenance.processing_runs @@ -172,12 +203,21 @@ def _refusal( def _validate_distance_source( source_observation: ScientificMeasurementObservation, coordinate: MBTCoordinateEvidence, -) -> MedicineBallThrowMeasurementIdentity: +) -> tuple[MedicineBallThrowMeasurementIdentity, MBTCoordinateEvidence]: + _require_instance(coordinate, MBTCoordinateEvidence, "coordinate") if not isinstance(source_observation, ScientificMeasurementObservation): raise ValueError("MBT source observation is required") identity = source_observation.identity if not isinstance(identity, MedicineBallThrowMeasurementIdentity): raise ValueError("MBT source observation requires MBT identity") + if coordinate.source_evidence is None: + raise ValueError("MBT distance requires typed upstream coordinate evidence") + normalized_coordinate = normalize_mbt_coordinate_evidence( + coordinate.source_evidence, + source_observation, + coordinate, + ) + coordinate = normalized_coordinate protocol = identity.semantic.protocol_identity if protocol is None: raise ValueError("MBT protocol identity is required") @@ -210,7 +250,7 @@ def _validate_distance_source( raise ValueError("MBT throw type/posture is unresolved") if protocol.ball_mass_kg is None: raise ValueError("MBT ball mass is required") - return identity + return identity, coordinate def _output_identity( @@ -291,6 +331,15 @@ def _build_derived( if not isinstance(source_observation.identity, MedicineBallThrowMeasurementIdentity): raise ValueError("MBT source identity is required") _require_instance(source_observation.context, ObservationContext, "context") + base_provenance = source_observation.provenance + if coordinate_evidence is not None and coordinate_evidence.source_evidence is not None: + base_provenance = _merge_provenance( + base_provenance, coordinate_evidence.source_evidence.provenance + ) + if release_evidence is not None and release_evidence.release_event.source_evidence is not None: + base_provenance = _merge_provenance( + base_provenance, release_evidence.release_event.source_evidence.provenance + ) numeric = _finite(value, "MBT derived value") identity = _output_identity( source_observation.identity, @@ -322,7 +371,7 @@ def _build_derived( ), source_artifact_ids=tuple( sorted( - (item.artifact_id for item in source_observation.provenance.source_artifacts), + (item.artifact_id for item in base_provenance.source_artifacts), key=lambda item: item.qualified, ) ), @@ -333,7 +382,7 @@ def _build_derived( ) if not run.source_artifact_ids: raise ValueError("MBT derived result requires source artifacts") - edges = list(source_observation.provenance.lineage_edges) + edges = list(base_provenance.lineage_edges) for source_id in ( source_observation.observation_id, *(run.source_artifact_ids), @@ -343,7 +392,7 @@ def _build_derived( ) if edge not in edges: edges.append(edge) - for acquisition in source_observation.provenance.acquisitions: + for acquisition in base_provenance.acquisitions: edge = LineageEdge( acquisition.acquisition_id.qualified, run.processing_run_id.qualified, @@ -370,26 +419,25 @@ def _build_derived( provenance_id=InstanceIdentifier("provenance", output_id.value), source_artifacts=tuple( sorted( - (*source_observation.provenance.source_artifacts, output_artifact), + (*base_provenance.source_artifacts, output_artifact), key=lambda item: item.artifact_id.qualified, ) ), - acquisitions=source_observation.provenance.acquisitions, - processing_runs=(*source_observation.provenance.processing_runs, run), + acquisitions=base_provenance.acquisitions, + processing_runs=(*base_provenance.processing_runs, run), lineage_edges=tuple( sorted(edges, key=lambda item: (item.from_id, item.to_id, item.relation.value)) ), evidence_references=tuple( dict.fromkeys( ( - *source_observation.provenance.evidence_references, + *base_provenance.evidence_references, EvidenceReference(RES68_DECISION_EXPLOSIVE_TEST_FAMILY), ) ) ), - metrological_traceability=source_observation.provenance.metrological_traceability, - recorded_at=source_observation.provenance.recorded_at - or source_observation.context.observed_at, + metrological_traceability=base_provenance.metrological_traceability, + recorded_at=base_provenance.recorded_at or source_observation.context.observed_at, ) observation = ScientificMeasurementObservation( observation_id=output_id, @@ -429,7 +477,7 @@ def calculate_mbt_throw_distance( claim = "calculate medicine-ball throw distance" try: - _validate_distance_source(source_observation, coordinate_evidence) + _, coordinate_evidence = _validate_distance_source(source_observation, coordinate_evidence) if qualification is not None: require_qualified_mbt(qualification, source_observation) value = coordinate_evidence.endpoint_coordinate_m - coordinate_evidence.origin_coordinate_m diff --git a/src/dynamislm/measurement/medicine_ball_throw/qualification.py b/src/dynamislm/measurement/medicine_ball_throw/qualification.py index c9911b6..33a6370 100644 --- a/src/dynamislm/measurement/medicine_ball_throw/qualification.py +++ b/src/dynamislm/measurement/medicine_ball_throw/qualification.py @@ -4,7 +4,7 @@ import datetime as datetime_module import math -from dataclasses import dataclass +from dataclasses import dataclass, replace from itertools import pairwise from dynamislm.measurement.identity import ( @@ -18,9 +18,12 @@ ) from dynamislm.measurement.medicine_ball_throw.identity import ( MBTArtifactStatus, + MBTCoordinateEvidence, + MBTCoordinateSourceEvidence, MBTProcessingState, MBTQualificationStatus, MBTReleaseEvent, + MBTReleaseEventSourceEvidence, MBTSensorModality, MBTTimebase, MedicineBallThrowAcquisitionIdentity, @@ -32,11 +35,15 @@ MedicineBallThrowSourceArtifact, ) from dynamislm.measurement.medicine_ball_throw.registry import ( + MBT_COORDINATE_QUALIFICATION_RULE, + MBT_COORDINATE_SOURCE_OPERATION, MBT_INSTRUMENTED_RELEASE_VELOCITY_MEASURAND, MBT_INSTRUMENTED_RELEASE_VELOCITY_METRIC, MBT_QUALIFICATION_MEASURAND, MBT_QUALIFICATION_METRIC, MBT_REGISTRY_VERSION, + MBT_RELEASE_EVENT_QUALIFICATION_RULE, + MBT_RELEASE_EVENT_SOURCE_OPERATION, MBT_RELEASE_VELOCITY_SCHEMA, MBT_RELEASE_VELOCITY_SOURCE_OPERATION, MBT_SOFTWARE_VERSION, @@ -278,22 +285,421 @@ def build_mbt_source_distance_observation( ) +def _normalize_samples( + samples: tuple[tuple[float, float], ...], + label: str, +) -> tuple[tuple[float, float], ...]: + if not isinstance(samples, tuple) or not samples: + raise ValueError(f"MBT {label} samples must be a non-empty tuple") + normalized: list[tuple[float, float]] = [] + for sample in samples: + if not isinstance(sample, tuple) or len(sample) != 2: + raise ValueError(f"MBT {label} samples must be (time, value) tuples") + normalized.append( + ( + _finite(sample[0], f"{label} sample time"), + _finite(sample[1], f"{label} sample value"), + ) + ) + if any(right[0] <= left[0] for left, right in pairwise(normalized)): + raise ValueError(f"MBT {label} timestamps must increase") + return tuple(normalized) + + def _trajectory_digest( trajectory_samples: tuple[tuple[float, float], ...], velocity_samples: tuple[tuple[float, float], ...], timebase: MBTTimebase, coordinate_frame: RegistryReference, ) -> str: + normalized_trajectory = _normalize_samples(trajectory_samples, "trajectory") + normalized_velocity = _normalize_samples(velocity_samples, "velocity") return canonical_hash( { - "trajectory_samples": trajectory_samples, - "velocity_samples": velocity_samples, + "trajectory_samples": normalized_trajectory, + "velocity_samples": normalized_velocity, "timebase": timebase, "coordinate_frame": coordinate_frame, } ) +def _coordinate_source_parameters( + source: MBTCoordinateSourceEvidence, +) -> tuple[MetadataEntry, ...]: + return ( + MetadataEntry("source_coordinate_id", source.source_coordinate_id.qualified), + MetadataEntry("source_context_id", source.source_context_id.qualified), + MetadataEntry("source_observation_id", source.source_observation_id.qualified), + MetadataEntry("source_artifact_id", source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", source.source_acquisition_id.qualified), + MetadataEntry( + "source_measurement_identity_id", source.source_measurement_identity_id.stable_id + ), + MetadataEntry("protocol_reference", source.protocol_reference.stable_id), + MetadataEntry("origin_coordinate_m", source.origin_coordinate_m), + MetadataEntry("endpoint_coordinate_m", source.endpoint_coordinate_m), + MetadataEntry("coordinate_frame", source.coordinate_frame.stable_id), + MetadataEntry("origin_convention", source.origin_convention.stable_id), + MetadataEntry("endpoint_convention", source.endpoint_convention.stable_id), + MetadataEntry( + "first_contact_no_roll_convention", + source.first_contact_no_roll_convention.stable_id, + ), + MetadataEntry("first_contact_observed", source.first_contact_observed), + MetadataEntry("no_roll_observed", source.no_roll_observed), + MetadataEntry("source_content_digest", source.source_content_digest), + MetadataEntry("provider", source.provider), + MetadataEntry("method_parameters", canonical_json(source.method_parameters)), + MetadataEntry("source_value_origin", source.source_value_origin), + ) + + +def _validate_coordinate_source_evidence( + source: MBTCoordinateSourceEvidence, + source_observation: ScientificMeasurementObservation, +) -> None: + _require_instance(source, MBTCoordinateSourceEvidence, "coordinate_source_evidence") + _require_instance(source_observation, ScientificMeasurementObservation, "source_observation") + identity = source_observation.identity + if not isinstance(identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT coordinate source requires MBT identity") + protocol = identity.semantic.protocol_identity + if protocol is None: + raise ValueError("MBT coordinate source requires protocol identity") + observation_acquisition = next( + ( + item + for item in source_observation.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ), + None, + ) + if not isinstance(observation_acquisition, MedicineBallThrowAcquisitionRecord): + raise ValueError("MBT coordinate source requires its typed acquisition") + if ( + source.source_context_id != source_observation.context.context_id + or source.source_observation_id != source_observation.observation_id + or source.source_measurement_identity_id != identity.identity_id + or source.protocol_reference != protocol.reference + or source.provider != observation_acquisition.provider + or source.coordinate_frame != observation_acquisition.axis_or_frame + ): + raise ValueError("MBT coordinate source is not bound to the source observation") + if source_observation.result.status is not ResultStatus.VALID: + raise ValueError("MBT coordinate source observation must be valid") + if source.source_value_origin != source_observation.result.classification.value_origin.value: + raise ValueError("MBT coordinate source origin does not match observation") + if source.processing_method != MBT_COORDINATE_SOURCE_OPERATION: + raise ValueError("MBT coordinate source processing method is not registered") + if source.source_artifact_id not in { + item.artifact_id for item in source_observation.provenance.source_artifacts + } or source.source_acquisition_id not in { + item.acquisition_id for item in source_observation.provenance.acquisitions + }: + raise ValueError("MBT coordinate source is not bound to source provenance") + source_artifacts = tuple( + item + for item in source.provenance.source_artifacts + if item.artifact_id == source.source_artifact_id + ) + source_acquisitions = tuple( + item + for item in source.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ) + observation_artifact = next( + item + for item in source_observation.provenance.source_artifacts + if item.artifact_id == source.source_artifact_id + ) + observation_acquisition = next( + item + for item in source_observation.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ) + if ( + source_artifacts != (observation_artifact,) + or source_acquisitions != (observation_acquisition,) + or not isinstance(observation_artifact, MedicineBallThrowSourceArtifact) + or not observation_artifact.immutable + or observation_artifact.status is not MBTArtifactStatus.VERIFIED + ): + raise ValueError("MBT coordinate source provenance must preserve verified source objects") + if source.source_content_digest != observation_artifact.content_digest: + raise ValueError("MBT coordinate source digest does not match source artifact") + if protocol.provider is not None and protocol.provider != source.provider: + raise ValueError("MBT coordinate source provider does not match protocol") + if protocol.distance_origin_convention != source.origin_convention: + raise ValueError("MBT coordinate origin convention does not match protocol") + if protocol.distance_endpoint_convention != source.endpoint_convention: + raise ValueError("MBT coordinate endpoint convention does not match protocol") + if protocol.first_contact_no_roll_convention != source.first_contact_no_roll_convention: + raise ValueError("MBT coordinate contact/no-roll convention does not match protocol") + run = next( + ( + item + for item in source.provenance.processing_runs + if item.output_entity_id == source.source_coordinate_id + ), + None, + ) + if run is None or run.processing_run_id != source.upstream_processing_run_id: + raise ValueError("MBT coordinate source provenance must preserve its producing run") + if run.method != source.processing_method: + raise ValueError("MBT coordinate source run method does not match processing method") + if run.parameters != _coordinate_source_parameters(source): + raise ValueError("MBT coordinate source run parameters do not reproduce") + for from_id, relation in ( + (source.source_observation_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_artifact_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_acquisition_id.qualified, LineageRelation.DERIVED_FROM), + (MBT_COORDINATE_QUALIFICATION_RULE.stable_id, LineageRelation.SUPPORTED_BY), + ): + if not any( + edge.from_id == from_id + and edge.to_id == run.processing_run_id.qualified + and edge.relation is relation + for edge in source.provenance.lineage_edges + ): + raise ValueError("MBT coordinate source provenance omits an authority lineage edge") + if not any( + edge.from_id == run.processing_run_id.qualified + and edge.to_id == source.source_coordinate_id.qualified + and edge.relation is LineageRelation.PRODUCED + for edge in source.provenance.lineage_edges + ): + raise ValueError("MBT coordinate source provenance omits the produced coordinate edge") + + +def normalize_mbt_coordinate_evidence( + source: MBTCoordinateSourceEvidence, + source_observation: ScientificMeasurementObservation, + coordinate: MBTCoordinateEvidence | None = None, +) -> MBTCoordinateEvidence: + """Normalize qualified upstream coordinates into distance evidence.""" + + _validate_coordinate_source_evidence(source, source_observation) + normalized = MBTCoordinateEvidence( + source_observation_id=source.source_observation_id, + origin_coordinate_m=source.origin_coordinate_m, + endpoint_coordinate_m=source.endpoint_coordinate_m, + coordinate_frame=source.coordinate_frame, + origin_convention=source.origin_convention, + endpoint_convention=source.endpoint_convention, + first_contact_no_roll_convention=source.first_contact_no_roll_convention, + first_contact_observed=source.first_contact_observed, + no_roll_observed=source.no_roll_observed, + source_artifact_id=source.source_artifact_id, + acquisition_id=source.source_acquisition_id, + source_evidence=source, + ) + if coordinate is not None and coordinate != normalized: + raise ValueError("MBT coordinate does not preserve upstream coordinate evidence") + return normalized + + +def _release_event_source_parameters( + source: MBTReleaseEventSourceEvidence, +) -> tuple[MetadataEntry, ...]: + return ( + MetadataEntry("source_event_id", source.source_event_id.qualified), + MetadataEntry("source_context_id", source.source_context_id.qualified), + MetadataEntry("source_observation_id", source.source_observation_id.qualified), + MetadataEntry("source_series_id", source.source_series_id.qualified), + MetadataEntry("source_artifact_id", source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", source.source_acquisition_id.qualified), + MetadataEntry( + "source_measurement_identity_id", source.source_measurement_identity_id.stable_id + ), + MetadataEntry("source_series_digest", source.source_series_digest), + MetadataEntry("source_timebase", canonical_json(source.source_timebase)), + MetadataEntry("sample_index", source.sample_index), + MetadataEntry("event_time_s", source.event_time_s), + MetadataEntry("release_method", source.release_method.stable_id), + MetadataEntry("coordinate_frame", source.coordinate_frame.stable_id), + MetadataEntry("protocol_reference", source.protocol_reference.stable_id), + MetadataEntry("provider", source.provider), + MetadataEntry("method_parameters", canonical_json(source.method_parameters)), + MetadataEntry("source_value_origin", source.source_value_origin), + ) + + +def _validate_release_event_source_evidence( + source: MBTReleaseEventSourceEvidence, + observation: ScientificMeasurementObservation, + release_event: MBTReleaseEvent, + protocol: MedicineBallThrowProtocolIdentity, + timebase: MBTTimebase, + coordinate_frame: RegistryReference, + source_series_digest: str, +) -> None: + _require_instance(source, MBTReleaseEventSourceEvidence, "release_event_source_evidence") + identity = observation.identity + if not isinstance(identity, MedicineBallThrowMeasurementIdentity): + raise ValueError("MBT release source requires MBT identity") + observation_acquisition = next( + ( + item + for item in observation.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ), + None, + ) + if not isinstance(observation_acquisition, MedicineBallThrowAcquisitionRecord): + raise ValueError("MBT release source requires its typed acquisition") + if ( + source != release_event.source_evidence + or source.source_context_id != observation.context.context_id + or source.source_observation_id != observation.observation_id + or source.source_measurement_identity_id != identity.identity_id + or source.protocol_reference != protocol.reference + or source.source_timebase != timebase + or source.source_series_digest != source_series_digest + or source.coordinate_frame != coordinate_frame + or source.provider != observation_acquisition.provider + ): + raise ValueError("MBT release event source is not bound to the exact observation") + if observation.result.status is not ResultStatus.VALID: + raise ValueError("MBT release source observation must be valid") + if source.source_value_origin != observation.result.classification.value_origin.value: + raise ValueError("MBT release source origin does not match observation") + if source.processing_method != MBT_RELEASE_EVENT_SOURCE_OPERATION: + raise ValueError("MBT release event source processing method is not registered") + if protocol.provider is not None and source.provider != protocol.provider: + raise ValueError("MBT release event source provider does not match protocol") + if source.source_artifact_id not in { + item.artifact_id for item in observation.provenance.source_artifacts + } or source.source_acquisition_id not in { + item.acquisition_id for item in observation.provenance.acquisitions + }: + raise ValueError("MBT release event source is not bound to source provenance") + source_artifact = next( + item + for item in observation.provenance.source_artifacts + if item.artifact_id == source.source_artifact_id + ) + if not isinstance(source_artifact, MedicineBallThrowSourceArtifact): + raise ValueError("MBT release source must preserve its typed artifact") + if ( + not source_artifact.immutable + or source_artifact.status is not MBTArtifactStatus.VERIFIED + or source_artifact.content_digest != source_series_digest + ): + raise ValueError("MBT release source artifact digest is not verified") + source_artifacts = tuple( + item + for item in source.provenance.source_artifacts + if item.artifact_id == source.source_artifact_id + ) + source_acquisitions = tuple( + item + for item in source.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ) + observation_acquisition = next( + item + for item in observation.provenance.acquisitions + if item.acquisition_id == source.source_acquisition_id + ) + if source_artifacts != (source_artifact,) or source_acquisitions != (observation_acquisition,): + raise ValueError("MBT release source provenance does not preserve source objects") + run = next( + ( + item + for item in source.provenance.processing_runs + if item.output_entity_id == source.source_event_id + ), + None, + ) + if run is None or run.processing_run_id != source.upstream_processing_run_id: + raise ValueError("MBT release source provenance must preserve its producing run") + if run.method != source.processing_method: + raise ValueError("MBT release source run method does not match processing method") + if run.parameters != _release_event_source_parameters(source): + raise ValueError("MBT release source run parameters do not reproduce") + for from_id, relation in ( + (source.source_observation_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_series_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_artifact_id.qualified, LineageRelation.DERIVED_FROM), + (source.source_acquisition_id.qualified, LineageRelation.DERIVED_FROM), + (MBT_RELEASE_EVENT_QUALIFICATION_RULE.stable_id, LineageRelation.SUPPORTED_BY), + ): + if not any( + edge.from_id == from_id + and edge.to_id == run.processing_run_id.qualified + and edge.relation is relation + for edge in source.provenance.lineage_edges + ): + raise ValueError("MBT release source provenance omits an authority lineage edge") + if not any( + edge.from_id == run.processing_run_id.qualified + and edge.to_id == source.source_event_id.qualified + and edge.relation is LineageRelation.PRODUCED + for edge in source.provenance.lineage_edges + ): + raise ValueError("MBT release source provenance omits the produced event edge") + + +def _merge_provenance(left: Provenance, right: Provenance) -> Provenance: + artifacts = list(left.source_artifacts) + for artifact_item in right.source_artifacts: + prior_artifact = next( + (value for value in artifacts if value.artifact_id == artifact_item.artifact_id), + None, + ) + if prior_artifact is not None and prior_artifact != artifact_item: + raise ValueError("MBT provenance contains conflicting source artifacts") + if prior_artifact is None: + artifacts.append(artifact_item) + acquisitions = list(left.acquisitions) + for acquisition_item in right.acquisitions: + prior_acquisition = next( + ( + value + for value in acquisitions + if value.acquisition_id == acquisition_item.acquisition_id + ), + None, + ) + if prior_acquisition is not None and prior_acquisition != acquisition_item: + raise ValueError("MBT provenance contains conflicting acquisitions") + if prior_acquisition is None: + acquisitions.append(acquisition_item) + runs = list(left.processing_runs) + for run_item in right.processing_runs: + prior_run = next( + (value for value in runs if value.processing_run_id == run_item.processing_run_id), + None, + ) + if prior_run is not None and prior_run != run_item: + raise ValueError("MBT provenance contains conflicting processing runs") + if prior_run is None: + runs.append(run_item) + edges = list(left.lineage_edges) + for edge_item in right.lineage_edges: + if edge_item not in edges: + edges.append(edge_item) + evidence = list(left.evidence_references) + for evidence_item in right.evidence_references: + if evidence_item not in evidence: + evidence.append(evidence_item) + traceability = list(left.metrological_traceability) + for traceability_item in right.metrological_traceability: + if traceability_item not in traceability: + traceability.append(traceability_item) + return Provenance( + provenance_id=left.provenance_id, + source_artifacts=tuple(artifacts), + acquisitions=tuple(acquisitions), + processing_runs=tuple(runs), + lineage_edges=tuple(edges), + evidence_references=tuple(evidence), + metrological_traceability=tuple(traceability), + recorded_at=left.recorded_at or right.recorded_at, + ) + + @register_serializable_type @dataclass(frozen=True, slots=True) class MBTReleaseVelocityEvidence: @@ -316,6 +722,9 @@ def __post_init__(self) -> None: _require_instance(self.velocity_definition, RegistryReference, "velocity_definition") if self.observation.observation_id != self.release_event.source_observation_id: raise ValueError("release event must bind the source observation") + release_source_evidence = self.release_event.source_evidence + if not isinstance(release_source_evidence, MBTReleaseEventSourceEvidence): + raise ValueError("release_event.source_evidence must be MBTReleaseEventSourceEvidence") identity = self.observation.identity if not isinstance(identity, MedicineBallThrowMeasurementIdentity): raise ValueError("MBT release evidence requires MBT measurement identity") @@ -357,23 +766,12 @@ def __post_init__(self) -> None: (self.trajectory_samples, "trajectory"), (self.velocity_samples, "velocity"), ): - normalized = [] - for sample in samples: - if not isinstance(sample, tuple) or len(sample) != 2: - raise ValueError(f"MBT {label} samples must be (time, value) tuples") - normalized.append( - ( - _finite(sample[0], f"{label} sample time"), - _finite(sample[1], f"{label} sample value"), - ) - ) - if any(right[0] <= left[0] for left, right in pairwise(normalized)): - raise ValueError(f"MBT {label} timestamps must increase") + normalized = _normalize_samples(samples, label) if any( self.timebase.time_at(index) != sample[0] for index, sample in enumerate(normalized) ): raise ValueError(f"MBT {label} timestamps must match timebase") - object.__setattr__(self, f"{label}_samples", tuple(normalized)) + object.__setattr__(self, f"{label}_samples", normalized) digest = _trajectory_digest( self.trajectory_samples, self.velocity_samples, self.timebase, self.coordinate_frame ) @@ -416,6 +814,18 @@ def __post_init__(self) -> None: raise ValueError("MBT release source evidence must remain source/provider-origin") if self.observation.result.status is not ResultStatus.VALID: raise ValueError("MBT release source observation must be valid") + protocol = identity.semantic.protocol_identity + if protocol is None: + raise ValueError("MBT release evidence requires protocol identity") + _validate_release_event_source_evidence( + release_source_evidence, + self.observation, + self.release_event, + protocol, + self.timebase, + self.coordinate_frame, + self.source_series_digest, + ) @property def release_velocity_m_per_s(self) -> float: @@ -505,6 +915,9 @@ def build_mbt_release_velocity_evidence( _require_instance(protocol_identity, MedicineBallThrowProtocolIdentity, "protocol_identity") _require_instance(release_event, MBTReleaseEvent, "release_event") _require_instance(timebase, MBTTimebase, "timebase") + release_source_evidence = release_event.source_evidence + if not isinstance(release_source_evidence, MBTReleaseEventSourceEvidence): + raise ValueError("release_event.source_evidence must be MBTReleaseEventSourceEvidence") if release_event.source_observation_id != observation_id: raise ValueError("MBT release event source observation must match observation_id") if release_event.source_artifact_id != source_artifact.artifact_id: @@ -515,7 +928,11 @@ def build_mbt_release_velocity_evidence( raise ValueError("MBT release event method must match velocity definition") if value_origin not in {ValueOrigin.SOURCE_REPORTED, ValueOrigin.PROVIDER_DERIVED}: raise ValueError("MBT release evidence must remain source/provider-origin") - digest = _trajectory_digest(trajectory_samples, velocity_samples, timebase, coordinate_frame) + normalized_trajectory = _normalize_samples(trajectory_samples, "trajectory") + normalized_velocity = _normalize_samples(velocity_samples, "velocity") + digest = _trajectory_digest( + normalized_trajectory, normalized_velocity, timebase, coordinate_frame + ) if release_event.source_series_digest != digest: raise ValueError("release event digest does not match exact instrumented samples") _require_verified_artifact(source_artifact) @@ -594,11 +1011,24 @@ def build_mbt_release_velocity_evidence( acquisition=acquisition, recorded_at=recorded_at, ) + _validate_release_event_source_evidence( + release_source_evidence, + observation, + release_event, + protocol_identity, + timebase, + coordinate_frame, + digest, + ) + observation = replace( + observation, + provenance=_merge_provenance(observation.provenance, release_source_evidence.provenance), + ) return MBTReleaseVelocityEvidence( observation=observation, release_event=release_event, - trajectory_samples=trajectory_samples, - velocity_samples=velocity_samples, + trajectory_samples=normalized_trajectory, + velocity_samples=normalized_velocity, timebase=timebase, coordinate_frame=coordinate_frame, velocity_definition=velocity_definition, @@ -629,6 +1059,8 @@ def build_mbt_qualification_source_observation( observation_id = output_observation_id or InstanceIdentifier( "observation", f"mbt-qualification:{target_observation.observation_id.value}" ) + if observation_id == target_observation.observation_id: + raise ValueError("MBT qualification observation must differ from target") parameters = ( MetadataEntry("target_observation_id", target_observation.observation_id.qualified), MetadataEntry("source_status", reported_status.value), @@ -763,6 +1195,7 @@ def require_qualified_mbt( "build_mbt_qualification_source_observation", "build_mbt_release_velocity_evidence", "build_mbt_source_distance_observation", + "normalize_mbt_coordinate_evidence", "normalize_mbt_qualification", "require_qualified_mbt", ] diff --git a/src/dynamislm/measurement/medicine_ball_throw/registry.py b/src/dynamislm/measurement/medicine_ball_throw/registry.py index edd6e59..6214175 100644 --- a/src/dynamislm/measurement/medicine_ball_throw/registry.py +++ b/src/dynamislm/measurement/medicine_ball_throw/registry.py @@ -65,6 +65,16 @@ def _unit(key: str, label: str) -> UnitReference: MBT_SOURCE_DISTANCE_OPERATION = _reference( "processing-method", "medicine-ball-throw-source-distance-v1", "MBT source distance V1" ) +MBT_COORDINATE_SOURCE_OPERATION = _reference( + "processing-method", + "medicine-ball-throw-coordinate-source-v1", + "MBT coordinate source V1", +) +MBT_COORDINATE_QUALIFICATION_RULE = _reference( + "source-adjudication-rule", + "medicine-ball-throw-coordinate-qualification-v1", + "MBT coordinate qualification V1", +) MBT_DISTANCE_FROM_REGISTERED_COORDINATES_OPERATION = _reference( "registered-operation", "medicine-ball-throw-distance-from-registered-coordinates-v1", @@ -75,6 +85,16 @@ def _unit(key: str, label: str) -> UnitReference: "medicine-ball-throw-release-velocity-source-v1", "MBT release-velocity source V1", ) +MBT_RELEASE_EVENT_SOURCE_OPERATION = _reference( + "processing-method", + "medicine-ball-throw-release-event-source-v1", + "MBT release-event source V1", +) +MBT_RELEASE_EVENT_QUALIFICATION_RULE = _reference( + "source-adjudication-rule", + "medicine-ball-throw-release-event-qualification-v1", + "MBT release-event qualification V1", +) MBT_INSTRUMENTED_RELEASE_VELOCITY_OPERATION = _reference( "registered-operation", "medicine-ball-throw-instrumented-release-velocity-v1", diff --git a/tests/test_explosive_test_families.py b/tests/test_explosive_test_families.py index a66658e..b41981c 100644 --- a/tests/test_explosive_test_families.py +++ b/tests/test_explosive_test_families.py @@ -8,7 +8,7 @@ import pytest -from dynamislm.comparability.models import ComparabilityState +from dynamislm.comparability.models import ComparabilityResult, ComparabilityState from dynamislm.measurement.bench_press_throw import ( BenchPressThrowAcquisitionIdentity, BenchPressThrowAcquisitionRecord, @@ -28,6 +28,7 @@ BPTLoadIdentity, BPTLoadKind, BPTMachineType, + BPTMetricSupportSourceEvidence, BPTMovementPattern, BPTPauseTouchBounce, BPTProcessingState, @@ -47,6 +48,7 @@ calculate_bpt_sampled_maximum_bar_velocity, compare_bpt_metric_results, create_bpt_velocity_series_evidence, + normalize_bpt_metric_support, normalize_bpt_qualification, wrap_bpt_provider_metric, ) @@ -54,7 +56,18 @@ BPT_BAR_VELOCITY_MEASURAND, BPT_CONSTRUCT, BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + BPT_MEAN_POWER_MEASURAND, + BPT_MEAN_PROPULSIVE_VELOCITY_MEASURAND, + BPT_METRIC_SUPPORT_BOUNDARY_CONVENTION, + BPT_METRIC_SUPPORT_METHOD, + BPT_METRIC_SUPPORT_QUALIFICATION_RULE, + BPT_METRIC_SUPPORT_SOURCE_OPERATION, + BPT_PEAK_POWER_MEASURAND, + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + BPT_PROVIDER_MEAN_POWER_METRIC, + BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC, BPT_PROVIDER_MEAN_VELOCITY_METRIC, + BPT_PROVIDER_PEAK_POWER_METRIC, BPT_REGISTRY_VERSION, BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, BPT_SOURCE_VELOCITY_SERIES_OPERATION, @@ -62,9 +75,13 @@ BPT_VELOCITY_SERIES_METRIC, KILOGRAM, METER_PER_SECOND, + WATT, ) from dynamislm.measurement.cmj.weighing import STANDARD_GRAVITY from dynamislm.measurement.drop_jump import ( + DROP_JUMP_REBOUND_TAKEOFF_DETECTOR, + DROP_JUMP_SUBSEQUENT_LANDING_DETECTOR, + DROP_JUMP_TOUCHDOWN_DETECTOR, DropJumpAcquisitionIdentity, DropJumpAcquisitionRecord, DropJumpArmCondition, @@ -73,6 +90,8 @@ DropJumpEventDetectorParameters, DropJumpEventLabel, DropJumpEventOccurrence, + DropJumpEventOccurrenceStatus, + DropJumpEventSourceEvidence, DropJumpFlightTimeApplicability, DropJumpInitiationMode, DropJumpMeasurementIdentity, @@ -123,8 +142,10 @@ MBTArtifactStatus, MBTBodyPosture, MBTCoordinateEvidence, + MBTCoordinateSourceEvidence, MBTQualificationStatus, MBTReleaseEvent, + MBTReleaseEventSourceEvidence, MBTReleaseSemantics, MBTReleaseVelocityEvidence, MBTSensorModality, @@ -145,16 +166,26 @@ calculate_mbt_release_velocity_from_distance, calculate_mbt_throw_distance, compare_mbt_metric_results, + normalize_mbt_coordinate_evidence, normalize_mbt_qualification, ) +from dynamislm.measurement.medicine_ball_throw.qualification import _release_event_source_parameters +from dynamislm.measurement.medicine_ball_throw.registry import ( + MBT_COORDINATE_QUALIFICATION_RULE, + MBT_COORDINATE_SOURCE_OPERATION, + MBT_RELEASE_EVENT_QUALIFICATION_RULE, + MBT_RELEASE_EVENT_SOURCE_OPERATION, +) from dynamislm.measurement.observation import ObservationContext, ScientificMeasurementObservation from dynamislm.measurement.result import ( MeasurementQuality, MeasurementResult, + ScalarValue, StructuredOutputReference, UncertaintyMetadata, ) from dynamislm.measurement.taxonomy import ScientificClassification, ValueOrigin +from dynamislm.provenance import LineageEdge, LineageRelation, ProcessingRun, Provenance from dynamislm.refusal.models import RefusalResult from dynamislm.serialization import ( SERIALIZATION_VERSION, @@ -162,6 +193,7 @@ canonical_json, from_canonical_json, ) +from test_cmj_jump_height import _flight_events def _ref(object_type: str, key: str, label: str) -> RegistryReference: @@ -207,6 +239,126 @@ def _refused(value: object) -> RefusalResult: return value +def _dj_event_source( + source: ScientificMeasurementObservation, + *, + label: DropJumpEventLabel, + sample_index: int, + source_sample_count: int, + source_series_digest: str, + detector_parameters: DropJumpEventDetectorParameters, + prefix: str, +) -> DropJumpEventSourceEvidence: + artifact = next( + item + for item in source.provenance.source_artifacts + if isinstance(item, DropJumpSourceArtifact) + ) + acquisition = next( + item + for item in source.provenance.acquisitions + if isinstance(item, DropJumpAcquisitionRecord) + ) + identity = source.identity + if ( + not isinstance(identity, DropJumpMeasurementIdentity) + or identity.acquisition.timebase is None + ): + raise AssertionError("DJ fixture requires a typed timebase") + timebase = identity.acquisition.timebase + detector = { + DropJumpEventLabel.TOUCHDOWN: DROP_JUMP_TOUCHDOWN_DETECTOR, + DropJumpEventLabel.REBOUND_TAKEOFF: DROP_JUMP_REBOUND_TAKEOFF_DETECTOR, + DropJumpEventLabel.SUBSEQUENT_LANDING: DROP_JUMP_SUBSEQUENT_LANDING_DETECTOR, + }[label] + event_id = InstanceIdentifier("event-occurrence", f"{prefix}-{label.value.lower()}") + signal_id = InstanceIdentifier( + "signal", f"drop-jump:{source_series_digest.removeprefix('sha256:')[:24]}" + ) + source_value_origin = ValueOrigin.SOURCE_REPORTED + source_parameters = ( + MetadataEntry("source_event_id", event_id.qualified), + MetadataEntry("source_observation_id", source.observation_id.qualified), + MetadataEntry("source_signal_id", signal_id.qualified), + MetadataEntry("source_artifact_id", artifact.artifact_id.qualified), + MetadataEntry("source_acquisition_id", acquisition.acquisition_id.qualified), + MetadataEntry("source_series_digest", source_series_digest), + MetadataEntry("source_sample_count", source_sample_count), + MetadataEntry("label", label.value), + MetadataEntry("sample_index", sample_index), + MetadataEntry("event_time_s", timebase.time_at(sample_index)), + MetadataEntry("detector_parameters", canonical_hash(detector_parameters)), + MetadataEntry("status", "VALID"), + MetadataEntry("qc_codes", canonical_hash(())), + MetadataEntry("source_value_origin", source_value_origin.value), + MetadataEntry("preceding_event_id", None), + ) + run_id = InstanceIdentifier("processing-run", f"{event_id.value}:source") + run = ProcessingRun( + processing_run_id=run_id, + source_artifact_ids=(artifact.artifact_id,), + method=detector.reference, + parameters=source_parameters, + software_version="synthetic-upstream-dj-event-v1", + output_entity_id=event_id, + ) + provenance = Provenance( + provenance_id=InstanceIdentifier("provenance", event_id.value), + source_artifacts=(artifact,), + acquisitions=(acquisition,), + processing_runs=(run,), + lineage_edges=( + LineageEdge( + artifact.artifact_id.qualified, + acquisition.acquisition_id.qualified, + LineageRelation.ACQUIRED_AS, + ), + LineageEdge( + source.observation_id.qualified, + run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge(signal_id.qualified, run_id.qualified, LineageRelation.DERIVED_FROM), + LineageEdge( + artifact.artifact_id.qualified, run_id.qualified, LineageRelation.DERIVED_FROM + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + detector.decision_reference.stable_id, + run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge(run_id.qualified, event_id.qualified, LineageRelation.PRODUCED), + ), + ) + return DropJumpEventSourceEvidence( + source_event_id=event_id, + source_context=source.context, + source_observation_id=source.observation_id, + source_signal_id=signal_id, + source_artifact_id=artifact.artifact_id, + source_acquisition_id=acquisition.acquisition_id, + source_measurement_identity=identity, + source_timebase=timebase, + source_series_digest=source_series_digest, + source_sample_count=source_sample_count, + label=label, + sample_index=sample_index, + event_time_s=timebase.time_at(sample_index), + detector_method=detector, + detector_parameters=detector_parameters, + status=DropJumpEventOccurrenceStatus.VALID, + qc_codes=(), + source_value_origin=source_value_origin, + upstream_processing_run_id=run_id, + provenance=provenance, + ) + + def _dj_fixture( *, actual_drop_height_m: float | None = None, @@ -311,6 +463,9 @@ def _dj_fixture( source_artifact=artifact, acquisition=acquisition, ) + parameters = DropJumpEventDetectorParameters( + (MetadataEntry("threshold_semantics", "exact source detector output"),) + ) events = { label: build_drop_jump_event_occurrence( source_observation=source, @@ -318,8 +473,15 @@ def _dj_fixture( sample_index=index, source_sample_count=len(samples), source_series_digest=series_digest, - detector_parameters=DropJumpEventDetectorParameters( - (MetadataEntry("threshold_semantics", "exact source detector output"),) + detector_parameters=parameters, + source_evidence=_dj_event_source( + source, + label=label, + sample_index=index, + source_sample_count=len(samples), + source_series_digest=series_digest, + detector_parameters=parameters, + prefix=prefix, ), ) for label, index in ( @@ -517,6 +679,95 @@ def test_dj_cmj_event_is_not_accepted_and_v3_roundtrip_is_stable() -> None: ) ).blocks_claim + cmj_takeoff, cmj_landing, _ = _flight_events("res68-real-cmj") + real_cmj_refusal = calculate_drop_jump_flight_time_jump_height( + cast(DropJumpEventOccurrence, cmj_takeoff), + cast(DropJumpEventOccurrence, cmj_landing), + STANDARD_GRAVITY, + source, + applicability, + ) + assert isinstance(real_cmj_refusal, RefusalResult) + assert "EVENT_SOURCE_MISMATCH" in real_cmj_refusal.reason_codes + + +def test_dj_adjudication_rule_is_validated_before_stable_id_access() -> None: + source, _, _ = _dj_fixture(prefix="synthetic-dj-bad-adjudication") + artifact = next( + item + for item in source.provenance.source_artifacts + if isinstance(item, DropJumpSourceArtifact) + ) + acquisition = next( + item + for item in source.provenance.acquisitions + if isinstance(item, DropJumpAcquisitionRecord) + ) + with pytest.raises(ValueError, match="adjudication_rule"): + build_drop_jump_qualification_source_observation( + target_observation=source, + reported_status=DropJumpQualificationStatus.QUALIFIED, + source_artifact=artifact, + acquisition=acquisition, + adjudication_rule=cast(RegistryReference, object()), + ) + + +def test_dj_event_authority_requires_upstream_event_evidence() -> None: + source, events, _ = _dj_fixture(prefix="synthetic-dj-event-authority") + valid_event = events[DropJumpEventLabel.TOUCHDOWN] + assert valid_event.source_evidence is not None + source_evidence = valid_event.source_evidence + with pytest.raises(ValueError, match="source evidence"): + build_drop_jump_event_occurrence( + source_observation=source, + label=DropJumpEventLabel.TOUCHDOWN, + sample_index=100, + source_sample_count=1000, + source_series_digest=valid_event.source_series_digest, + status=DropJumpEventOccurrenceStatus.VALID, + ) + with pytest.raises(ValueError): + replace(source_evidence, detector_method=DROP_JUMP_REBOUND_TAKEOFF_DETECTOR) + with pytest.raises(ValueError): + replace( + source_evidence, + provenance=replace(source_evidence.provenance, lineage_edges=()), + ) + with pytest.raises(ValueError): + replace(source_evidence, source_series_digest="sha256:forged") + context_mismatch = replace(source_evidence, source_context=_context("other-dj-context")) + with pytest.raises(ValueError, match="source"): + build_drop_jump_event_occurrence( + source_observation=source, + source_evidence=context_mismatch, + ) + with pytest.raises(ValueError): + replace(source_evidence, source_artifact_id=InstanceIdentifier("artifact", "other")) + with pytest.raises(ValueError): + replace(source_evidence, source_acquisition_id=InstanceIdentifier("acquisition", "other")) + other_source, _, _ = _dj_fixture(prefix="synthetic-dj-other-protocol", actual_drop_height_m=0.3) + assert isinstance(other_source.identity, DropJumpMeasurementIdentity) + protocol_mismatch = replace( + source_evidence, + source_measurement_identity=other_source.identity, + ) + with pytest.raises(ValueError, match="source observation"): + build_drop_jump_event_occurrence( + source_observation=source, + source_evidence=protocol_mismatch, + ) + assert ( + valid_event.provenance.processing_runs[-1].method == valid_event.detector_method.reference + ) + assert valid_event.provenance.processing_runs[-1].software_version.startswith( + "synthetic-upstream" + ) + encoded = canonical_json(source_evidence) + restored = from_canonical_json(encoded, type(source_evidence)) + assert restored == source_evidence + assert canonical_hash(restored) == canonical_hash(source_evidence) + def test_source_qualification_is_upstream_typed_and_cannot_be_minted() -> None: source, events, _ = _dj_fixture(prefix="synthetic-dj-qualified") @@ -603,12 +854,121 @@ def test_source_qualification_is_upstream_typed_and_cannot_be_minted() -> None: ).blocks_claim +def _bpt_support_source( + *, + context: ObservationContext, + identity: BenchPressThrowMeasurementIdentity, + series: BenchPressThrowVelocitySeries, + artifact: BenchPressThrowSourceArtifact, + acquisition: BenchPressThrowAcquisitionRecord, + metric: RegistryReference, + support_definition: str, + includes_post_release_samples: bool, + prefix: str, +) -> BPTMetricSupportSourceEvidence: + support_id = InstanceIdentifier("support", f"{prefix}-{metric.identifier.key}") + run_id = InstanceIdentifier("processing-run", f"{support_id.value}:source") + source_origin = ValueOrigin.PROVIDER_DERIVED + source = BPTMetricSupportSourceEvidence( + support_id=support_id, + source_context=context, + source_observation_id=InstanceIdentifier("observation", f"{prefix}-series"), + source_series_id=series.series_id, + source_artifact_id=artifact.artifact_id, + source_acquisition_id=acquisition.acquisition_id, + source_measurement_identity_id=identity.identity_id, + source_series_digest=series.canonical_content_digest(), + source_timebase=series.timebase, + source_sample_count=len(series.samples), + metric=metric, + start_index=0, + end_index=len(series.samples) - 1, + start_time_s=series.samples[0][0], + end_time_s=series.samples[-1][0], + support_definition=support_definition, + boundary_method=BPT_METRIC_SUPPORT_METHOD, + boundary_convention=BPT_METRIC_SUPPORT_BOUNDARY_CONVENTION, + includes_post_release_samples=includes_post_release_samples, + value_origin=source_origin, + upstream_processing_run_id=run_id, + provenance=Provenance( + provenance_id=InstanceIdentifier("provenance", support_id.value), + source_artifacts=(artifact,), + acquisitions=(acquisition,), + processing_runs=(), + lineage_edges=(), + ), + ) + parameters = ( + MetadataEntry("support_id", source.support_id.qualified), + MetadataEntry("source_context_id", source.source_context.context_id.qualified), + MetadataEntry("source_observation_id", source.source_observation_id.qualified), + MetadataEntry("source_series_id", source.source_series_id.qualified), + MetadataEntry("source_artifact_id", source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", source.source_acquisition_id.qualified), + MetadataEntry( + "source_measurement_identity_id", source.source_measurement_identity_id.stable_id + ), + MetadataEntry("source_series_digest", source.source_series_digest), + MetadataEntry("source_timebase", canonical_json(source.source_timebase)), + MetadataEntry("source_sample_count", source.source_sample_count), + MetadataEntry("metric", source.metric.stable_id), + MetadataEntry("start_index", source.start_index), + MetadataEntry("end_index", source.end_index), + MetadataEntry("start_time_s", source.start_time_s), + MetadataEntry("end_time_s", source.end_time_s), + MetadataEntry("support_definition", source.support_definition), + MetadataEntry("boundary_method", source.boundary_method.stable_id), + MetadataEntry("boundary_convention", source.boundary_convention.stable_id), + MetadataEntry("boundary_parameters", canonical_json(source.boundary_parameters)), + MetadataEntry("includes_post_release_samples", source.includes_post_release_samples), + MetadataEntry("value_origin", source.value_origin.value), + ) + run = ProcessingRun( + processing_run_id=run_id, + source_artifact_ids=(artifact.artifact_id,), + method=BPT_METRIC_SUPPORT_SOURCE_OPERATION, + parameters=parameters, + software_version="synthetic-upstream-bpt-support-v1", + output_entity_id=support_id, + ) + provenance = replace( + source.provenance, + processing_runs=(run,), + lineage_edges=( + LineageEdge( + source.source_observation_id.qualified, + run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge(series.series_id.qualified, run_id.qualified, LineageRelation.DERIVED_FROM), + LineageEdge( + artifact.artifact_id.qualified, run_id.qualified, LineageRelation.DERIVED_FROM + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + BPT_METRIC_SUPPORT_QUALIFICATION_RULE.stable_id, + run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge(run_id.qualified, support_id.qualified, LineageRelation.PRODUCED), + ), + ) + return replace(source, provenance=provenance) + + def _bpt_fixture( *, movement: BPTMovementPattern = BPTMovementPattern.CONCENTRIC_ONLY, prefix: str = "synthetic-bpt", explicit_timebase: bool = False, counterbalance_status: BPTCounterbalanceStatus = BPTCounterbalanceStatus.ABSENT, + support_metric: RegistryReference = BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + includes_post_release_samples: bool = True, ) -> tuple[ BenchPressThrowVelocitySeriesEvidence, BenchPressThrowProtocolIdentity, @@ -733,42 +1093,43 @@ def _bpt_fixture( sign_convention=sign, processing_state=BPTProcessingState.PROVIDER_PROCESSED, ) - support = BenchPressThrowMetricSupport( - support_id=InstanceIdentifier("support", f"{prefix}-vmax"), - metric=BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, - start_index=0, - end_index=4, - start_time_s=0.0, - end_time_s=samples[-1][0], + context = _context(prefix) + support_source = _bpt_support_source( + context=context, + identity=identity, + series=series, + artifact=artifact, + acquisition=acquisition, + metric=support_metric, support_definition=( "first positive bar velocity through explicit release/maximum-height support" ), - source_series_digest=series_digest, - includes_post_release_samples=True, + includes_post_release_samples=includes_post_release_samples, + prefix=prefix, ) evidence = create_bpt_velocity_series_evidence( observation_id=InstanceIdentifier("observation", f"{prefix}-series"), result_id=InstanceIdentifier("result", f"{prefix}-series"), - context=_context(prefix), + context=context, identity=identity, series=series, source_artifact=artifact, acquisition=acquisition, - support=support, + support=support_source, ) - return evidence, protocol, support, series_digest, acquisition, artifact + return evidence, protocol, evidence.support, series_digest, acquisition, artifact def test_bpt_vmax_time_weighted_mean_and_post_release_support() -> None: evidence, protocol, _, _, _, _ = _bpt_fixture() vmax = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(evidence)) assert vmax.value_m_per_s == pytest.approx(1.0) - mean_support = replace( - evidence.support, - support_id=InstanceIdentifier("support", "synthetic-bpt-mean"), - metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, + assert evidence.support_source_evidence is not None + assert evidence.support_source_evidence.includes_post_release_samples + mean_evidence, _, _, _, _, _ = _bpt_fixture( + prefix="synthetic-bpt-mean", + support_metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, ) - mean_evidence = replace(evidence, support=mean_support) mean = _bpt_result(calculate_bpt_dynamislm_time_weighted_mean_bar_velocity(mean_evidence)) assert mean.value_m_per_s == pytest.approx(0.4375) assert ( @@ -776,6 +1137,15 @@ def test_bpt_vmax_time_weighted_mean_and_post_release_support() -> None: == BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC ) assert protocol.release_semantics is BPTReleaseSemantics.RELEASED + no_post_release, _, _, _, _, _ = _bpt_fixture( + prefix="synthetic-bpt-no-post-release", + includes_post_release_samples=False, + ) + assert no_post_release.support_source_evidence is not None + assert not no_post_release.support_source_evidence.includes_post_release_samples + assert _bpt_result( + calculate_bpt_sampled_maximum_bar_velocity(no_post_release) + ).value_m_per_s == pytest.approx(1.0) def test_bpt_metric_support_and_provider_origin_are_distinct() -> None: @@ -805,6 +1175,118 @@ def test_bpt_metric_support_and_provider_origin_are_distinct() -> None: ) +def test_bpt_provider_metrics_use_exact_measurands() -> None: + _, protocol, _, _, acquisition, artifact = _bpt_fixture(prefix="synthetic-bpt-measurands") + cases = ( + ( + BPT_PROVIDER_MEAN_VELOCITY_METRIC, + METER_PER_SECOND, + BPT_BAR_VELOCITY_MEASURAND, + ), + ( + BPT_PROVIDER_MAXIMUM_VELOCITY_METRIC, + METER_PER_SECOND, + BPT_BAR_VELOCITY_MEASURAND, + ), + ( + BPT_PROVIDER_MEAN_PROPULSIVE_VELOCITY_METRIC, + METER_PER_SECOND, + BPT_MEAN_PROPULSIVE_VELOCITY_MEASURAND, + ), + (BPT_PROVIDER_MEAN_POWER_METRIC, WATT, BPT_MEAN_POWER_MEASURAND), + (BPT_PROVIDER_PEAK_POWER_METRIC, WATT, BPT_PEAK_POWER_MEASURAND), + ) + for metric, unit, measurand in cases: + observation = build_bpt_provider_metric_observation( + observation_id=InstanceIdentifier( + "observation", f"synthetic-bpt-provider-{metric.identifier.key}" + ), + result_id=InstanceIdentifier( + "result", f"synthetic-bpt-provider-{metric.identifier.key}" + ), + context=_context(f"synthetic-bpt-provider-{metric.identifier.key}"), + protocol_identity=protocol, + metric=metric, + value=1.0, + unit=unit, + source_artifact=artifact, + acquisition=acquisition, + value_origin=ValueOrigin.PROVIDER_DERIVED, + ) + assert observation.identity.semantic.measurand == measurand + + +def test_bpt_metric_support_requires_upstream_source_authority() -> None: + evidence, _, _, _, acquisition, artifact = _bpt_fixture(prefix="synthetic-bpt-authority") + raw_support = BenchPressThrowMetricSupport( + support_id=InstanceIdentifier("support", "raw-bpt-support"), + metric=BPT_SAMPLED_MAXIMUM_BAR_VELOCITY_METRIC, + start_index=0, + end_index=4, + start_time_s=0.0, + end_time_s=0.4, + support_definition="caller-selected support", + source_series_digest=evidence.source_series_digest, + includes_post_release_samples=True, + ) + with pytest.raises(ValueError, match="upstream BPT metric support"): + create_bpt_velocity_series_evidence( + observation_id=evidence.observation.observation_id, + result_id=evidence.observation.result.result_id, + context=evidence.observation.context, + identity=evidence.identity, + series=evidence.series, + source_artifact=artifact, + acquisition=acquisition, + support=raw_support, + ) + source = evidence.support_source_evidence + assert source is not None + for mutated in ( + replace(source, source_series_digest="sha256:wrong"), + replace(source, source_context=_context("synthetic-bpt-other-context")), + replace(source, source_artifact_id=InstanceIdentifier("artifact", "other")), + replace(source, source_acquisition_id=InstanceIdentifier("acquisition", "other")), + replace(source, metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC), + replace(source, start_index=1), + replace(source, end_time_s=99.0), + replace(source, boundary_method=_ref("support-method", "other", "Other support")), + replace( + source, + boundary_convention=_ref("boundary-convention", "other", "Other convention"), + ), + replace( + source, + provenance=replace( + source.provenance, + lineage_edges=( + LineageEdge( + source.upstream_processing_run_id.qualified, + source.support_id.qualified, + LineageRelation.PRODUCED, + ), + ), + ), + ), + ): + with pytest.raises(ValueError): + normalize_bpt_metric_support( + mutated, + evidence.observation, + evidence.series, + artifact, + acquisition, + ) + normalized = normalize_bpt_metric_support( + source, evidence.observation, evidence.series, artifact, acquisition + ) + assert normalized == evidence.support + assert normalized.authority_source_id == source.support_id + restored = from_canonical_json(canonical_json(source), type(source)) + assert restored == source + assert canonical_hash(restored) == canonical_hash(source) + + def test_bpt_metric_specific_refusals_and_serialization() -> None: evidence, _, _, _, _, _ = _bpt_fixture() assert calculate_bpt_mean_propulsive_velocity(evidence).blocks_claim @@ -820,18 +1302,81 @@ def test_bpt_metric_specific_refusals_and_serialization() -> None: assert result.observation.result.classification.value_origin is ValueOrigin.DYNAMISLM_DERIVED +def _assert_unresolved_comparison(value: object, missing: str) -> ComparabilityResult: + assert isinstance(value, ComparabilityResult) + assert value.state is ComparabilityState.INSUFFICIENT_INFORMATION + assert value.decided_by.value == "UNRESOLVED" + assert value.rule_reference is None + assert missing in value.missing_information + return value + + +def test_res68_comparators_fail_closed_for_refusals_malformed_inputs_and_duplicate_ids() -> None: + bpt_evidence, _, _, _, _, _ = _bpt_fixture(prefix="synthetic-bpt-comparability") + bpt_result = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(bpt_evidence)) + bpt_refusal = _refused(calculate_bpt_mean_power(bpt_evidence)) + _assert_unresolved_comparison( + compare_bpt_metric_results(cast(BenchPressThrowMetricResult, bpt_refusal), bpt_result), + "two typed BPT metric results", + ) + _assert_unresolved_comparison( + compare_bpt_metric_results(bpt_result, cast(BenchPressThrowMetricResult, bpt_refusal)), + "two typed BPT metric results", + ) + _assert_unresolved_comparison( + compare_bpt_metric_results(cast(BenchPressThrowMetricResult, object()), bpt_result), + "two typed BPT metric results", + ) + duplicate_bpt = replace(bpt_result, metric=bpt_result.metric) + _assert_unresolved_comparison( + compare_bpt_metric_results(duplicate_bpt, bpt_result), "two distinct observations" + ) + + dj_source, dj_events, _ = _dj_fixture(prefix="synthetic-dj-comparability") + dj_result = _dj_result( + calculate_drop_jump_contact_time( + dj_events[DropJumpEventLabel.TOUCHDOWN], + dj_events[DropJumpEventLabel.REBOUND_TAKEOFF], + dj_source, + ) + ) + _assert_unresolved_comparison( + compare_drop_jump_metric_results(cast(DropJumpMetricResult, object()), dj_result), + "two typed DJ metric results", + ) + _assert_unresolved_comparison( + compare_drop_jump_metric_results(dj_result, dj_result), "two distinct observations" + ) + duplicate_dj = replace(dj_result, metric=dj_result.metric) + _assert_unresolved_comparison( + compare_drop_jump_metric_results(duplicate_dj, dj_result), "two distinct observations" + ) + + mbt_source, mbt_coordinate, _, _, _, _ = _mbt_distance_fixture( + prefix="synthetic-mbt-comparability" + ) + mbt_result = _mbt_result(calculate_mbt_throw_distance(mbt_coordinate, mbt_source)) + _assert_unresolved_comparison( + compare_mbt_metric_results(cast(MedicineBallThrowMetricResult, object()), mbt_result), + "two typed MBT metric results", + ) + _assert_unresolved_comparison( + compare_mbt_metric_results(mbt_result, mbt_result), "two distinct observations" + ) + duplicate_mbt = replace(mbt_result, metric=mbt_result.metric) + _assert_unresolved_comparison( + compare_mbt_metric_results(duplicate_mbt, mbt_result), "two distinct observations" + ) + + def test_bpt_irregular_timebase_and_same_method_comparability() -> None: explicit, _, _, _, _, _ = _bpt_fixture(prefix="synthetic-bpt-explicit", explicit_timebase=True) - mean_support = replace( - explicit.support, - support_id=InstanceIdentifier("support", "synthetic-bpt-explicit-mean"), - metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, - ) - mean = _bpt_result( - calculate_bpt_dynamislm_time_weighted_mean_bar_velocity( - replace(explicit, support=mean_support) - ) + mean_evidence, _, _, _, _, _ = _bpt_fixture( + prefix="synthetic-bpt-explicit-mean", + explicit_timebase=True, + support_metric=BPT_DYNAMISLM_TIME_WEIGHTED_MEAN_BAR_VELOCITY_METRIC, ) + mean = _bpt_result(calculate_bpt_dynamislm_time_weighted_mean_bar_velocity(mean_evidence)) assert mean.value_m_per_s == pytest.approx(0.3775 / 0.9) other, _, _, _, _, _ = _bpt_fixture(prefix="synthetic-bpt-other", explicit_timebase=True) other_vmax = _bpt_result(calculate_bpt_sampled_maximum_bar_velocity(other)) @@ -864,6 +1409,119 @@ def test_bpt_unresolved_counterbalance_and_movement_mismatch_refuse_or_bridge() ) +def _mbt_coordinate_source( + *, + source_observation: ScientificMeasurementObservation, + protocol: MedicineBallThrowProtocolIdentity, + artifact: MedicineBallThrowSourceArtifact, + acquisition: MedicineBallThrowAcquisitionRecord, + origin_coordinate_m: float, + endpoint_coordinate_m: float, + coordinate_frame: RegistryReference, + origin_convention: RegistryReference, + endpoint_convention: RegistryReference, + first_contact_no_roll_convention: RegistryReference, + first_contact_observed: bool, + no_roll_observed: bool, + prefix: str, +) -> MBTCoordinateSourceEvidence: + coordinate_id = InstanceIdentifier("coordinate-evidence", f"{prefix}-coordinate") + run_id = InstanceIdentifier("processing-run", f"{coordinate_id.value}:source") + source = MBTCoordinateSourceEvidence( + source_coordinate_id=coordinate_id, + source_context_id=source_observation.context.context_id, + source_observation_id=source_observation.observation_id, + source_artifact_id=artifact.artifact_id, + source_acquisition_id=acquisition.acquisition_id, + source_measurement_identity_id=source_observation.identity.identity_id, + protocol_reference=protocol.reference, + origin_coordinate_m=origin_coordinate_m, + endpoint_coordinate_m=endpoint_coordinate_m, + coordinate_frame=coordinate_frame, + origin_convention=origin_convention, + endpoint_convention=endpoint_convention, + first_contact_no_roll_convention=first_contact_no_roll_convention, + first_contact_observed=first_contact_observed, + no_roll_observed=no_roll_observed, + source_content_digest=artifact.content_digest, + provider=acquisition.provider, + source_value_origin=source_observation.result.classification.value_origin.value, + upstream_processing_run_id=run_id, + provenance=Provenance( + provenance_id=InstanceIdentifier("provenance", coordinate_id.value), + source_artifacts=(artifact,), + acquisitions=(acquisition,), + processing_runs=(), + lineage_edges=(), + ), + ) + parameters = ( + MetadataEntry("source_coordinate_id", source.source_coordinate_id.qualified), + MetadataEntry("source_context_id", source.source_context_id.qualified), + MetadataEntry("source_observation_id", source.source_observation_id.qualified), + MetadataEntry("source_artifact_id", source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", source.source_acquisition_id.qualified), + MetadataEntry( + "source_measurement_identity_id", source.source_measurement_identity_id.stable_id + ), + MetadataEntry("protocol_reference", source.protocol_reference.stable_id), + MetadataEntry("origin_coordinate_m", source.origin_coordinate_m), + MetadataEntry("endpoint_coordinate_m", source.endpoint_coordinate_m), + MetadataEntry("coordinate_frame", source.coordinate_frame.stable_id), + MetadataEntry("origin_convention", source.origin_convention.stable_id), + MetadataEntry("endpoint_convention", source.endpoint_convention.stable_id), + MetadataEntry( + "first_contact_no_roll_convention", + source.first_contact_no_roll_convention.stable_id, + ), + MetadataEntry("first_contact_observed", source.first_contact_observed), + MetadataEntry("no_roll_observed", source.no_roll_observed), + MetadataEntry("source_content_digest", source.source_content_digest), + MetadataEntry("provider", source.provider), + MetadataEntry("method_parameters", canonical_json(source.method_parameters)), + MetadataEntry("source_value_origin", source.source_value_origin), + ) + run = ProcessingRun( + processing_run_id=run_id, + source_artifact_ids=(artifact.artifact_id,), + method=MBT_COORDINATE_SOURCE_OPERATION, + parameters=parameters, + software_version="synthetic-upstream-mbt-coordinate-v1", + output_entity_id=coordinate_id, + ) + provenance = replace( + source.provenance, + processing_runs=(run,), + lineage_edges=( + LineageEdge( + artifact.artifact_id.qualified, + acquisition.acquisition_id.qualified, + LineageRelation.ACQUIRED_AS, + ), + LineageEdge( + source_observation.observation_id.qualified, + run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + artifact.artifact_id.qualified, run_id.qualified, LineageRelation.DERIVED_FROM + ), + LineageEdge( + acquisition.acquisition_id.qualified, + run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + MBT_COORDINATE_QUALIFICATION_RULE.stable_id, + run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge(run_id.qualified, coordinate_id.qualified, LineageRelation.PRODUCED), + ), + ) + return replace(source, provenance=provenance) + + def _mbt_distance_fixture( *, throw_type: MBTThrowVariant = MBTThrowVariant.SEATED_CHEST, @@ -916,15 +1574,31 @@ def _mbt_distance_fixture( sensor_modality=MBTSensorModality.MEASURING_TAPE, axis_or_frame=frame, ) + context = _context(prefix) source = build_mbt_source_distance_observation( observation_id=InstanceIdentifier("observation", prefix), result_id=InstanceIdentifier("result", prefix), - context=_context(prefix), + context=context, protocol_identity=protocol, distance_m=4.5, source_artifact=artifact, acquisition=acquisition, ) + coordinate_source = _mbt_coordinate_source( + source_observation=source, + protocol=protocol, + artifact=artifact, + acquisition=acquisition, + origin_coordinate_m=1.0, + endpoint_coordinate_m=5.5, + coordinate_frame=frame, + origin_convention=origin, + endpoint_convention=endpoint, + first_contact_no_roll_convention=no_roll, + first_contact_observed=True, + no_roll_observed=True, + prefix=prefix, + ) coordinate = MBTCoordinateEvidence( source_observation_id=source.observation_id, origin_coordinate_m=1.0, @@ -937,6 +1611,7 @@ def _mbt_distance_fixture( no_roll_observed=True, source_artifact_id=artifact.artifact_id, acquisition_id=acquisition.acquisition_id, + source_evidence=coordinate_source, ) return source, coordinate, protocol, artifact, acquisition, frame @@ -966,11 +1641,95 @@ def test_mbt_protocol_conventions_and_variant_comparability() -> None: compare_mbt_metric_results(distance, standing_distance).state is ComparabilityState.NOT_COMPARABLE ) - bad_coordinate = replace( - coordinate, - endpoint_convention=_ref("distance-endpoint", "rolled", "Rolled endpoint"), + with pytest.raises(ValueError, match="upstream coordinate evidence"): + replace( + coordinate, + endpoint_convention=_ref("distance-endpoint", "rolled", "Rolled endpoint"), + ) + + +def test_mbt_coordinate_authority_requires_upstream_adjudication() -> None: + source, coordinate, protocol, artifact, acquisition, frame = _mbt_distance_fixture( + prefix="synthetic-mbt-coordinate-authority" + ) + assert protocol.distance_origin_convention is not None + assert protocol.distance_endpoint_convention is not None + assert protocol.first_contact_no_roll_convention is not None + raw_coordinate = MBTCoordinateEvidence( + source_observation_id=source.observation_id, + origin_coordinate_m=1.0, + endpoint_coordinate_m=5.5, + coordinate_frame=frame, + origin_convention=protocol.distance_origin_convention, + endpoint_convention=protocol.distance_endpoint_convention, + first_contact_no_roll_convention=protocol.first_contact_no_roll_convention, + first_contact_observed=True, + no_roll_observed=True, + source_artifact_id=artifact.artifact_id, + acquisition_id=acquisition.acquisition_id, ) - assert _refused(calculate_mbt_throw_distance(bad_coordinate, source)).blocks_claim + assert _refused(calculate_mbt_throw_distance(raw_coordinate, source)).blocks_claim + source_evidence = coordinate.source_evidence + assert source_evidence is not None + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace(source_evidence, source_content_digest="sha256:wrong"), source + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace(source_evidence, source_context_id=InstanceIdentifier("context", "other")), + source, + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace(source_evidence, protocol_reference=_ref("protocol", "other", "Other")), + source, + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace(source_evidence, source_artifact_id=InstanceIdentifier("artifact", "other")), + source, + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace( + source_evidence, + source_acquisition_id=InstanceIdentifier("acquisition", "other"), + ), + source, + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace( + source_evidence, + endpoint_convention=_ref("distance-endpoint", "other", "Other endpoint"), + ), + source, + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace(source_evidence, coordinate_frame=_ref("frame", "other", "Other frame")), + source, + ) + with pytest.raises(ValueError): + normalize_mbt_coordinate_evidence( + replace( + source_evidence, + provenance=replace(source_evidence.provenance, lineage_edges=()), + ), + source, + ) + normalized = normalize_mbt_coordinate_evidence(source_evidence, source, coordinate) + assert normalized == coordinate + restored = from_canonical_json(canonical_json(source_evidence), type(source_evidence)) + assert restored == source_evidence + assert canonical_hash(restored) == canonical_hash(source_evidence) + distance = _mbt_result(calculate_mbt_throw_distance(normalized, source)) + assert distance.value_m == pytest.approx(4.5) + assert source.result.classification.value_origin is ValueOrigin.SOURCE_REPORTED + assert isinstance(source.result.value, ScalarValue) + assert source.result.value.value == pytest.approx(4.5) + assert acquisition.axis_or_frame == frame def test_mbt_instrumented_release_velocity_requires_qualified_event_evidence() -> None: @@ -1024,8 +1783,108 @@ def test_mbt_instrumented_release_velocity_requires_qualified_event_evidence() - timebase=timebase, axis_or_frame=frame, ) + context = _context("synthetic-mbt-release") + event_id = InstanceIdentifier("event-occurrence", "synthetic-mbt-release") + release_source = MBTReleaseEventSourceEvidence( + source_event_id=event_id, + source_context_id=context.context_id, + source_observation_id=InstanceIdentifier("observation", "synthetic-mbt-release"), + source_series_id=InstanceIdentifier("signal", "synthetic-mbt-release"), + source_artifact_id=artifact.artifact_id, + source_acquisition_id=acquisition.acquisition_id, + source_measurement_identity_id=ScientificIdentifier( + "dynamislm", "measurement-identity", "mbt-release:synthetic-mbt-release", "1.0.0" + ), + source_series_digest=digest, + source_timebase=timebase, + sample_index=2, + event_time_s=0.2, + release_method=velocity_definition, + coordinate_frame=frame, + protocol_reference=protocol.reference, + provider=acquisition.provider, + upstream_processing_run_id=InstanceIdentifier( + "processing-run", "synthetic-mbt-release:event-source" + ), + provenance=Provenance( + provenance_id=InstanceIdentifier("provenance", "synthetic-mbt-release:event-source"), + source_artifacts=(artifact,), + acquisitions=(acquisition,), + processing_runs=(), + lineage_edges=(), + ), + ) + release_source_parameters = ( + MetadataEntry("source_event_id", release_source.source_event_id.qualified), + MetadataEntry("source_context_id", release_source.source_context_id.qualified), + MetadataEntry("source_observation_id", release_source.source_observation_id.qualified), + MetadataEntry("source_series_id", release_source.source_series_id.qualified), + MetadataEntry("source_artifact_id", release_source.source_artifact_id.qualified), + MetadataEntry("source_acquisition_id", release_source.source_acquisition_id.qualified), + MetadataEntry( + "source_measurement_identity_id", + release_source.source_measurement_identity_id.stable_id, + ), + MetadataEntry("source_series_digest", release_source.source_series_digest), + MetadataEntry("source_timebase", canonical_json(release_source.source_timebase)), + MetadataEntry("sample_index", release_source.sample_index), + MetadataEntry("event_time_s", release_source.event_time_s), + MetadataEntry("release_method", release_source.release_method.stable_id), + MetadataEntry("coordinate_frame", release_source.coordinate_frame.stable_id), + MetadataEntry("protocol_reference", release_source.protocol_reference.stable_id), + MetadataEntry("provider", release_source.provider), + MetadataEntry("method_parameters", canonical_json(release_source.method_parameters)), + MetadataEntry("source_value_origin", release_source.source_value_origin), + ) + release_run = ProcessingRun( + processing_run_id=release_source.upstream_processing_run_id, + source_artifact_ids=(artifact.artifact_id,), + method=MBT_RELEASE_EVENT_SOURCE_OPERATION, + parameters=release_source_parameters, + software_version="synthetic-upstream-mbt-release-event-v1", + output_entity_id=event_id, + ) + release_source = replace( + release_source, + provenance=replace( + release_source.provenance, + processing_runs=(release_run,), + lineage_edges=( + LineageEdge( + release_source.source_observation_id.qualified, + release_run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + release_source.source_series_id.qualified, + release_run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + artifact.artifact_id.qualified, + release_run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + acquisition.acquisition_id.qualified, + release_run.processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + MBT_RELEASE_EVENT_QUALIFICATION_RULE.stable_id, + release_run.processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge( + release_run.processing_run_id.qualified, + event_id.qualified, + LineageRelation.PRODUCED, + ), + ), + ), + ) event = MBTReleaseEvent( - event_id=InstanceIdentifier("event-occurrence", "synthetic-mbt-release"), + event_id=event_id, source_observation_id=InstanceIdentifier("observation", "synthetic-mbt-release"), source_series_id=InstanceIdentifier("signal", "synthetic-mbt-release"), source_artifact_id=artifact.artifact_id, @@ -1035,11 +1894,12 @@ def test_mbt_instrumented_release_velocity_requires_qualified_event_evidence() - release_method=velocity_definition, coordinate_frame=frame, source_series_digest=digest, + source_evidence=release_source, ) evidence = build_mbt_release_velocity_evidence( observation_id=event.source_observation_id, result_id=InstanceIdentifier("result", "synthetic-mbt-release"), - context=_context("synthetic-mbt-release"), + context=context, protocol_identity=protocol, release_event=event, trajectory_samples=trajectory, @@ -1050,8 +1910,290 @@ def test_mbt_instrumented_release_velocity_requires_qualified_event_evidence() - source_artifact=artifact, acquisition=acquisition, ) + raw_event = replace(event, source_evidence=None) + with pytest.raises(ValueError, match="source.evidence"): + build_mbt_release_velocity_evidence( + observation_id=event.source_observation_id, + result_id=InstanceIdentifier("result", "synthetic-mbt-release-raw"), + context=context, + protocol_identity=protocol, + release_event=raw_event, + trajectory_samples=trajectory, + velocity_samples=velocity, + timebase=timebase, + coordinate_frame=frame, + velocity_definition=velocity_definition, + source_artifact=artifact, + acquisition=acquisition, + ) + with pytest.raises(ValueError): + replace(event, sample_index=1) + with pytest.raises(ValueError): + replace(event, event_time_s=0.1) + with pytest.raises(ValueError): + replace(event, release_method=_ref("velocity-definition", "other", "Other velocity")) + with pytest.raises(ValueError): + replace(event, source_artifact_id=InstanceIdentifier("artifact", "other-artifact")) + with pytest.raises(ValueError): + replace(event, acquisition_id=InstanceIdentifier("acquisition", "other-acquisition")) + assert event.source_evidence is not None + forged_protocol_event = replace( + event, + source_evidence=replace( + event.source_evidence, + protocol_reference=_ref("protocol", "other", "Other protocol"), + ), + ) + with pytest.raises(ValueError): + build_mbt_release_velocity_evidence( + observation_id=event.source_observation_id, + result_id=InstanceIdentifier("result", "synthetic-mbt-release-forged-protocol"), + context=context, + protocol_identity=protocol, + release_event=forged_protocol_event, + trajectory_samples=trajectory, + velocity_samples=velocity, + timebase=timebase, + coordinate_frame=frame, + velocity_definition=velocity_definition, + source_artifact=artifact, + acquisition=acquisition, + ) + forged_provenance_event = replace( + event, + source_evidence=replace( + event.source_evidence, + provenance=replace( + event.source_evidence.provenance, + lineage_edges=( + LineageEdge( + event.source_evidence.upstream_processing_run_id.qualified, + event.event_id.qualified, + LineageRelation.PRODUCED, + ), + ), + ), + ), + ) + with pytest.raises(ValueError): + build_mbt_release_velocity_evidence( + observation_id=event.source_observation_id, + result_id=InstanceIdentifier("result", "synthetic-mbt-release-forged-provenance"), + context=context, + protocol_identity=protocol, + release_event=forged_provenance_event, + trajectory_samples=trajectory, + velocity_samples=velocity, + timebase=timebase, + coordinate_frame=frame, + velocity_definition=velocity_definition, + source_artifact=artifact, + acquisition=acquisition, + ) + forged_origin_event = replace( + event, + source_evidence=replace(event.source_evidence, source_value_origin="SOURCE_REPORTED"), + ) + with pytest.raises(ValueError): + build_mbt_release_velocity_evidence( + observation_id=event.source_observation_id, + result_id=InstanceIdentifier("result", "synthetic-mbt-release-forged-origin"), + context=context, + protocol_identity=protocol, + release_event=forged_origin_event, + trajectory_samples=trajectory, + velocity_samples=velocity, + timebase=timebase, + coordinate_frame=frame, + velocity_definition=velocity_definition, + source_artifact=artifact, + acquisition=acquisition, + ) result = _mbt_result(calculate_mbt_instrumented_release_velocity(evidence)) assert result.value_m_per_s == pytest.approx(3.5) + assert evidence.release_event.source_evidence is not None + encoded = canonical_json(evidence.release_event.source_evidence) + restored = from_canonical_json(encoded, type(evidence.release_event.source_evidence)) + assert restored == evidence.release_event.source_evidence + assert canonical_hash(restored) == canonical_hash(evidence.release_event.source_evidence) + + +def _mbt_release_fixture( + trajectory_samples: tuple[tuple[int | float, int | float], ...], + velocity_samples: tuple[tuple[int | float, int | float], ...], + *, + prefix: str, +) -> MBTReleaseVelocityEvidence: + normalized_trajectory = tuple((float(time), float(value)) for time, value in trajectory_samples) + normalized_velocity = tuple((float(time), float(value)) for time, value in velocity_samples) + timebase = MBTTimebase( + MBTTimebaseKind.EXPLICIT, + times_s=tuple(time for time, _ in normalized_trajectory), + ) + frame = _ref("frame", f"{prefix}-forward", "Ball forward frame") + device = _ref("device", f"{prefix}-radar", "Synthetic radar") + protocol = MedicineBallThrowProtocolIdentity( + throw_type=MBTThrowVariant.STANDING_CHEST, + body_posture=MBTBodyPosture.STANDING, + support_restraint_condition=MBTSupportCondition.FREE_STANDING, + ball_mass_kg=2.0, + countermovement=MBTAllowedState.ALLOWED, + lower_body_contribution=MBTAllowedState.ALLOWED, + throw_arm_technique="two-hand chest throw", + starting_position="ball at chest", + release_semantics=MBTReleaseSemantics.EXPLICIT_RELEASE_EVENT, + measurement_method=_ref("measurement-method", f"{prefix}-radar", "Synthetic radar"), + provider="synthetic-provider", + device=device, + sensor_modality=MBTSensorModality.RADAR, + ) + digest = canonical_hash( + { + "trajectory_samples": normalized_trajectory, + "velocity_samples": normalized_velocity, + "timebase": timebase, + "coordinate_frame": frame, + } + ) + artifact = MedicineBallThrowSourceArtifact( + artifact_id=InstanceIdentifier("artifact", prefix), + content_digest=digest, + media_type="application/vnd.synthetic.mbt-trajectory", + status=MBTArtifactStatus.VERIFIED, + hash_scope=MBTArtifactHashScope.CANONICAL_SERIES_REPRESENTATION, + ) + acquisition = MedicineBallThrowAcquisitionRecord( + acquisition_id=InstanceIdentifier("acquisition", prefix), + device=device, + source_artifact_id=artifact.artifact_id, + provider="synthetic-provider", + sensor_modality=MBTSensorModality.RADAR, + timebase=timebase, + axis_or_frame=frame, + ) + context = _context(prefix) + observation_id = InstanceIdentifier("observation", prefix) + event_id = InstanceIdentifier("event-occurrence", prefix) + source_event = MBTReleaseEventSourceEvidence( + source_event_id=event_id, + source_context_id=context.context_id, + source_observation_id=observation_id, + source_series_id=InstanceIdentifier("signal", prefix), + source_artifact_id=artifact.artifact_id, + source_acquisition_id=acquisition.acquisition_id, + source_measurement_identity_id=ScientificIdentifier( + "dynamislm", "measurement-identity", f"mbt-release:{prefix}", "1.0.0" + ), + source_series_digest=digest, + source_timebase=timebase, + sample_index=len(normalized_velocity) - 1, + event_time_s=normalized_velocity[-1][0], + release_method=_ref("velocity-definition", f"{prefix}-sample", "Exact release sample"), + coordinate_frame=frame, + protocol_reference=protocol.reference, + provider=acquisition.provider, + upstream_processing_run_id=InstanceIdentifier( + "processing-run", f"{prefix}:release-event-source" + ), + provenance=Provenance( + provenance_id=InstanceIdentifier("provenance", f"{prefix}:release-event-source"), + source_artifacts=(artifact,), + acquisitions=(acquisition,), + processing_runs=(), + lineage_edges=(), + ), + ) + source_event = replace( + source_event, + provenance=replace( + source_event.provenance, + processing_runs=( + ProcessingRun( + processing_run_id=source_event.upstream_processing_run_id, + source_artifact_ids=(artifact.artifact_id,), + method=MBT_RELEASE_EVENT_SOURCE_OPERATION, + parameters=_release_event_source_parameters(source_event), + software_version="synthetic-upstream-mbt-release-event-v1", + output_entity_id=event_id, + ), + ), + lineage_edges=( + LineageEdge( + observation_id.qualified, + source_event.upstream_processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + source_event.source_series_id.qualified, + source_event.upstream_processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + artifact.artifact_id.qualified, + source_event.upstream_processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + acquisition.acquisition_id.qualified, + source_event.upstream_processing_run_id.qualified, + LineageRelation.DERIVED_FROM, + ), + LineageEdge( + MBT_RELEASE_EVENT_QUALIFICATION_RULE.stable_id, + source_event.upstream_processing_run_id.qualified, + LineageRelation.SUPPORTED_BY, + ), + LineageEdge( + source_event.upstream_processing_run_id.qualified, + event_id.qualified, + LineageRelation.PRODUCED, + ), + ), + ), + ) + event = MBTReleaseEvent( + event_id=event_id, + source_observation_id=observation_id, + source_series_id=source_event.source_series_id, + source_artifact_id=artifact.artifact_id, + acquisition_id=acquisition.acquisition_id, + sample_index=source_event.sample_index, + event_time_s=source_event.event_time_s, + release_method=source_event.release_method, + coordinate_frame=frame, + source_series_digest=digest, + source_evidence=source_event, + ) + return build_mbt_release_velocity_evidence( + observation_id=observation_id, + result_id=InstanceIdentifier("result", prefix), + context=context, + protocol_identity=protocol, + release_event=event, + trajectory_samples=trajectory_samples, + velocity_samples=velocity_samples, + timebase=timebase, + coordinate_frame=frame, + velocity_definition=source_event.release_method, + source_artifact=artifact, + acquisition=acquisition, + ) + + +def test_mbt_integer_and_float_samples_have_one_authoritative_digest() -> None: + integer_evidence = _mbt_release_fixture( + ((0, 0), (1, 2), (2, 3)), + ((0, 0), (1, 2), (2, 3)), + prefix="synthetic-mbt-numeric-equivalence", + ) + float_evidence = _mbt_release_fixture( + ((0.0, 0.0), (1.0, 2.0), (2.0, 3.0)), + ((0.0, 0.0), (1.0, 2.0), (2.0, 3.0)), + prefix="synthetic-mbt-numeric-equivalence", + ) + assert integer_evidence == float_evidence + assert canonical_hash(integer_evidence) == canonical_hash(float_evidence) + assert integer_evidence.release_velocity_m_per_s == pytest.approx(3.0) assert _refused( calculate_mbt_instrumented_release_velocity(cast(MBTReleaseVelocityEvidence, object())) ).blocks_claim From 6deee518aff98cc4fab2bb28eef69728230f810c Mon Sep 17 00:00:00 2001 From: Julio Rodriguez <144072916+Litju@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:28:25 +0000 Subject: [PATCH 4/4] fix(res68): close final comparability review seams --- docs/decisions/RES68-RECEIPT.json | 2 +- .../measurement/drop_jump/comparability.py | 7 +- tests/test_explosive_test_families.py | 131 +++++++++++++++++- 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/docs/decisions/RES68-RECEIPT.json b/docs/decisions/RES68-RECEIPT.json index b1cf781..700c189 100644 --- a/docs/decisions/RES68-RECEIPT.json +++ b/docs/decisions/RES68-RECEIPT.json @@ -70,7 +70,7 @@ "pytest": "PASS", "hosted_ci": "HISTORICAL_PASS_ONLY", "hosted_ci_run": "https://github.com/Litju/DynamisLM/actions/runs/35153538223", - "hosted_ci_tested_commit": "8e2f7d63efa28a1be9a23426da73046cfbab3a6f", + "hosted_ci_tested_commit": "6bea4abd85e71201413e7ec040684bc7db2adb4a", "fresh_hosted_ci": "REQUIRED_ON_REPAIR_HEAD", "advisory_review": "PENDING", "test_count": 749, diff --git a/src/dynamislm/measurement/drop_jump/comparability.py b/src/dynamislm/measurement/drop_jump/comparability.py index 38bd576..a66bfc1 100644 --- a/src/dynamislm/measurement/drop_jump/comparability.py +++ b/src/dynamislm/measurement/drop_jump/comparability.py @@ -298,7 +298,8 @@ def compare_drop_jump_metric_results( ) left_protocol = left_identity.semantic.protocol_identity right_protocol = right_identity.semantic.protocol_identity - if _is_actual_claim(claim): + actual_claim = _is_actual_claim(claim) + if actual_claim: if ( left_protocol.actual_drop_height_m is None or right_protocol.actual_drop_height_m is None @@ -331,8 +332,8 @@ def compare_drop_jump_metric_results( ) # Unknown actual height is deliberately ignored for claims whose equation # does not use it. Other protocol attributes remain material. - if _protocol_signature(left_protocol, include_actual=False) != _protocol_signature( - right_protocol, include_actual=False + if _protocol_signature(left_protocol, include_actual=actual_claim) != _protocol_signature( + right_protocol, include_actual=actual_claim ): return _result( request, diff --git a/tests/test_explosive_test_families.py b/tests/test_explosive_test_families.py index b41981c..cfd753d 100644 --- a/tests/test_explosive_test_families.py +++ b/tests/test_explosive_test_families.py @@ -362,6 +362,8 @@ def _dj_event_source( def _dj_fixture( *, actual_drop_height_m: float | None = None, + actual_drop_height_method: RegistryReference | None = None, + actual_drop_height_source: RegistryReference | None = None, arms: DropJumpArmCondition = DropJumpArmCondition.PROHIBITED, prefix: str = "synthetic-dj", ) -> tuple[ @@ -396,14 +398,19 @@ def _dj_fixture( nominal_box_height_m=0.30, actual_drop_height_m=actual_drop_height_m, actual_drop_height_method=( - _ref( - "measurement-method", - "synthetic-drop-height", - "Synthetic independent drop-height method", + actual_drop_height_method + if actual_drop_height_method is not None + else ( + _ref( + "measurement-method", + "synthetic-drop-height", + "Synthetic independent drop-height method", + ) + if actual_drop_height_m is not None + else None ) - if actual_drop_height_m is not None - else None ), + actual_drop_height_source=actual_drop_height_source, initiation_mode=DropJumpInitiationMode.STEP_OFF, pre_drop_posture="quiet standing on box", pre_drop_stillness=True, @@ -628,6 +635,116 @@ def test_dj_nominal_actual_height_and_claim_relative_comparability() -> None: ) +def test_dj_actual_height_identity_requires_complete_claim_relative_metadata() -> None: + actual_method = _ref( + "measurement-method", + "synthetic-drop-height-complete", + "Synthetic independent drop-height method", + ) + actual_source = _ref( + "measurement-source", + "synthetic-drop-height-complete", + "Synthetic independent drop-height source", + ) + + def contact_result( + source: ScientificMeasurementObservation, + events: dict[DropJumpEventLabel, DropJumpEventOccurrence], + ) -> DropJumpMetricResult: + return _dj_result( + calculate_drop_jump_contact_time( + events[DropJumpEventLabel.TOUCHDOWN], + events[DropJumpEventLabel.REBOUND_TAKEOFF], + source, + ) + ) + + same_a_source, same_a_events, _ = _dj_fixture( + actual_drop_height_m=0.30, + actual_drop_height_method=actual_method, + actual_drop_height_source=actual_source, + prefix="synthetic-dj-actual-same-a", + ) + same_b_source, same_b_events, _ = _dj_fixture( + actual_drop_height_m=0.30, + actual_drop_height_method=actual_method, + actual_drop_height_source=actual_source, + prefix="synthetic-dj-actual-same-b", + ) + same_a = contact_result(same_a_source, same_a_events) + same_b = contact_result(same_b_source, same_b_events) + assert ( + compare_drop_jump_metric_results(same_a, same_b, "compare actual drop exposure").state + is ComparabilityState.COMPARABLE + ) + + different_method_source, different_method_events, _ = _dj_fixture( + actual_drop_height_m=0.30, + actual_drop_height_method=_ref( + "measurement-method", + "synthetic-drop-height-different-method", + "Different synthetic independent drop-height method", + ), + actual_drop_height_source=actual_source, + prefix="synthetic-dj-actual-different-method", + ) + assert ( + compare_drop_jump_metric_results( + same_a, + contact_result(different_method_source, different_method_events), + "compare actual drop exposure", + ).state + is ComparabilityState.BRIDGE_VALIDATION_REQUIRED + ) + + different_source_source, different_source_events, _ = _dj_fixture( + actual_drop_height_m=0.30, + actual_drop_height_method=actual_method, + actual_drop_height_source=_ref( + "measurement-source", + "synthetic-drop-height-different-source", + "Different synthetic independent drop-height source", + ), + prefix="synthetic-dj-actual-different-source", + ) + assert ( + compare_drop_jump_metric_results( + same_a, + contact_result(different_source_source, different_source_events), + "compare actual drop exposure", + ).state + is ComparabilityState.BRIDGE_VALIDATION_REQUIRED + ) + + different_height_source, different_height_events, _ = _dj_fixture( + actual_drop_height_m=0.31, + actual_drop_height_method=actual_method, + actual_drop_height_source=actual_source, + prefix="synthetic-dj-actual-different-height", + ) + assert ( + compare_drop_jump_metric_results( + same_a, + contact_result(different_height_source, different_height_events), + "compare actual drop exposure", + ).state + is ComparabilityState.BRIDGE_VALIDATION_REQUIRED + ) + + unknown_a_source, unknown_a_events, _ = _dj_fixture(prefix="synthetic-dj-actual-unknown-a") + unknown_b_source, unknown_b_events, _ = _dj_fixture(prefix="synthetic-dj-actual-unknown-b") + unknown_a = contact_result(unknown_a_source, unknown_a_events) + unknown_b = contact_result(unknown_b_source, unknown_b_events) + assert ( + compare_drop_jump_metric_results(unknown_a, unknown_b, "compare actual drop exposure").state + is ComparabilityState.INSUFFICIENT_INFORMATION + ) + assert ( + compare_drop_jump_metric_results(unknown_a, unknown_b).state + is ComparabilityState.COMPARABLE + ) + + def test_dj_event_order_source_and_missing_landing_fail_closed() -> None: source, events, _ = _dj_fixture() ordered = ( @@ -1911,7 +2028,7 @@ def test_mbt_instrumented_release_velocity_requires_qualified_event_evidence() - acquisition=acquisition, ) raw_event = replace(event, source_evidence=None) - with pytest.raises(ValueError, match="source.evidence"): + with pytest.raises(ValueError, match=r"source_evidence"): build_mbt_release_velocity_evidence( observation_id=event.source_observation_id, result_id=InstanceIdentifier("result", "synthetic-mbt-release-raw"),