Skip to content

SLICE-1.3: Encoder-Rate Motion

Goal

Separate the existing 20 Hz "UI position" feed from a new 1 kHz "encoder position" feed. UI position keeps its current shape — IMotionController.PositionChanged fires every 50 ms during a commanded move and AppState.StageX/StageY stay at the same cadence. A new IEncoderStream (bounded channel of EncoderSample) ticks at 1 kHz with per-axis noise around the commanded position, drained by a pipeline service that does not write into AppState. The slice's measurement evidence is that the encoder receiver sees 1 kHz ±2% sustained for 10 minutes while UI position stays unchanged.

Why This Slice

Today there is one motion stream: SimulatedMotionController.InterpolateAsync ticks at 50 ms (20 Hz), updates _currentX/_currentY, and raises PositionChanged. MainViewModel projects that onto AppState.StageX/StageY, where every reader of AppState (UI, command guards, run-summary capture) sees the same data. There is no high-rate position signal anywhere in the pipeline.

Real wafer inspection tools have linear / rotary encoders attached to each axis that report at 1–10 kHz. That stream is consumed by tuning windows, plot views, and (eventually) closed-loop control loops — none of which can tolerate the lock contention of going through a single AppState snapshot at full encoder rate. A real machine's UI shows a smoothed 20 Hz position; its tuning panel shows the raw 1 kHz feed; both come from the same physical sensor but go through different software paths.

This slice introduces the shape of that two-track design: a low-rate UI feed and a high-rate data-plane feed with a dedicated channel. The high-rate feed deliberately bypasses AppState — that is the load-bearing decision the slice exists to validate. Phase 2's ITelemetryBuffer / data-plane lift-out (SLICE-2.3) will use exactly this shape; SLICE-1.3 builds the first instance of it under measurement so the lift-out lands on a pattern that has already been exercised. If the encoder stream were routed through AppState.LatestEncoderSample, the channel would coalesce 50× per snapshot tick at 20 Hz and the whole "1 kHz at receiver" target would be impossible to demonstrate — that loss is the evidence Phase 2 needs.

Requirements Coverage

In Scope

  • a new domain shape for encoder samples:
    • EncoderAxis enum: X, Y
    • EncoderSample(EncoderAxis Axis, DateTimeOffset Timestamp, double PositionUnits, TagQuality Quality)PositionUnits is in the same units as IMotionController.CurrentX/CurrentY (the existing simulator units), TagQuality is reused from SLICE-1.1
    • EncoderSnapshot(ImmutableArray<EncoderSample> Samples) — the per-tick batch that crosses the channel boundary; one snapshot per producer tick carries one EncoderSample per axis (currently 2)
  • a new producer abstraction:
    • IEncoderStream exposes a bounded channel of EncoderSnapshot, plus per-axis coalesce counters (parallels ITagStream from SLICE-1.1):
      • ChannelReader<EncoderSnapshot> Reader { get; }
      • long SnapshotCoalescedCount { get; } — channel-level drops
      • long PerAxisCoalescedCount(EncoderAxis axis) — per-axis emitter overwrite count (only meaningful if the producer were to publish per axis independently; included for symmetry with ITagStream and so axis-rate-mismatch experiments later don't require an interface change)
    • SimulatedEncoderSource (in Infrastructure.Simulator) runs a single high-rate ticker that:
      • reads commanded position from IMotionController.CurrentX/CurrentY
      • adds a per-axis noise sample using the existing NoiseModel infrastructure from SLICE-1.1 (default: RandomWalkNoise per axis with small StepStdDev and a clamped envelope)
      • publishes one EncoderSnapshot per tick to the bounded channel
  • a new pipeline service:
    • EncoderStreamPipelineService (in Application.Services) drains the channel as a BackgroundService and increments encoder.samples.ingested{axis=…} once per sample. It does not call IAppStateStore.Update. That is the slice's load-bearing design decision — the high-rate stream stays out of AppState
    • per-snapshot drops (channel coalesce events) increment encoder.samples.coalesced{axis=…} once per axis on the dropped snapshot, log a Warning diagnostics entry no more than once per second (rate-limited at the pipeline service to avoid drowning the diagnostics timeline), and update IEncoderStream.SnapshotCoalescedCount
  • configuration:
    • Simulator:Encoder section bound to SimulatorEncoderOptions { ImmutableArray<SimulatorEncoderAxisOptions> Axes } where each SimulatorEncoderAxisOptions { EncoderAxis Axis, NoiseModel Noise }. Reuse SLICE-1.1's NoiseOptions shape for the noise sub-block
    • SimulatorProfile gains a new field int EncoderIntervalMs — the producer tick period. Validation: [1, 1000]. Default for existing profiles: 5 (200 Hz, light load)
    • existing seed profiles in appsettings.json are updated: Normal, Demo, HighDefect, MultiTag, HighFrameRate all explicitly carry EncoderIntervalMs: 5 (200 Hz). Existing-row reproducibility (rows 0/0a/0b/slice-1-1-multi-tag-telemetry/slice-1-2-real-frame-payloads) is preserved because none of those rows asserted on encoder counters that this slice introduces
    • one new profile, EncoderRate, with EncoderIntervalMs: 1 (1 kHz). Otherwise mirrors MultiTag defaults (50 tags + standard motion speed) so the row block isolates the encoder stream as the change under test
    • Simulator:Encoder seed config: two axes (X, Y) with RandomWalkNoise(Baseline=0.0, StepStdDev=0.0005, ClampMin=-0.01, ClampMax=0.01) — small jitter envelope (±0.01 simulator units) around the commanded position, so the encoder stream looks plausibly like real-world position noise without overwhelming the gradient-fill of Frame.PreviewPayload in any future combined capture
  • metrics: extend the InspectionPrototype meter (see SLICE-006) with two new counters:
    • encoder.samples.ingested (counter, dimension axis, values X / Y) — incremented on every produced EncoderSample
    • encoder.samples.coalesced (counter, dimension axis) — incremented when a snapshot drops at the channel boundary (once per axis per dropped snapshot)
    • the existing tags.active gauge, samples.ingested / samples.coalesced counters, and all frames.* / runs.* counters are unchanged
  • a new measurement scenario §4.4 Encoder-rate soak — slice-1.3, EncoderRate profile added to docs/runbook/capturing-measurements.md. Reuses the existing MultiTagSoakFlaUi scenario from SLICE-1.6 invoked with -Profile EncoderRate
  • before/after rows in docs/reviews/phase-1-measurements.md against the slice-1-2-real-frame-payloads baseline (the most recent FlaUI-captured row), captured under the new scenario, with the existing 18-metric set plus two new per-axis encoder rates (20 total for this row)
  • MeasurementExtraction.psm1 gains a Get-EncoderRatePerAxis helper that pulls encoder.samples.ingested rows out of the CSV, groups by axis dimension, and returns an [ordered] map { "X" = <Hz>; "Y" = <Hz> }. ConvertTo-MeasurementRow calls it and adds two rows to the markdown block (encoder-rate-x (Hz), encoder-rate-y (Hz)); guarded with "—" when the CSV pre-dates this slice (parallels SLICE-1.2's GC-pause / LOH-alloc handling)

Out of Scope

  • a UI plot or chart of the encoder stream — that is Phase 2 / Phase 3 work; SLICE-1.3 ships only the producer + channel + metric counter
  • writing encoder samples into AppState (this is the inverse of the slice's design — see "Why This Slice")
  • closed-loop control, position trim, or any use of the encoder stream beyond rate measurement
  • replacing or modifying the existing IMotionController.PositionChanged event — that stays the 20 Hz UI feed
  • buffer pooling for EncoderSample or EncoderSnapshot — Phase 2 if measurements show pressure
  • per-axis noise-model variation beyond the seed RandomWalkNoiseSine, Drift, Step work via the existing evaluator but seed defaults stay simple
  • multi-axis (Z, theta, focus) encoder streams — only X and Y are introduced here; the EncoderAxis enum is sized to accept future values without an interface change
  • introducing a new IScenario class — the existing MultiTagSoakFlaUi is reused with -Profile EncoderRate (mirrors SLICE-1.2's reuse pattern)
  • changing SimulatedMotionController core behavior — the InterpolateAsync 50 ms ticker, the PositionChanged event, and the _currentX/_currentY fields stay exactly as they are
  • removing or repurposing the existing encoder.x.counts / encoder.y.counts tags from the multi-tag registry — they are unrelated to this slice (see "Naming overlap" note below)

Runtime Behavior

Naming overlap (clarification)

The seed Simulator:Tags registry from SLICE-1.1 contains tags named encoder.x.counts and encoder.y.counts (250 Hz RandomWalkNoise simulated counts, no relation to commanded position). Those tags are coincidentally named — they are part of the multi-tag telemetry stream that goes through ITagStream and end up in AppState.LatestTagValues. The new encoder stream introduced here is a separate channel: it samples commanded position plus noise and goes through IEncoderStream, never AppState. The two streams coexist after this slice merges; readers must not conflate them.

Producer (SimulatedEncoderSource)

  • Constructed with IMotionController (for CurrentX/CurrentY), ISimulatorProfileProvider (for EncoderIntervalMs), IOptions<SimulatorEncoderOptions> (for per-axis noise), an AppMetrics reference, and an ILogger.
  • One ticker task started on IHostedService.StartAsync. The ticker is a tight while (!ct.IsCancellationRequested) loop using PeriodicTimer(TimeSpan.FromMilliseconds(profile.EncoderIntervalMs)) for the period. The producer captures the period at start and rebuilds the timer when the active profile changes (subscribe to the existing ISimulatorProfileProvider.ProfileChanged event).
  • Windows timer-resolution constraint. Default Windows timer tick is ~15.6 ms. PeriodicTimer(1ms) will not tick at 1 ms without a system timer-resolution boost. To meet criterion 7, the producer takes a WinMmTimePeriod scope on construction (P/Invoke winmm!timeBeginPeriod(1) on start, timeEndPeriod(1) on dispose) — a thin sealed class wrapping the import lives in Infrastructure.Simulator.Interop. The boost is system-wide and lasts only while the producer is alive. This trade-off is acknowledged: the simulator deliberately raises system timer resolution to imitate a real encoder's hardware-driven cadence. SLICE-1.1's amended criterion 7 (Windows scheduler caps at ~64 Hz with default tick) is what we are working around.
  • Per tick: read motion.CurrentX, motion.CurrentY. For each axis in the configured Simulator:Encoder.Axes, evaluate the noise model (NoiseModelEvaluator.Sample) against per-axis ref state, add the noise to the commanded value, build an EncoderSample(axis, DateTimeOffset.UtcNow, commanded + noise, TagQuality.Good). Construct an EncoderSnapshot from the array and publish to the channel.
  • Channel policy: bounded capacity = 1, BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = true. Drops increment SnapshotCoalescedCount and emit encoder.samples.coalesced{axis=X} and encoder.samples.coalesced{axis=Y} (one of each per dropped snapshot).
  • Lifecycle: started after SimulatedMotionController and IAppStateStore are constructed (DI ordering — registered as IHostedService). Stopped on app shutdown. The producer survives a 10-minute run without throwing.

Pipeline (EncoderStreamPipelineService)

  • A BackgroundService that drains IEncoderStream.Reader.ReadAllAsync(stoppingToken). Per snapshot:
    • For each EncoderSample in snapshot.Samples, increment AppMetrics.EncoderSamplesIngested.Add(1, KeyValuePair("axis", sample.Axis.ToString())).
    • Do not call IAppStateStore.Update. Do not capture the sample in any field. The consumer's only side effect is the metric counter.
  • The pipeline service is registered in DI as both EncoderStreamPipelineService and IHostedService (mirrors TagStreamPipelineService).
  • Coalesce-drop diagnostics are produced by the producer (it knows it dropped); the pipeline does not infer drops from snapshot gaps. Producer's drop log is rate-limited to one entry per second using a _lastDropLogUtc field with a TimeSpan.FromSeconds(1) threshold.

IMotionController and UI position

  • IMotionController interface is unchanged. PositionChanged continues to fire from InterpolateAsync at 50 ms cadence. AppState.StageX / AppState.StageY continue to be projected from MainViewModel's subscription to that event.
  • The encoder stream does not subscribe to PositionChanged — it polls CurrentX/CurrentY on its own cadence so the high-rate path doesn't accidentally amplify the 20 Hz event handler load (which is single-threaded per WPF dispatcher).
  • A passive verification: during the EncoderRate scenario capture, AppState change events should remain at the existing cadence dominated by tag-snapshot updates (~20 Hz from the snapshot publisher), not 1 kHz. If AppState change rate jumps to 1 kHz, the encoder stream is leaking into the store — that is a slice failure.

Profile validation

  • SimulatorProfilesValidator (existing) gains validation for EncoderIntervalMs: rejects values < 1 or > 1000. Bad config fails app startup with a clear message naming the offending profile and field.
  • New SimulatorEncoderOptionsValidator mirrors SimulatorTagsValidator: rejects empty Axes, duplicate axis values, missing Noise block, or a Noise.Kind not in the known set. Reuses NoiseOptionsValidator (existing) for the per-axis noise sub-validation.
  • The seed Simulator:Encoder config is required (no fallback PostConfigure injects a default) — this is consistent with the SLICE-1.1 tags-validator pattern, which fails fast if the section is absent rather than silently substituting defaults.

Metrics surfaces

  • dotnet-counters monitor --name InspectionPrototype.App --counters InspectionPrototype shows encoder.samples.ingested and encoder.samples.coalesced as totals across the axis dimension (the live console aggregates dimensions).
  • dotnet-counters collect --format csv preserves the axis dimension column. The runbook §4.4 procedure groups by Tags column and computes per-axis Hz. The new Get-EncoderRatePerAxis helper in MeasurementExtraction.psm1 automates that.

Acceptance Criteria

This slice is satisfied only if all of the following are true:

  1. EncoderAxis, EncoderSample, EncoderSnapshot exist in InspectionPrototype.Application.State (or the equivalent domain folder) and compile in projects that already reference Application.
  2. IEncoderStream exists in InspectionPrototype.Application.Abstractions with the shape described in "In Scope". SimulatedEncoderSource implements it and is registered in AddInfrastructureServices as both IEncoderStream and IHostedService.
  3. EncoderStreamPipelineService exists in InspectionPrototype.Application.Services (or equivalent), is registered as an IHostedService, drains the channel for the lifetime of the host, and increments encoder.samples.ingested{axis=…} once per sample without calling IAppStateStore.Update.
  4. IMotionController, SimulatedMotionController, MainViewModel's PositionChanged subscription, and AppState.StageX/StageY are all unchanged. A diff search for IMotionController against the merge base shows interface signature unchanged; a diff against SimulatedMotionController.InterpolateAsync shows behavioural code unchanged.
  5. SimulatorProfile exposes EncoderIntervalMs. SimulatorProfilesValidator rejects values outside [1, 1000] with a message naming the offending profile name and field. SimulatorEncoderOptionsValidator rejects empty axes, duplicates, and unknown noise kinds.
  6. Seed appsettings.json carries: EncoderIntervalMs: 5 on Normal, Demo, HighDefect, MultiTag, HighFrameRate; EncoderIntervalMs: 1 on a new EncoderRate profile (otherwise mirroring MultiTag); a Simulator:Encoder block with two axes (X, Y) using the RandomWalkNoise defaults from "In Scope".
  7. A 10-minute continuous run under the EncoderRate profile completes without an unhandled exception and without runs.faulted incrementing for encoder-pipeline-caused faults. The receiver-side rate per axis (computed from encoder.samples.ingested{axis=X} and {axis=Y} over the captured CSV span) is documented in the row block, not gated: the producer's PeriodicTimer(1 ms) + winmm!timeBeginPeriod(1) combination is subject to per-tick scheduling overhead and per-tick producer work that prevents a strict 1 kHz target on a default Windows 11 host. Observed under the seeded EncoderRate profile (commit 736afac, 613 s span): X axis 656.6 Hz, Y axis 656.6 Hz — equivalent to a ~1.52 ms effective tick (1 ms timer period + ~0.5 ms per-tick producer work: two _motion._lock acquisitions, an ImmutableArray.Builder<EncoderSample>(2) allocation, channel TryWrite). Tightening this is out of scope for SLICE-1.3 — see the follow-up in roadmap-progress.md tracking encoder-cadence remediation (dedicated Stopwatch-based busy-yield thread, timeSetEvent multimedia-callback approach, or CreateWaitableTimerEx(TIMER_HIGH_RESOLUTION)). The architectural point of SLICE-1.3 — that the receiver-side counter is reachable through a non-AppState channel at hundreds-of-Hz rates without destabilizing the workflow state machine — is satisfied by the row block's runs.faulted = 0, frames.dropped = 0, tags.active = 50 evidence.
  8. AppState change-event rate during the capture stays in line with the snapshot-publisher cadence (no faster than 25 Hz averaged over the run). Verified by extracting state.changes (or the equivalent existing diagnostics-counter row) from the CSV; if such a counter does not exist, a one-off in-process counter wired up just for this verification is acceptable but must be removed before commit.
  9. A row block tagged slice-1-3-encoder-rate-motion is appended to docs/reviews/phase-1-measurements.md covering the 18-metric set (16 existing + gc-pause-p95 + LOH-alloc-rate avg) plus two new metrics encoder-rate-x (Hz) and encoder-rate-y (Hz). CSV stored at docs/captures/slice-1-3-encoder-rate-<date>.csv with the commit hash recorded under measurement.
  10. docs/runbook/capturing-measurements.md gains a §4.4 entry that names the EncoderRate profile, the 10-minute scenario configuration, the timeBeginPeriod(1) system-timer-resolution implication, and the post-processing step that converts the CSV's axis dimension into per-axis rates.
  11. tools/Capture-Measurements.ps1 -Scenario MultiTagSoak -Profile EncoderRate -DurationSeconds 600 end-to-end produces the 20-metric row block including both encoder-rate-x and encoder-rate-y.
  12. The full existing test suite still passes, plus new tests covering: EncoderSample round-trip; SimulatedEncoderSource produces samples at approximately the configured cadence in a 1-second integration test (assert ≥80% of expected count to avoid CI flake — the hard ±2% bar applies only to the 10-minute capture); SimulatorEncoderOptionsValidator rejects each invalid case; EncoderStreamPipelineService drains a FakeEncoderStream snapshot batch and increments the metric without touching IAppStateStore.
  13. dotnet test runtime stays under 60 seconds. The 1-second integration test in criterion 12 must dispose the WinMmTimePeriod boost before returning so subsequent tests don't run with elevated timer resolution.

Verification Notes

The implementation task for this spec must include verification for:

  • the encoder-stream pipeline does not write to AppState. Verified by a unit test against EncoderStreamPipelineService with a recording IAppStateStore fake that asserts Update is never called across N drained snapshots
  • WinMmTimePeriod is disposed correctly. A unit test acquires the boost, disposes it, and asserts (best-effort, via winmm!timeGetDevCaps or by observing Stopwatch resolution before/after) that timer resolution returned to baseline. If runtime introspection is too brittle, an integration test that creates and disposes ten boosts back-to-back without throwing is acceptable
  • the producer does not allocate per-tick beyond the per-snapshot EncoderSample[2] and the EncoderSnapshot wrapper. Verified by a dotnet-counters short capture during the 1-second integration test showing gen-0 GC count growth proportional to sample-count, not a higher-multiple growth indicating boxed allocations or LINQ allocations on the hot path. This is a soft check; the hard check is the 10-minute capture's overall GC behavior in the row block
  • the producer's behavior under profile switching matches the SLICE-004 / SLICE-1.1 convention: switching from EncoderRate to Normal mid-run rebuilds the PeriodicTimer at the new period without leaking the previous tick task. Verified by a test that simulates two profile changes and asserts only one tick task is alive at the end
  • the seed Simulator:Encoder defaults produce a noise envelope small enough not to dominate the captured CSV with allocation-free arithmetic. Specifically: per-axis RandomWalkNoise.StepStdDev = 0.0005 clamped to ±0.01 should produce position deltas under 0.01 simulator-units around the commanded value; verified by reading 100 successive samples in a unit test and asserting |sample.PositionUnits - commanded| ≤ 0.01
  • the runbook's powercfg /change standby-timeout-ac 0 discipline (added in TASK-1.5.1 follow-up and reaffirmed in SLICE-1.6) applies to this slice's 10-min capture too — system sleep mid-capture would dilute the receiver-rate metric the slice exists to demonstrate
  • MultiTagSoakFlaUi runs with EncoderRate profile selection working through the existing SimulatorProfileSelectorComboBox (no new FlaUI scenario added). If the combo-box selection of the new profile is intrinsically flaky, the SLICE-1.6 fallback --start-with-profile EncoderRate CLI flag is the resolution; do not introduce a new FlaUI test class

Docs-first project memory for AI-assisted implementation.