Skip to content

Add PHC-base-clock refclock mode (phc.sync = false) - #317

Draft
jclark wants to merge 28 commits into
masterfrom
freerun-phc
Draft

Add PHC-base-clock refclock mode (phc.sync = false)#317
jclark wants to merge 28 commits into
masterfrom
freerun-phc

Conversation

@jclark

@jclark jclark commented Jun 12, 2026

Copy link
Copy Markdown
Owner

For #257.

With sync = false in the [phc] table, satpulsed leaves the PHC free-running and sends chrony SOCK samples whose tv is the PHC timestamp of each PPS edge, treated as TAI; the offset is the difference between GPS-derived TAI at the edge (with sawtooth correction) and that PHC reading. Chrony maps the PHC to the system clock on its side via its multi-clock support, so GPS-derived offsets are never re-expressed in the system-clock domain inside satpulse.

The branch carries the shared free-running machinery developed on phc-sample (pulse-edge admissibility, dual-edge polarity selection, the wallClock fit, PHC discontinuity detection, sawtooth correction), reconciled with current master, and reshapes emission to per-edge: the cross-sample regression, the sim rig, and everything else only the system-referenced flavor needs is deferred to #256, which stacks on this branch and ends up essentially where phc-sample was heading. With only one free-running flavor here, phc.sync = false alone selects the mode; the ntp.clock selector arrives with #256.

Observer.NTPSample does not fire in this mode: satpulse never computes a sys-vs-true offset when the PHC is free-running.

End-to-end validation against multi-clock chrony on real hardware is still pending (binary go/no-go per the plan in #257); this PR should not merge before that.

jclark added 26 commits April 19, 2026 14:56
Creates the phcsample package with the Config, wallClock, Generator,
and phcWindow surface. wallClock does the monotonic-to-UTC labelling
regression with gates tailored to its actual observables; Generator
and phcWindow wire up as thin pass-throughs with TrueTimeOffset
stubbed to ErrNotReady.

Also revises the plan to reframe Config parameter semantics: phcsample
inherits some field names from phcsync.ResetConfig only where meanings
survive the change from pair-based pulse/message correlation to a
continuous message-stream regression, and introduces new fields
(MsgWindow, MaxMsgGap, MinMsgSpan, ClockRateLimit, MsgTimingVariation)
for observables that exist only in the new model.
Discrete-event simulator paralleling syncsim/sim: feeds Generator with
synthesized PulseEdge events and UTC time messages, installs a capture
Sampler, and scores emitted samples against ground-truth true time.
Covers single-edge, dual-edge, startup, missing messages/edges, gross
outliers, and multi-rate (5 Hz) message delivery. Steady-state cases
stay red until phcWindow.TrueTimeOffset lands in step 6, per plan.

MsgRate config field strides message generation at 1/rate and applies
delay+jitter per grid point, exercising the "messages can outnumber
pulses" path that distinguishes phcsample's wallClock regression from
phcsync's per-pulse pairing.
Fills in the phcWindow pipeline: stride-consistency + dual-edge
polarity selection, edge-to-UTC labelling via wallClock, and the
PHC calibration OLS fit. Adds a backward-coverage gate on wallClock
so edges predating the retained message window surface as
ErrNotReady rather than being labelled by backward extrapolation
after a long message outage.
Three runtime modes are now explicit in gpsevent.Dispatcher:
controller (PHC-disciplined), generator (PHC free-running), or
neither (serial timing). A new phc.freeRunning TOML field selects
between controller and generator when a PHC is present.

phcsample.Generator loses its Sampler interface; the dispatcher
now emits obs.NTPSample uniformly across all three modes, after
rc.Sample succeeds, consistent with the existing sysSample path.
NewInstance replaces the prior "drop and reconstruct via factory"
approach for PHC era transitions.

Plan gains two phase-2 cleanup steps (unify the pulse-edge sink
interface; add logging inside phcsample) and a faster-startup
motivation section.
A derivative plan that assumes phc-sample.md and describes a ppssample
mode sourcing edges from the Linux kernel PPS API (/dev/ppsN). Scoped
as a delta: what reuses from phcsample, what moves to a new shared
gensample package, what is genuinely new. Four post-MVP input paths
(pps_ldisc attach + shared tty, Linux TIOCMIWAIT, Darwin CTS polling,
FreeBSD timepps.h) broken out with code-level detail. Appendix covers
pure-Go Linux PPS via x/sys/unix with cgo -godefs only as a build-time
constant generator.
# Conflicts:
#	time/app/daemon/daemon.go
Surface NTPSample in the web UI via SSE so the sys-vs-true-time
offset is visible across all three dispatcher modes, not only
free-running.
Apply PrePulse pulse-offset corrections to the PHC calibration window
so each ruler mark lands at the true top-of-second rather than at the
physical pulse edge. PulseOffset enters the fit as an X-side shift
(scaled from true-time nanoseconds to PHC nanoseconds via the median
pulse interval), preserving the UTC-in / UTC-out boundary established
in phase 1.

Add IgnoreSawtoothCorrection to phcsample.Config as an operator
escape hatch and A/B toggle; Generator skips the corrector when set.

timemsg.Buffer gains GetUTCPulseCorrection (float64 ns, PrePulse-only,
keyed on the message's UTC derived from TAI when UTCTime is absent)
and stops forwarding PrePulse messages to MsgUTCTime so their
upcoming-second UTC never pollutes the wallClock / SOCK pipeline.

phcsample/sim installs the buffer as the generator's pulse corrector
and synthesises PrePulse correction events. The acceptance test
(TestPHCSampleSawtoothCorrection) exercises a 15 ns sawtooth under
both fast (250 us) and CM4/5-style (0.25 s) pulse-delivery delays,
asserting that corrections visibly shrink the residual delta versus
the ignored case and recover close to the zero-sawtooth baseline.
Phase 1 was already complete (steps 3-7 all merged) but the status
line still listed only 3-5. Phase 2 now has its first landed step
(10 - PrePulse sawtooth correction).
Info-level "generated first NTP refclock sample" on the first
Observer.NTPSample call of the process lifetime, covering the
step-12 warmup log generically (serial / disciplined / free-running).

Wired into the daemon observer fan-out alongside the existing logobs
observers. Message uses "refclock" / "generated" rather than any
chrony-specific wording, matching satpulse.toml(5) terminology.

Plan updated: the warmup info log leaves phcsample scope; step 12
now covers only the per-sample and failure-path debug tiers, with
per-era re-firing noted as deferred pending a pause/suspend hook.
…sary

Two complementary tweaks so warm-up lands on the same pulse across
normal message-delivery-timing jitter:

  - wallClock.predictUTC allows up to 500 ms backward extrapolation
    before the earliest retained message's read-time. The old
    threshold was points[0].tRead - expectedDelay, which floated
    with per-message delay jitter; a first message that came in
    slightly later than ExpectedDelay could reject an otherwise
    valid early edge. The 500 ms window is generous enough to cover
    any plausible edge-to-message-read skew without letting the fit
    reach arbitrarily far back into stale pulse history after
    recovery from a message gap. ErrNotReady (not errStale) is still
    the sentinel.

  - MinMsgSpan default drops from 3.0 s to 2.9 s so normal
    sub-second delivery jitter on 1 Hz messages does not push
    startup from pulse 5 to pulse 6.

sim: expose PulseSeed and MsgSeed on Config (defaults preserve the
former hardcoded 999/888 seeds), and track Stats.ReadyPulse - the
1-based pulse index of the first successful sample. New
TestPHCSampleWarmUpMsgSeedSweep sweeps 100 message seeds under the
default scenario and asserts ReadyPulse == 5 every time, locking in
the invariant these tweaks restore.
Add phase-2 step 15: the calibration window already discards a
forward PHC step via firstGap, but a backward step leaves pre- and
post-step edges in the same fit. Extend the gap detector to cover
both directions.
Step 9 becomes purely about TOML wiring of [phcsample] into the
daemon. Step 16 covers the man-page addition for phc.freeRunning
(with a pointer to the schema for [phcsample]) and extending
configs/config-schema.json to describe freeRunning and [phcsample].
Adds Config.PHCSample of type phcsample.Config, seeded from
phcsample.DefaultConfig(), validated alongside Sync, and passed
to phcsample.NewGenerator in free-running mode instead of the
built-in defaults. go-toml's case-insensitive field matching
maps the [phcsample] section to the PHCSample field, consistent
with Sync / LeapSecond / PHC.

Covers step 9 of plan/phc-sample.md phase 2.
Schema now covers phc.freeRunning and all 11 fields of phcsample.Config,
with descriptions, defaults, and bounds kept in sync with the Go
toml:, check:, and comment: tags. Also adds a matching comment: tag
to PHCConfig.FreeRunning so the Go struct and schema description
agree. Plan status updated: schema part of step 16 landed; man-page
portion still outstanding.
Introduce gpsevent.PulseReceiver with a single Pulse(ts, tr) method so
the dispatcher delivers edges without caring whether phcsync.Controller
or phcsample.Generator is on the receiving end. Both types now satisfy
the interface: their former exported PulseEdge structs are unexported,
Controller.PulseEdge is renamed to Pulse(ts, tr), and Generator.Pulse
takes (ts, tr) instead of an edge value. gpsevent.NewDispatcher takes a
single PulseReceiver; a constructor-time type switch populates the
typed controller/generator fields for mode-specific paths, and
d.pulse is reassigned alongside d.generator on PauseEvent so post-
resume edges land in the fresh instance.
Sync defaults to true; set to false to leave the PHC free-running and
use [sample.phc] to configure the refclock sample generator. Inverts
the boolean sense so the default behavior (discipline the PHC) is
unchanged, and nests the sample-generation tuning under [sample] so a
future ppssample mode fits at [sample.pps] alongside.

Also trim doc comments on IgnoreSawtoothCorrection in both
phcsync.TrackingConfig and phcsample.Config: drop the PrePulse
specificity (inaccurate for phcsync, where both pre- and post-pulse
corrections apply) and the editorializing about when to use it.
…sample step 15)

Replace firstGap with firstDiscontinuity so consistentEdges restarts
from the post-step suffix on forward or backward PHC steps injected
by another process (e.g. a future chrony PHC-disciplining feature,
or phc_ctl). The detector uses an absolute DiscontinuityThreshold
(seconds; default 1 ms) and a neighbour-consistency check; Stage 4/5
PPB-based per-edge rejection is unchanged. Adds sim-rig PHCStep
fault injection under a phcsample-local FaultConfig that embeds
syncsim.FaultConfig, plus paired acceptance tests that run each step
scenario with the detector active and effectively disabled so the
clean-residual check cannot go vacuous.
  ExpectedDelay       0.1  -> 0.15
  MsgTimingVariation  0.05 -> 0.2
  EdgeSecondTolerance 0.1  -> 0.4
Add a Logging subsection under Implementation design capturing the
policy (package logs itself, TrueTimeOffset returns bool, ErrNotReady
becomes strictly warmup), the rejection-to-TOML-parameter mapping, the
attribution rule, and reset.go-style sticky-flag repetition
suppression. Step 12 in Phase 2 becomes a pointer to that subsection.
Add prerequisites and relationship-to-other-modes section. Rewrite core
per-edge calc in terms of ntime.Time. Split mode selection into
orthogonal edge-source and sample-domain axes, with the invalid
[pps] + phc-* combination rejected at config load. Expand the
dispatcher table to cover all modes in the family.
Brings the phc-sample branch up to current master plus the ntime.Time
refactor (#258). Beyond the textual conflicts, this adapts the
phc-sample-only code to APIs that changed on master since the last
merge, and converts the phcsample sample path to ntime.Time:

- wallClock's Y axis and calibEntry.Y carry ntime.Time instead of
  time.Time; Generator.MsgUTCTime converts at the boundary.
- timemsg and phcsample/sim use the opt.Val gpsprot accessors.
- The dispatcher keeps the pulse/PulseReceiver mode dispatch from
  phc-sample combined with the SHM writer support from master.
- replay.go keeps master's typed JSON envelope with the unified
  Pulse call.
@jclark

jclark commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

Review map: where the additive change is, relative to the old phc-sample branch.

Of the ~1040 non-test Go lines in this PR, roughly 600 are machinery carried from phc-sample unchanged or mechanically adapted, and the genuinely new logic is ~250 lines in five places:

  1. phc_window.go, bottom half — labelEdge / labelEdges / Samples (~90 lines). The heart of the mode, replacing mapEdgesToUTC + fitAndEvaluate + TrueTimeOffset. Worth active checking:

    • Offset arithmetic: offset = (ref − edgePHC) − corrNs; the sawtooth sign follows from true_second = pulse_time + correction, so true time at the edge is ref − corr.
    • Precision: the potentially huge TAI−PHC distance is carried as an int64 Duration and converted to float64 seconds only at the end (the SOCK wire is a double anyway).
    • Consume semantics (new relative to 256's relabel-per-query): lastEmitted makes each edge emit at most once; a tolerance-rejected edge stays retryable until a later edge emits, then is skipped forever; errStale stops iteration mid-window; after warm-up the whole backlog emits in one burst.
  2. generator.goemit, MsgTAITime, leap piggyback (~40 lines). Emission triggers on both pulse and message arrival. One behavioral consequence to bless: in steady state the sample emits at the pulse, via the wallClock fit's forward extrapolation inside maxMsgGap (documented by TestGeneratorEmitsPerEdge). The leap flag attached to a sample is the last one delivered by the message stream.

  3. timemsg.go additions (~80 lines). msgTAITime delivery: PrePulse skip, TAI-or-UTC-converted fallback (msgTAI via ls.UTCtoTime), millisecond rounding, monotonic dedupe, leap kind via StateAt. And GetTAIPulseCorrection: PrePulse-only, TAI-keyed walk-back, unrounded float64 via the new pulseOffsetNs split (whose rounded wrapper preserves the existing GetPulseCorrection behavior).

  4. Dispatcher wiring (~50 lines). Per-mode sink routing; MsgTAITime is forwarded through the dispatcher so era transitions that swap d.generator don't leave the buffer pointing at a dead instance; Pause → NewInstance; deliberate absence of Observer.NTPSample in this mode.

  5. Config/daemon (~20 lines). validatePHCSync (requires ntp.sock.path, excludes SHM) and gating ptp4l setup on phc.sync.

Everything else is deletion (regression layer, sim rig, extrapolation gate, leap-reset scaffolding — the TAI labelling is leap-immune), type-mechanical (the ntime.Time swap in wallclock.go; git diff --color-words origin/phc-sample -- time/internal/phcsample/wallclock.go collapses it to renames), or carried over verbatim (all the admissibility filtering, the wallClock gates, the config knobs). One semantic note on the "unchanged" wallClock: identical math, but its input changed domain — it now fits monotonic→TAI rather than →UTC.

@jclark
jclark marked this pull request as draft June 12, 2026 06:30
With sync = false in the [phc] table, satpulsed leaves the PHC
free-running and sends chrony SOCK samples whose tv is the PHC
timestamp of each PPS edge, treated as TAI; the offset is the
difference between GPS-derived TAI at the edge (with sawtooth
correction) and that PHC reading. Chrony maps the PHC to the system
clock on its side via its multi-clock support, so GPS-derived offsets
are never re-expressed in the system-clock domain inside satpulse.

Relative to the cross-sample model previously on this branch:

- The regression layer goes away: each admitted pulse edge is its own
  sample, emitted to a new phcsample.Sampler as soon as the wallClock
  fit can label it. fitAndEvaluate, calibEntry, and the extrapolation
  gate are deleted, along with the phcsample/sim rig whose scoring
  measured regression error. (System-referenced free-running samples
  return with #256, stacked on this branch, which also introduces the
  ntp.clock key for selecting between the two free-running flavors.)
- The Generator consumes TAI instead of UTC: timemsg.Buffer grows a
  MsgTAITimer sink and a TAI-keyed GetTAIPulseCorrection accessor;
  MsgUTCTimer is unchanged and remains the serial-mode sink.
- The wallClock Y axis is domain-neutral (ntime.Time); the mode picks
  the domain flowing through at wiring time.
- phc.sync = false is validated against the NTP settings: it requires
  a SOCK path and excludes SHM (whose timestamps are system-clock by
  definition).

Observer.NTPSample does not fire in this mode: satpulse never computes
a sys-vs-true offset when the PHC is free-running.

End-to-end validation against multi-clock chrony on real hardware is
still pending; tracked in #257.
The wallClock fit carried its own ordinary-least-squares arithmetic,
and the PHC calibration fit arriving with #256 carries another copy.
Pull the algorithmic core into one file - fitLine / fittedLine /
lineAt / lineResidual, generic over int64- and float64-based
coordinates - so it is tested directly against known points instead of
only through the pipelines. The file is self-contained so it can be
promoted to a library package if a consumer outside phcsample appears.

The wallClock's slope-1 fallback for zero x spread is dropped: the
MinMsgSpan gate guarantees x spread before the fit is consulted, so it
was unreachable; fitLine reports a degenerate fit and predictRef
answers ErrNotReady.
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