Skip to content

Say "no timestamp" instead of inventing 1990-01-01 - #631

Merged
xmap merged 6 commits into
mainfrom
worktree-impossible-timestamps
Aug 9, 2026
Merged

Say "no timestamp" instead of inventing 1990-01-01#631
xmap merged 6 commits into
mainfrom
worktree-impossible-timestamps

Conversation

@xmap

@xmap xmap commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #629. Merge that first, then retarget this to main so CI runs
(ci.yml only triggers on PRs targeting main, so a stacked PR gets no checks).

EPICS starts counting at 1990-01-01, so a record that never processed carries
stamp zero, and datetime.fromtimestamp turned that into a date that parses,
sorts and filters exactly like a real reading. Measurement.produced_at is now
datetime | None, and each adapter decides absence for itself because every
substrate spells it differently.

Measured, not inferred

Through CORA's own adapter on the subscribe path, against the live 2-BM IOC:

S02BM-PSS:StaA:SecureM  'ON'   produced_at = 1990-01-01T00:00:00Z
S02BM-PSS:StaB:SecureM  'OFF'  produced_at = 1990-01-01T00:00:00Z

Every update, both signals. The everyday case there, not an edge.

This was also not latent: conductor.py already persisted the field into run
records in two places, so the sentinel was reaching stored history. The
enclosure seam is the one place it was dropped, which is why it went unnoticed
(2-BM-A's recorded observation looked sane because it was stamped from CORA's
clock, not the IOC's).

The rule

Each substrate's own epoch is the boundary, since no reading can predate the
clock that stamps it. EPICS adapters cut at 1990, Tango at 1970.

This catches a MISSING time, never a WRONG one. An IOC whose clock is set to
the wrong year reports a plausible stamp that no adapter can detect, and the
docstrings say so rather than implying broader coverage.

Two things found along the way

caproto substituted datetime.now() when metadata carried no stamp, which
made an ingest time indistinguishable from a substrate time once written down.
That is the exact fallback this design rejects. Removed and pinned.

Strengthening the existing assertions from "tzinfo is set" to "a stamp is
present" exposed a bug in the softIOC fixture: its NTNDArray Q:group aimed
+type:"meta" at named subfields instead of the structure root, nesting alarm
and timeStamp one level too deep for p4p's unwrap. Every image read had been
reporting NO_ALARM and 1970-01-01 regardless of the record. The alarm half
passed unnoticed because the default matched the expected value. An assertion
weak enough to pass on a synthesized default pins nothing.

Not in scope

Threading the substrate time through the command, event payload and projection
is the next slice, and it carries the schema change. This PR only makes absence
representable and stops the invented dates. 2-BM's permit records are unchanged
by it.

Verification

  • no-IO lane: 46581 passed (parent 46567 plus exactly the 14 new unit tests)
  • architecture: 30313 passed, re-run against the final tree
  • live-softIOC integration: 34 CA + caproto, 18 PVA
  • pyright clean; all pre-commit hooks pass

🤖 Generated with Claude Code

xmap and others added 2 commits August 9, 2026 07:09
EPICS separates a value that is fine while the process it describes is
in alarm (MINOR, MAJOR) from a value that cannot be trusted at all
(INVALID). All three EPICS adapters collapsed MAJOR onto Bad, which
threw away a reading the IOC and the floor both consider authoritative.

At 2-BM the everyday case is an interlocked hutch left secured with a
designed MAJOR STATE alarm on SecureM: the position is known, the alarm
is the design, and CORA was reading it as a value it could not vouch
for. Mapping MAJOR to Uncertain restores the standard semantics and
puts the choice back where it belongs, at each consumer.

No behaviour moves with this commit. Every consumer today tests
quality == "Good", so Uncertain fails exactly where Bad failed; the
only visible change is the reason string on a halted check. Consumers
that mean "can I believe this value" rather than "can I act on this
value" opt in to != "Bad" one at a time, on their own merits.

The port docstring claimed MINOR collapsed to Bad as well, which no
adapter has done; it now states the mapping the code implements.

The softIOC fixture gains a second alarm record, because MAJOR and
INVALID now land on opposite sides of the trichotomy and one record
cannot pin both arms. A unit-tier table pins all three adapters
together so a drift between them fails in the fast lane rather than
waiting for a live IOC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EPICS starts counting at 1990-01-01, so a record that never processed
carries stamp zero, and `datetime.fromtimestamp` turned that into a
date that parses, sorts and filters exactly like a real reading.
Nothing marked it synthetic, and the port docstring positively
asserted the opposite. `Measurement.produced_at` is now
`datetime | None`, and each adapter decides absence for itself
because every substrate spells it differently.

Measured at APS 2-BM through CORA's own adapter on the subscribe
path: both PSS permit signals report an undefined stamp on every
update, so this was the everyday case there rather than an edge.

  S02BM-PSS:StaA:SecureM  'ON'   produced_at = 1990-01-01T00:00:00Z
  S02BM-PSS:StaB:SecureM  'OFF'  produced_at = 1990-01-01T00:00:00Z

Not a latent problem either: the conductor already persisted the
field into run records twice, so the sentinel was reaching stored
history. The enclosure seam is the one place it was dropped, which is
why nobody noticed.

The boundary is each substrate's own epoch, since no reading can
predate the clock that stamps it. EPICS adapters cut at 1990, Tango
at 1970. This catches a MISSING time, never a WRONG one: an IOC whose
clock is set to the wrong year still reports a plausible stamp that
no adapter can detect.

caproto additionally substituted `datetime.now()` when metadata
carried no stamp. That made an ingest time indistinguishable from a
substrate time once written down, which is the confusion this change
exists to remove; it is gone and pinned so it cannot return.

Two docstrings claimed the enclosure handler preserved the
substrate's time and two claimed it used the Clock. The code did the
latter and the seam drops the field entirely. Both now say so, and
Supply's said its time was "captured on the SUBSCRIPTION side" when
Supply has no observer port at all.

Strengthening the existing assertions from "tzinfo is set" to "a
stamp is present" exposed a separate bug in the softIOC fixture: its
NTNDArray Q:group aimed `+type:"meta"` at named subfields instead of
the structure root, nesting alarm and timeStamp one level too deep
for p4p's unwrap. Every image read had been reporting NO_ALARM and
1970-01-01 regardless of the record. The alarm half passed unnoticed
because the default matched the expected value. An assertion weak
enough to pass on a synthesized default pins nothing.

Threading the substrate time through the command, event payload and
projection is deliberately NOT here; it is the next slice.

Verified: no-IO lane 46581 passed (parent 46567 plus the 14 new unit
tests, exactly), architecture 30313 passed against the final tree,
pyright clean, and live-softIOC integration green on all three wire
protocols (34 CA + caproto, 18 PVA).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from worktree-alarm-severity-mapping to main August 9, 2026 16:21
…ible-timestamps

# Conflicts:
#	apps/api/tests/integration/_softioc.py
#	apps/api/tests/integration/test_caproto_control_port.py
#	apps/api/tests/integration/test_epics_ca_control_port.py
#	apps/api/tests/integration/test_epics_pva_control_port.py
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/api
  _enclosure_permit_observer.py
  apps/api/src/cora/enclosure/features/observe_enclosure_status
  command.py
  apps/api/src/cora/infrastructure/projection
  wakeup.py
  apps/api/src/cora/operation
  _steering_resume.py
  conductor.py
  apps/api/src/cora/operation/adapters
  caproto_control_port.py
  epics_ca_control_port.py
  epics_pva_control_port.py
  tango_control_port.py
  apps/api/src/cora/operation/ports
  measurement.py
  apps/api/src/cora/supply/features/observe_supply_status
  command.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap
xmap merged commit 015718e into main Aug 9, 2026
19 checks passed
@xmap
xmap deleted the worktree-impossible-timestamps branch August 9, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant