Skip to content

fix(rate): the FREQUENCY slider stopped at 1000 Hz on a board advertising 22000 (#270) - #273

Merged
tylerkron merged 1 commit into
mainfrom
builder/issue-270-rate-slider-cap
Sep 6, 2026
Merged

tylerkron merged 1 commit into
mainfrom
builder/issue-270-rate-slider-cap

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review. closes #270

The Devices pane's FREQUENCY slider wrote Maximum="1000" into the markup. That 1000 is Core's
board-table bootstrap value, correct only until a device describes itself. The bench Nq1
(fw 3.7.2) publishes a schema-2 capability document stating sample_rate_range_hz.max = 22000, and
InitializeAsync raises MaxSamplingRate to it during connect. The wrapper's rate guard (#255) and
Core's own setter both validate against that figure — so the slider was the only thing in the chain
that could not ask for a rate the hardware supports.

The ceiling now comes from the device.

What the user saw, on the attached board

Both frames are the real Devices drawer rendered by the parity-audit harness with the real Nq1
connected over USB
(/dev/cu.usbmodem1101), driven through the pane's own view model: open the
drawer, ask for 5000 Hz.

Before — the wrapper stored 5000 Hz (the device advertises 22000, so its guard accepted it), but the
slider was already at its own maximum and coerced the value back to 1000. The number on screen
was not the number the user asked for, and not the one the device would have run at:

before

After — 5000, with the thumb a fifth of the way along a 1..22000 track:

after

Harness output from those two runs, same board, same session:

[INFO] board MaxSamplingRate = 22000
[INFO] device StreamingFrequency after asking for 5000 = 5000
[INFO] pane FrequencyHz = 5000

Non-destructive: connect, read the capability document, set the wrapper's rate, disconnect. The rate
is only written to hardware by InitializeStreaming/StartSdCardLogging, neither of which ran, so
nothing on the board was changed and there was nothing to restore. The evidence mode was a temporary
edit to AvaloniaCapture/Program.cs, reverted — it is not in this diff.

Where the ceiling lives now

AbstractStreamingDevice.MaxStreamingFrequency is the Math.Max(1, Capabilities.MaxSamplingRate)
expression that the rate setter's guard and ClampRateToAdvertisedCeiling() each already computed
separately; both now read it, so the concept has one home instead of two copies.
DevicesPaneViewModel.MaxFrequencyHz exposes it for the selected device, and both device views bind
their slider's Maximum to that.

SelectedTile is a sufficient trigger for the ceiling moving: ConnectionManager publishes a device
only once Connect() has returned, and Connect() hydrates the capability document before it
returns, so no tile ever exists while the ceiling is still the bootstrap value — and a reconnect
rebuilds the tile. MaxFrequencyHz is announced before FrequencyHz (the generator raises
[NotifyPropertyChangedFor] in declaration order) so the slider never sees an incoming rate against
an outgoing ceiling.

One extra line in FrequencyHz's setter: a write carrying the rate the device already holds is
dropped. A Slider writes its Value back when a falling Maximum coerces it, and pushing that
echo through the shell would answer a change nobody made with "Cannot change sampling frequency
while logging."

Why the bound is the absolute ceiling and not current_max_rate_hz

#270 asks this to be settled explicitly, so: the absolute advertised ceiling, and the current cap
is not an option at this layer.

current_max_rate_hz (7746 on this board) is not static. Core 1.7.0 documents
CapabilityStreaming.CurrentMaximumRateHz as "the device's authoritative cap for the channel set
enabled right now … It changes whenever the enabled set changes, so it is only as fresh as the last
document read." This app reads the capability document exactly once, in Connect(). It never
re-reads it. Binding the slider to that figure would pin the control to a cap computed for whatever
channels happened to be enabled at connect — optimistic once the user enables more, needlessly
restrictive once they enable fewer. SampleRateCap.ComputeForDevice can also legitimately return
0 ("nothing enabled"), and a slider whose Maximum is 0 sits below its own Minimum of 1.

The honest statement about the hazard #270 raises: the slider's maximum is not, and cannot be, the
enforcement point for the current cap.
It is unenforced at 1000 too — a board with sixteen analog
inputs enabled can have a current cap well below 1000, and today's slider offers 1000 anyway. What
this change does is let a user ask for something the firmware answers with SCPI -222
(RateValidation: "error") where before they could only ask for something smaller that the
firmware might refuse for the same reason. Enforcement belongs beside ClampRateToAdvertisedCeiling()
at the Core handoff, and needs a fresh ReadCapabilityDocumentAsync when the enabled set changes —
a device round trip on a channel toggle that nothing in this app does yet. Filed separately as #272
rather than smuggled in here.

Every 1000 in the rate path

Changed — the two sliders that set a connected device's live rate:

File Line Was Now
View/Prototype/DevicesPanePrototype.axaml 654 Maximum="1000" Maximum="{Binding MaxFrequencyHz}"
Views/Mobile/DevicesMobileView.axaml 239 Maximum="1000" Maximum="{Binding MaxFrequencyHz}"

Deliberately left — the four Profiles sliders:

File Line Bound to
View/ProfilesPane.axaml 589 SamplingFrequency (a ProfileDevice)
View/ProfilesPane.axaml 773 NewProfileFrequency
Views/Mobile/ProfilesMobileView.axaml 261 SamplingFrequency
Views/Mobile/ProfilesMobileView.axaml 417 NewProfileFrequency

A ProfileDevice is an [ObservableProperty]-backed ObservableObject (Models/ProfileModels.cs:27)
deserialized from XML with no device behind it: a profile can name a board that is not connected, and
one profile can name several boards with different ceilings, so there is no device to read a bound
from. Making it device-derived means ProfilesPaneViewModel — which today observes neither
ConnectionManager.ConnectedDevices nor implements IDisposable — grows a subscription and a
disposal contract at every construction site. That is machinery this change does not need.

Deferring it is safe for stored data: applying a profile assigns its saved rate through the
guarded setter (ProfilesPaneViewModel.cs:390), which clamps to the real board's ceiling, so a
profile can never drive a device at a rate it would refuse. It is not free for editing, and
this PR widens that gap rather than creating it — SaveCurrentSettings (:539) copies the live
device rate into a profile unfiltered, so a profile can now hold 5000 while the edit drawer's slider
still stops at 1000. Measured headless: opening the drawer does not rewrite the stored value
(the numeric readout is Mode=OneWay and still reads 5000), but the thumb saturates at the right of
the track, and any drag drops the profile to ≤1000 — which drawer-close persists. Filed as #274,
alongside #272, rather than smuggled in here.

Also left, and not caps: ProfilesPaneViewModel.cs:60 and :224 set NewProfileFrequency = 1000 as
the default rate a new profile starts at. AbstractStreamingDevice.DEFAULT_PWM_FREQUENCY_HZ is the
PWM output frequency, not a sampling rate. No new 1000 is introduced anywhere.

Tests

Daqifi.Avalonia.Tests/ViewModels/RateSliderCeilingTests.cs, following the pattern
FirmwareUploadAffordanceTests established: neither device view declares an x:DataType, so their
bindings resolve by reflection at runtime and a missing or misspelled member fails silently while
both heads build green. Each view fact therefore asserts the pair a reflection binding needs — the
binding is in the markup, and the member it names exists and is readable on the view model.

Run against origin/main's markup with the test file in place, 4 of 13 fail — both views for
The_devices_pane_takes_its_rate_ceiling_from_the_device (Maximum="{Binding MaxFrequencyHz}" not
found) and both for Neither_devices_view_writes_a_rate_ceiling_of_its_own. The remaining 9 are
device-level facts about MaxStreamingFrequency; they cannot be run against main at all, because
the member they name does not exist there. What they pin is that the bound the slider offers and the
bound the setter saturates at cannot drift apart, and that a zero or negative advertised ceiling is
floored to 1 rather than producing a slider bound beneath its own minimum.

Build and verification

  • Desktop net10.0, iOS Release/iossimulator-arm64: both green. Android is not buildable on this
    Mac (workload absent) — CI covers it.
  • Tests: 601 passed, 0 failed.
  • Warnings, both sides force-rebuilt (-t:Rebuild) against merge-base 8438046: 171 first-party +
    20 vendored OxyPlot = 191
    on both. Identical sets; the only textual difference is a line-number
    shift of a pre-existing CS8625 in AbstractStreamingDevice.cs caused by the added lines.
  • packages.lock.json unchanged (iOS restored without -r, built with it).
  • Parity capture (Release, throwaway DAQIFI_DATA_DIR): 25/25 byte-identical to the committed
    macos-arm64 baseline.
    No screen moves — the FREQUENCY slider lives inside the device drawer
    (IsVisible="{Binding IsSettingsOpen}"), which no captured screen opens. No re-record needed.

🤖 Generated with Claude Code

…sing 22000 (#270)

Both Devices views wrote `Maximum="1000"` into the markup. That 1000 is Core's
board-table bootstrap value, correct only until the device describes itself: the
bench Nq1 (fw 3.7.2) publishes a schema-2 capability document stating
sample_rate_range_hz.max = 22000, and InitializeAsync raises MaxSamplingRate to
it during connect. The wrapper's rate guard (#255) and Core's own setter both
validate against that figure, so the slider was the only thing in the chain that
could not ask for a rate the hardware supports.

The ceiling is now read from the device. AbstractStreamingDevice grows
MaxStreamingFrequency, which is the `Math.Max(1, Capabilities.MaxSamplingRate)`
expression the rate guard and the re-clamp each already computed separately, so
the concept has one home; DevicesPaneViewModel exposes it as MaxFrequencyHz and
both views bind their slider's Maximum to that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Use device-advertised maximum for frequency sliders

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Derives streaming-rate ceilings from each connected device's advertised capabilities.
• Binds desktop and mobile frequency sliders to the selected device's maximum.
• Adds regression coverage for bindings, dynamic ceilings, and invalid capability values.
Diagram

graph TD
  A["Capability Document"] -->|hydrates maximum| B["Core Capabilities"] -->|exposes rate limit| C["Streaming Device"] -->|projects ceiling| D["Devices ViewModel"] -->|binds maximum| E["Desktop Slider"]
  D -->|binds maximum| F["Mobile Slider"]
Loading
High-Level Assessment

The current approach is appropriate: it centralizes ceiling sanitization beside the existing rate guard, exposes the value through the device contract, and gives both views one notification-aware view-model property. Direct nested bindings to the selected device were considered but would complicate null handling and capability-change notifications without reducing meaningful complexity.

Files changed (7) +265 / -14

Bug fix (5) +70 / -14
AbstractStreamingDevice.csCentralize the advertised streaming-frequency ceiling +28/-11

Centralize the advertised streaming-frequency ceiling

• Introduces MaxStreamingFrequency as the device capability maximum floored to 1 Hz. The streaming-frequency setter and capability re-clamping logic now share this single ceiling definition.

Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs

IStreamingDevice.csExpose each device's maximum streaming frequency +11/-1

Expose each device's maximum streaming frequency

• Extends the streaming-device contract with a read-only MaxStreamingFrequency property for rate controls and validation consumers.

Daqifi.Avalonia/Daqifi.Desktop/Device/IStreamingDevice.cs

DevicesPanePrototype.axamlBind the desktop frequency slider to device capabilities +1/-1

Bind the desktop frequency slider to device capabilities

• Replaces the desktop slider's hard-coded 1000 Hz maximum with the selected device's MaxFrequencyHz value.

Daqifi.Avalonia/Daqifi.Desktop/View/Prototype/DevicesPanePrototype.axaml

DevicesPaneViewModel.csProject the selected device's rate ceiling to the UI +29/-0

Project the selected device's rate ceiling to the UI

• Adds MaxFrequencyHz and raises its notification before FrequencyHz when selection changes, preventing transient coercion against the previous device's ceiling. It also ignores slider write-backs that do not change the device's stored frequency.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DevicesPaneViewModel.cs

DevicesMobileView.axamlBind the mobile frequency slider to device capabilities +1/-1

Bind the mobile frequency slider to device capabilities

• Replaces the mobile slider's hard-coded 1000 Hz maximum with the selected device's MaxFrequencyHz value.

Daqifi.Avalonia/Views/Mobile/DevicesMobileView.axaml

Tests (2) +195 / -0
RecordingStreamingDevice.csImplement maximum frequency on the recording test double +2/-0

Implement maximum frequency on the recording test double

• Adds a configurable MaxStreamingFrequency property so the test device continues to satisfy the expanded IStreamingDevice contract.

Daqifi.Avalonia.Tests/Device/RecordingStreamingDevice.cs

RateSliderCeilingTests.csCover device-driven frequency slider ceilings +193/-0

Cover device-driven frequency slider ceilings

• Adds regression tests confirming both device views bind their slider maximum to MaxFrequencyHz and no longer declare 1000 Hz. It also verifies valid, invalid, and dynamically changing advertised limits against the concrete serial-device wrapper.

Daqifi.Avalonia.Tests/ViewModels/RateSliderCeilingTests.cs

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@tylerkron

Copy link
Copy Markdown
Contributor Author

Ready for review at dd72adb09677b6df8f2389b352cb8fed62b1178b — Qodo clean (0 bugs / 0 rule violations / 0 requirement gaps, 0 review threads, byte-stable across a ~30 min settle re-check on both surfaces) and all six real CI jobs green on that exact SHA (run 34007971877). Verified independently, not taken from the description: the de-duplication is real (#255's two Math.Max(1, Capabilities.MaxSamplingRate) copies at AbstractStreamingDevice.cs:81/:122 on main are now one site at :67, with no third copy in the repo), both Core-1.7.0 grounds for choosing the absolute ceiling over current_max_rate_hz hold at tag v1.7.0 (CurrentMaximumRateHz is documented stale-by-design, and SampleRateCap.ComputeForDevice can legitimately return 0 — which would put a slider Maximum under its own Minimum), test counts re-derived as 588 on main → 601 here → exactly 13 new, the 4-of-13 split reproduces precisely (reverting only the two .axaml files gives Failed: 4, Passed: 9 for the stated missing-binding reason, while the other 9 give CS1061 on main and so are correctly not counted as red-then-green), warnings are 171 + 20 = 191 on both sides force-rebuilt with identical per-code histograms (the 20 vendored are 13 AVP1002 + 7 others) and the only delta a line-number shift of a pre-existing CS8625, and the parity capture is 25/25 byte-identical with nothing moving. I rendered both views myself rather than trusting the markup assertions — Slider.Maximum resolves at runtime to 22000 in both the desktop drawer and the mobile pane (both sliders sit outside the tile DataTemplate, sharing the pane-VM DataContext), so neither head silently falls back. Test-merged onto current main (acc58865) together with #269 and #271: clean, builds green, 610/610; #269 adds no member to AbstractStreamingDevice (its only production change is a comment inside Connect()), so there is no duplicate-added-type collision.

Still uncovered: current_max_rate_hz enforcement — 7746 on this board against the 22000 the slider now offers, with firmware ≥3.5.0 answering an over-cap start with SCPI -222 and streaming nothing. Unenforced at 1000 too, so this PR widens the range in which it can be hit rather than creating it; deferred to #272. Separately, I filed #274: the four Profiles sliders still stop at 1000, and SaveCurrentSettings copies the live device rate into a profile unfiltered, so a profile can now hold 5000 that its edit drawer cannot express. I measured this rather than assuming it — opening the drawer does not silently rewrite the stored value (it stays 5000; the readout is Mode=OneWay), but the thumb saturates at the right of a 1..1000 track and any drag drops the profile to ≤1000, which drawer-close persists. Out of scope here and it needs the same design decision as #272, so it is filed rather than pushed into a settled review; the description's Profiles paragraph is corrected in place (body-only edit — SHA unmoved, render URLs carried verbatim).

Not merging.

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.

Rate sliders hard-cap at 1000 Hz while the board advertises 22000 — users cannot select rates their hardware supports

1 participant