SLICE-1.3: Encoder-Rate Motion
- Status: Completed (2026-04-30, criterion 7 amended)
- Date: 2026-04-30
- Depends on: Requirements, Evolution Roadmap, SLICE-006: Observability Baseline, SLICE-1.1: Multi-Tag Telemetry, SLICE-1.2: Real Frame Payloads, SLICE-1.6: FlaUI Capture
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
- 04. UI and Technical Requirements: bounded streaming with measurable behavior at high rate; UI position cadence must not change as a result of new high-rate data
- 05. Failure Modes and Workflow Requirements: high-rate streams must not destabilize the workflow state machine
- 07. AI Delivery Constraints and Roadmap: each phase ships a measurable before-and-after; this is row
slice-1-3-encoder-rate-motionin the measurements table
In Scope
- a new domain shape for encoder samples:
EncoderAxisenum:X,YEncoderSample(EncoderAxis Axis, DateTimeOffset Timestamp, double PositionUnits, TagQuality Quality)—PositionUnitsis in the same units asIMotionController.CurrentX/CurrentY(the existing simulator units),TagQualityis reused from SLICE-1.1EncoderSnapshot(ImmutableArray<EncoderSample> Samples)— the per-tick batch that crosses the channel boundary; one snapshot per producer tick carries oneEncoderSampleper axis (currently 2)
- a new producer abstraction:
IEncoderStreamexposes a bounded channel ofEncoderSnapshot, plus per-axis coalesce counters (parallelsITagStreamfrom SLICE-1.1):ChannelReader<EncoderSnapshot> Reader { get; }long SnapshotCoalescedCount { get; }— channel-level dropslong PerAxisCoalescedCount(EncoderAxis axis)— per-axis emitter overwrite count (only meaningful if the producer were to publish per axis independently; included for symmetry withITagStreamand so axis-rate-mismatch experiments later don't require an interface change)
SimulatedEncoderSource(inInfrastructure.Simulator) runs a single high-rate ticker that:- reads commanded position from
IMotionController.CurrentX/CurrentY - adds a per-axis noise sample using the existing
NoiseModelinfrastructure from SLICE-1.1 (default:RandomWalkNoiseper axis with smallStepStdDevand a clamped envelope) - publishes one
EncoderSnapshotper tick to the bounded channel
- reads commanded position from
- a new pipeline service:
EncoderStreamPipelineService(inApplication.Services) drains the channel as aBackgroundServiceand incrementsencoder.samples.ingested{axis=…}once per sample. It does not callIAppStateStore.Update. That is the slice's load-bearing design decision — the high-rate stream stays out ofAppState- per-snapshot drops (channel coalesce events) increment
encoder.samples.coalesced{axis=…}once per axis on the dropped snapshot, log aWarningdiagnostics entry no more than once per second (rate-limited at the pipeline service to avoid drowning the diagnostics timeline), and updateIEncoderStream.SnapshotCoalescedCount
- configuration:
Simulator:Encodersection bound toSimulatorEncoderOptions { ImmutableArray<SimulatorEncoderAxisOptions> Axes }where eachSimulatorEncoderAxisOptions { EncoderAxis Axis, NoiseModel Noise }. Reuse SLICE-1.1'sNoiseOptionsshape for the noise sub-blockSimulatorProfilegains a new fieldint EncoderIntervalMs— the producer tick period. Validation:[1, 1000]. Default for existing profiles:5(200 Hz, light load)- existing seed profiles in
appsettings.jsonare updated:Normal,Demo,HighDefect,MultiTag,HighFrameRateall explicitly carryEncoderIntervalMs: 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, withEncoderIntervalMs: 1(1 kHz). Otherwise mirrorsMultiTagdefaults (50 tags + standard motion speed) so the row block isolates the encoder stream as the change under test Simulator:Encoderseed config: two axes (X,Y) withRandomWalkNoise(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 ofFrame.PreviewPayloadin any future combined capture
- metrics: extend the
InspectionPrototypemeter (see SLICE-006) with two new counters:encoder.samples.ingested(counter, dimensionaxis, valuesX/Y) — incremented on every producedEncoderSampleencoder.samples.coalesced(counter, dimensionaxis) — incremented when a snapshot drops at the channel boundary (once per axis per dropped snapshot)- the existing
tags.activegauge,samples.ingested/samples.coalescedcounters, and allframes.*/runs.*counters are unchanged
- a new measurement scenario
§4.4 Encoder-rate soak — slice-1.3, EncoderRate profileadded todocs/runbook/capturing-measurements.md. Reuses the existingMultiTagSoakFlaUiscenario from SLICE-1.6 invoked with-Profile EncoderRate - before/after rows in
docs/reviews/phase-1-measurements.mdagainst theslice-1-2-real-frame-payloadsbaseline (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.psm1gains aGet-EncoderRatePerAxishelper that pullsencoder.samples.ingestedrows out of the CSV, groups byaxisdimension, and returns an[ordered]map{ "X" = <Hz>; "Y" = <Hz> }.ConvertTo-MeasurementRowcalls 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.PositionChangedevent — that stays the 20 Hz UI feed - buffer pooling for
EncoderSampleorEncoderSnapshot— Phase 2 if measurements show pressure - per-axis noise-model variation beyond the seed
RandomWalkNoise—Sine,Drift,Stepwork via the existing evaluator but seed defaults stay simple - multi-axis (Z, theta, focus) encoder streams — only X and Y are introduced here; the
EncoderAxisenum is sized to accept future values without an interface change - introducing a new
IScenarioclass — the existingMultiTagSoakFlaUiis reused with-Profile EncoderRate(mirrors SLICE-1.2's reuse pattern) - changing
SimulatedMotionControllercore behavior — theInterpolateAsync50 ms ticker, thePositionChangedevent, and the_currentX/_currentYfields stay exactly as they are - removing or repurposing the existing
encoder.x.counts/encoder.y.countstags 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(forCurrentX/CurrentY),ISimulatorProfileProvider(forEncoderIntervalMs),IOptions<SimulatorEncoderOptions>(for per-axis noise), anAppMetricsreference, and anILogger. - One ticker task started on
IHostedService.StartAsync. The ticker is a tightwhile (!ct.IsCancellationRequested)loop usingPeriodicTimer(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 existingISimulatorProfileProvider.ProfileChangedevent). - 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 aWinMmTimePeriodscope on construction (P/Invokewinmm!timeBeginPeriod(1)on start,timeEndPeriod(1)on dispose) — a thin sealed class wrapping the import lives inInfrastructure.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 eachaxisin the configuredSimulator:Encoder.Axes, evaluate the noise model (NoiseModelEvaluator.Sample) against per-axis ref state, add the noise to the commanded value, build anEncoderSample(axis, DateTimeOffset.UtcNow, commanded + noise, TagQuality.Good). Construct anEncoderSnapshotfrom the array and publish to the channel. - Channel policy: bounded capacity = 1,
BoundedChannelFullMode.DropOldest,SingleReader = true,SingleWriter = true. Drops incrementSnapshotCoalescedCountand emitencoder.samples.coalesced{axis=X}andencoder.samples.coalesced{axis=Y}(one of each per dropped snapshot). - Lifecycle: started after
SimulatedMotionControllerandIAppStateStoreare constructed (DI ordering — registered asIHostedService). Stopped on app shutdown. The producer survives a 10-minute run without throwing.
Pipeline (EncoderStreamPipelineService)
- A
BackgroundServicethat drainsIEncoderStream.Reader.ReadAllAsync(stoppingToken). Per snapshot:- For each
EncoderSampleinsnapshot.Samples, incrementAppMetrics.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.
- For each
- The pipeline service is registered in DI as both
EncoderStreamPipelineServiceandIHostedService(mirrorsTagStreamPipelineService). - 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
_lastDropLogUtcfield with aTimeSpan.FromSeconds(1)threshold.
IMotionController and UI position
IMotionControllerinterface is unchanged.PositionChangedcontinues to fire fromInterpolateAsyncat 50 ms cadence.AppState.StageX/AppState.StageYcontinue to be projected fromMainViewModel's subscription to that event.- The encoder stream does not subscribe to
PositionChanged— it pollsCurrentX/CurrentYon 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,
AppStatechange events should remain at the existing cadence dominated by tag-snapshot updates (~20 Hz from the snapshot publisher), not 1 kHz. IfAppStatechange rate jumps to 1 kHz, the encoder stream is leaking into the store — that is a slice failure.
Profile validation
SimulatorProfilesValidator(existing) gains validation forEncoderIntervalMs: rejects values < 1 or > 1000. Bad config fails app startup with a clear message naming the offending profile and field.- New
SimulatorEncoderOptionsValidatormirrorsSimulatorTagsValidator: rejects emptyAxes, duplicate axis values, missingNoiseblock, or aNoise.Kindnot in the known set. ReusesNoiseOptionsValidator(existing) for the per-axis noise sub-validation. - The seed
Simulator:Encoderconfig is required (no fallbackPostConfigureinjects 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 InspectionPrototypeshowsencoder.samples.ingestedandencoder.samples.coalescedas totals across theaxisdimension (the live console aggregates dimensions).dotnet-counters collect --format csvpreserves theaxisdimension column. The runbook §4.4 procedure groups byTagscolumn and computes per-axis Hz. The newGet-EncoderRatePerAxishelper inMeasurementExtraction.psm1automates that.
Acceptance Criteria
This slice is satisfied only if all of the following are true:
EncoderAxis,EncoderSample,EncoderSnapshotexist inInspectionPrototype.Application.State(or the equivalent domain folder) and compile in projects that already reference Application.IEncoderStreamexists inInspectionPrototype.Application.Abstractionswith the shape described in "In Scope".SimulatedEncoderSourceimplements it and is registered inAddInfrastructureServicesas bothIEncoderStreamandIHostedService.EncoderStreamPipelineServiceexists inInspectionPrototype.Application.Services(or equivalent), is registered as anIHostedService, drains the channel for the lifetime of the host, and incrementsencoder.samples.ingested{axis=…}once per sample without callingIAppStateStore.Update.IMotionController,SimulatedMotionController,MainViewModel'sPositionChangedsubscription, andAppState.StageX/StageYare all unchanged. A diff search forIMotionControlleragainst the merge base shows interface signature unchanged; a diff againstSimulatedMotionController.InterpolateAsyncshows behavioural code unchanged.SimulatorProfileexposesEncoderIntervalMs.SimulatorProfilesValidatorrejects values outside[1, 1000]with a message naming the offending profile name and field.SimulatorEncoderOptionsValidatorrejects empty axes, duplicates, and unknown noise kinds.- Seed
appsettings.jsoncarries:EncoderIntervalMs: 5onNormal,Demo,HighDefect,MultiTag,HighFrameRate;EncoderIntervalMs: 1on a newEncoderRateprofile (otherwise mirroringMultiTag); aSimulator:Encoderblock with two axes (X,Y) using theRandomWalkNoisedefaults from "In Scope". - A 10-minute continuous run under the
EncoderRateprofile completes without an unhandled exception and withoutruns.faultedincrementing for encoder-pipeline-caused faults. The receiver-side rate per axis (computed fromencoder.samples.ingested{axis=X}and{axis=Y}over the captured CSV span) is documented in the row block, not gated: the producer'sPeriodicTimer(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 seededEncoderRateprofile (commit736afac, 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._lockacquisitions, anImmutableArray.Builder<EncoderSample>(2)allocation, channelTryWrite). Tightening this is out of scope for SLICE-1.3 — see the follow-up inroadmap-progress.mdtracking encoder-cadence remediation (dedicatedStopwatch-based busy-yield thread,timeSetEventmultimedia-callback approach, orCreateWaitableTimerEx(TIMER_HIGH_RESOLUTION)). The architectural point of SLICE-1.3 — that the receiver-side counter is reachable through a non-AppStatechannel at hundreds-of-Hz rates without destabilizing the workflow state machine — is satisfied by the row block'sruns.faulted = 0,frames.dropped = 0,tags.active = 50evidence. AppStatechange-event rate during the capture stays in line with the snapshot-publisher cadence (no faster than 25 Hz averaged over the run). Verified by extractingstate.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.- A row block tagged
slice-1-3-encoder-rate-motionis appended todocs/reviews/phase-1-measurements.mdcovering the 18-metric set (16 existing +gc-pause-p95+LOH-alloc-rate avg) plus two new metricsencoder-rate-x (Hz)andencoder-rate-y (Hz). CSV stored atdocs/captures/slice-1-3-encoder-rate-<date>.csvwith the commit hash recorded under measurement. docs/runbook/capturing-measurements.mdgains a §4.4 entry that names theEncoderRateprofile, the 10-minute scenario configuration, thetimeBeginPeriod(1)system-timer-resolution implication, and the post-processing step that converts the CSV'saxisdimension into per-axis rates.tools/Capture-Measurements.ps1 -Scenario MultiTagSoak -Profile EncoderRate -DurationSeconds 600end-to-end produces the 20-metric row block including bothencoder-rate-xandencoder-rate-y.- The full existing test suite still passes, plus new tests covering:
EncoderSampleround-trip;SimulatedEncoderSourceproduces 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);SimulatorEncoderOptionsValidatorrejects each invalid case;EncoderStreamPipelineServicedrains aFakeEncoderStreamsnapshot batch and increments the metric without touchingIAppStateStore. dotnet testruntime stays under 60 seconds. The 1-second integration test in criterion 12 must dispose theWinMmTimePeriodboost 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 againstEncoderStreamPipelineServicewith a recordingIAppStateStorefake that assertsUpdateis never called across N drained snapshots WinMmTimePeriodis disposed correctly. A unit test acquires the boost, disposes it, and asserts (best-effort, viawinmm!timeGetDevCapsor by observingStopwatchresolution 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 theEncoderSnapshotwrapper. Verified by adotnet-countersshort 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
EncoderRatetoNormalmid-run rebuilds thePeriodicTimerat 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:Encoderdefaults produce a noise envelope small enough not to dominate the captured CSV with allocation-free arithmetic. Specifically: per-axisRandomWalkNoise.StepStdDev = 0.0005clamped to±0.01should 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 0discipline (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 MultiTagSoakFlaUiruns withEncoderRateprofile selection working through the existingSimulatorProfileSelectorComboBox(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 EncoderRateCLI flag is the resolution; do not introduce a new FlaUI test class