Skip to content

fix(streaming): report acquisition statistics in the channel's own units - #554

Merged
cptkoolbeenz merged 2 commits into
mainfrom
fix/534-scaled-statistics
Aug 20, 2026
Merged

cptkoolbeenz merged 2 commits into
mainfrom
fix/534-scaled-statistics

Conversation

@cptkoolbeenz

Copy link
Copy Markdown
Member

Fixes #534.

The defect

Configure a channel with engineering units — a pressure transducer reading 12.4 PSI rather than 2.48 V — and AcquisitionStatistics reported its min, max and mean in volts, with nothing in the snapshot saying which of the two you had got.

Both entry points read sample.Value instead of sample.ScaledValue: Record(IChannel, IDataSample) and the attached SampleReceived handler.

It looks like an ordering accident rather than a decision — AcquisitionStatistics landed in #518 four hours before ChannelScaling existed (#523, the same day), so it could not have considered engineering units. AcquisitionStatisticsTests had zero references to Scaling or ScaledValue, which is why nothing caught it.

Why record the scaled value rather than document the volts

The issue laid out both options fairly and called it a judgement call. The deciding evidence is that a negative gain transposes the extremes, so recording volts is not merely mislabelled — it cannot be corrected afterwards:

with gain -3.0, offset 5.0, the smallest voltage is the largest engineering reading, so a consumer re-applying the scaling by hand gets a MinValue that is really the maximum.

ChannelScaling permits a negative gain — only finiteness is validated — so that is a supported configuration, not an abuse. Option 2 (keep volts, document it) leaves that consumer with no safe path at all.

It is also the only place in Core where the loss is unrecoverable: every other path hands the consumer the sample so they can read ScaledValue/Unit themselves. This type consumes the samples on their behalf and returns only the derived numbers.

Unit on the snapshot

ChannelAcquisitionStatistics now carries Unit — which the issue asked for either way, since every analog channel already has one (CapabilityChannelUnits gives them an identity scaling labelled "V" at connect), so the snapshot was dropping a label it was already being handed.

It follows the same last-seen-wins rule as Name: a channel's scaling can be reassigned mid-session, and freezing the first unit would label later samples with a unit they were not measured in.

Behaviour change — worth a release note

A consumer with scaling configured sees different numbers. That is the fix. A consumer without scaling sees exactly what it saw before, because ScaledValue returns Value when there is no scaling.

Unit is appended with a default, so existing positional construction still compiles.

Test plan — each test checked for whether it can actually fail

test fails on pre-change code? catches a mutation? verdict
ReportsTheEngineeringValueAndItsUnit yes — proves the fix
DoesNotTransposeTheExtremes yes — proves the fix
WithNoScaling_IsUnchangedAndReportsNoUnit no yes control, and a real one

The third passes before and after, so it does not prove anything on its own — I say so rather than implying all three discriminate. It is still worth keeping: mutating the code to default an unstated unit to "V" makes it fail, which is the plausible way this path would regress.

Baseline-checked, not assumed. main fails 4 tests on this station before any change — all Device.Discovery.LinuxUsbPortDescriptorProviderTests, a WSL/Windows runtime artefact unrelated to this work. After this change: the same 4, with passing 3532 → 3535.

No hardware needed; the bench reproduction in the issue is already recorded there.

🤖 Generated with Claude Code

Configure a channel with engineering units -- a pressure transducer reading
12.4 PSI rather than 2.48 V -- and AcquisitionStatistics reported its min,
max and mean in VOLTS, with nothing in the snapshot saying which of the two
you had got.

Both entry points read sample.Value instead of sample.ScaledValue:
Record(IChannel, IDataSample) and the attached SampleReceived handler. It
looks like an ordering accident rather than a decision -- AcquisitionStatistics
landed in #518 four hours before ChannelScaling existed (#523, the same day),
so it could not have considered engineering units, and AcquisitionStatisticsTests
had zero references to Scaling or ScaledValue, which is why nothing caught it.

WHY RECORD THE SCALED VALUE RATHER THAN DOCUMENT THE VOLTS.

The issue offered both. The deciding evidence is that a negative gain
TRANSPOSES the extremes, so recording volts is not merely mislabelled, it
cannot be corrected afterwards: with gain -3, the smallest voltage is the
LARGEST engineering reading, so a consumer re-applying the scaling by hand
gets a MinValue that is really the maximum. ChannelScaling permits a
negative gain -- only finiteness is validated -- so that is a supported
configuration, not an abuse.

It is also the only place in Core where the loss is unrecoverable. Every
other path hands the consumer the sample, so they can read ScaledValue and
Unit themselves; this type consumes the samples on their behalf and returns
just the derived numbers.

ChannelAcquisitionStatistics now carries Unit, which the issue asked for
either way -- every analog channel already has one, so the snapshot was
dropping a label it was already being handed. The unit follows the same
last-seen-wins rule as Name, because a channel's scaling can be reassigned
mid-session and freezing the first unit would label later samples with a
unit they were not measured in.

BEHAVIOUR CHANGE. A consumer with scaling configured sees different numbers
-- that is the fix. A consumer without scaling sees exactly what it saw
before, since ScaledValue returns Value when there is no scaling.

Three tests, each checked for whether it can actually fail:

  ReportsTheEngineeringValueAndItsUnit    fails on the pre-change recorder
  DoesNotTransposeTheExtremes             fails on the pre-change recorder
  WithNoScaling_IsUnchangedAndReportsNoUnit
      passes both, so it is a control rather than a proof -- but a real one:
      mutating the code to default an unstated unit to "V" makes it fail.

Baseline-checked: main fails 4 tests on this station before any change
(Device.Discovery.LinuxUsbPortDescriptorProviderTests, a WSL/Windows runtime
artefact). After: the same 4, passing 3532 -> 3535.

Fixes #534
@cptkoolbeenz
cptkoolbeenz requested a review from a team as a code owner August 20, 2026 05:17
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

PR Code Suggestions ✨

Warning

/improve is deprecated. Use /agentic_review instead (removal date not yet scheduled).

CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent mixed-unit statistical aggregation

Reset the channel's statistical state when its unit changes to prevent
aggregating incompatible units.

src/Daqifi.Core/Device/AcquisitionStatistics.cs [41-44]

-// Last seen wins, exactly as Name does. A channel's scaling can be reassigned
-// mid-session, and the alternative -- freezing the first unit -- would label later
-// samples with a unit they were not measured in.
+if (state.SampleCount > 0 &&
+    !string.Equals(state.Unit, unit, StringComparison.Ordinal))
+{
+    ResetChannelState(state);
+}
+
 state.Unit = unit;

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 9

__

Why: The suggestion identifies a critical logical flaw where changing a channel's unit mid-session results in mathematically invalid statistical aggregations of incompatible quantities (e.g., mixing Volts and PSI).

High
  • Update

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Report acquisition statistics in channel engineering units

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Aggregate channel minimum, maximum, and mean from engineering-scaled sample values.
• Include the last-seen unit in each per-channel statistics snapshot.
• Cover positive, negative, and absent scaling to prevent regressions.
Diagram

graph TD
    S["IDataSample"] --> M["Manual Record"] --> C["RecordCore"]
    S --> E["Sample event"] --> C
    C --> A["Scaled accumulators"] --> P["Channel snapshot"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Segment statistics when units change
  • ➕ Prevents a measurement window from combining values expressed in different units.
  • ➕ Preserves an unambiguous unit for every reported aggregate.
  • ➖ Requires reset, grouping, or multiple snapshots when scaling changes.
  • ➖ Expands the public API and changes existing window semantics beyond this defect.
2. Expose both raw and scaled statistics
  • ➕ Preserves direct access to the previous voltage-based aggregates.
  • ➕ Makes both physical and engineering representations available to consumers.
  • ➖ Expands an already substantial snapshot model with duplicate metrics.
  • ➖ Still requires per-sample scaling to correctly handle mutable or negative transformations.

Recommendation: Aggregate ScaledValue at ingestion as implemented; it correctly handles negative gains and scaling changes that cannot be reconstructed from raw aggregates afterward. Keeping Unit on the snapshot is necessary for interpretation. Unit-segmented windows are worth considering separately if channels commonly switch between incompatible units during one measurement window.

Files changed (3) +133 / -9

Bug fix (2) +51 / -9
AcquisitionStatistics.csAggregate scaled values and retain channel units +27/-5

Aggregate scaled values and retain channel units

• Changes both explicit and event-driven recording paths to aggregate IDataSample.ScaledValue instead of Value. Tracks the last-seen sample unit in channel state and includes it in generated snapshots.

src/Daqifi.Core/Device/AcquisitionStatistics.cs

ChannelAcquisitionStatistics.csExpose units on channel statistics snapshots +24/-4

Expose units on channel statistics snapshots

• Adds an optional Unit property to the public record and documents that minimum, maximum, and mean values use that unit. The default null value preserves existing positional construction compatibility.

src/Daqifi.Core/Device/ChannelAcquisitionStatistics.cs

Tests (1) +82 / -0
AcquisitionStatisticsTests.csCover engineering-unit acquisition statistics +82/-0

Cover engineering-unit acquisition statistics

• Adds regression tests proving scaled values and units are reported, negative gains preserve correctly ordered extrema, and unscaled behavior remains unchanged. Adds a helper for constructing samples with channel scaling.

src/Daqifi.Core.Tests/Device/AcquisitionStatisticsTests.cs

@qodo-code-review

qodo-code-review Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Positional parameters break compatibility 🐞 Bug ≡ Correctness ⭐ New
Description
Adding Unit and ValueSampleCount to the public record's primary constructor removes the previous
constructor and 14-output Deconstruct signatures; optional defaults only preserve recompiled
constructor calls. Existing compiled consumers can fail with MissingMethodException, while source
consumers using positional deconstruction no longer compile.
Code

src/Daqifi.Core/Device/ChannelAcquisitionStatistics.cs[R120-121]

+        string? Unit = null,
+        long ValueSampleCount = 0)
Relevance

●●● Strong

PR #321 accepted the same public positional-record compatibility break and recommended an init
property or compatibility overload.

PR-#321

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ChannelAcquisitionStatistics is a public positional record, so these additions replace its
generated constructor and deconstruction shape. The repository's analogous SdCardLogEntry
deliberately uses an init property to avoid exactly this binary compatibility break.

src/Daqifi.Core/Device/ChannelAcquisitionStatistics.cs[105-121]
src/Daqifi.Core/Device/SdCard/SdCardLogEntry.cs[13-29]
src/Daqifi.Core/Daqifi.Core.csproj[9-25]
PR-#321

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Adding positional record parameters changes the generated constructor and `Deconstruct` signatures, breaking existing binary and deconstruction consumers.

## Issue Context
`Daqifi.Core` is a public package, and `SdCardLogEntry` already preserves compatibility by adding new metadata as `init` properties rather than primary-constructor parameters. Preserve the original primary constructor and expose `Unit` and `ValueSampleCount` as properties, initializing `ValueSampleCount` from `SampleCount` for legacy construction; update snapshot creation with an object initializer. Alternatively, provide exact legacy constructor and `Deconstruct` overloads.

## Fix Focus Areas
- src/Daqifi.Core/Device/ChannelAcquisitionStatistics.cs[105-121]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[187-206]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Mixed units share accumulator ✓ Resolved 🐞 Bug ≡ Correctness
Description
RecordCore overwrites state.Unit for every sample while retaining all prior values in the same
extrema and sum. If scaling changes from PSI to kPa during a window, the snapshot combines values
from both units and labels the result only as kPa, making min, max, and mean invalid.
Code

src/Daqifi.Core/Device/AcquisitionStatistics.cs[R412-415]

+                // Last seen wins, exactly as Name does. A channel's scaling can be reassigned
+                // mid-session, and the alternative -- freezing the first unit -- would label later
+                // samples with a unit they were not measured in.
+                state.Unit = unit;
Relevance

●● Moderate

The aggregate-unit inconsistency is plausible, but the PR explicitly chooses last-seen-wins and
history lacks a close rejection precedent.

PR-#518
PR-#523

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
AnalogChannel permits atomic scaling reassignment, and each emitted sample captures the scaling then
in force. RecordCore nevertheless accumulates every value into one sum/extrema while replacing the
unit, and Snapshot emits those lifetime aggregates with only the replacement unit.

src/Daqifi.Core/Channel/AnalogChannel.cs[128-142]
src/Daqifi.Core/Device/Internal/StreamFrameDecoder.cs[580-588]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[412-438]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[461-463]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[187-204]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`AcquisitionStatistics` combines values from different engineering units while retaining only the last unit. A scaling reassignment from PSI to kPa therefore produces dimensionally invalid min, max, and mean values labeled as kPa.

## Issue Context
Samples preserve the scaling active when they were created, and channel scaling can change at any time. The statistics implementation must not combine differently unit-labeled values into one accumulator; either segment/reset the channel statistics on a unit transition or represent separate unit-specific aggregates.

## Fix Focus Areas
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[403-463]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[187-204]
- src/Daqifi.Core.Tests/Device/AcquisitionStatisticsTests.cs[614-691]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Overflow fallback mislabels volts 🐞 Bug ≡ Correctness
Description
The new sample.ScaledValue and sample.Unit pairing records the original unscaled value but
labels it with the engineering unit whenever ChannelScaling.Apply overflows. Such samples are
consequently reported as PSI or another configured unit even though the accumulated number remains
in volts, and may be mixed with successfully converted readings.
Code

src/Daqifi.Core/Device/AcquisitionStatistics.cs[R281-282]

+            RecordCore(channel.Type, channel.ChannelNumber, channel.Name, sample.Timestamp,
+                sample.ScaledValue, sample.Unit);
Relevance

●●● Strong

Close precedent confirms overflow returns raw input; retaining engineering unit creates a concrete
correctness mismatch in statistics.

PR-#523

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ChannelScaling's documented implementation returns the input unchanged if the transformed result is
non-finite, while DataSample still derives Unit directly from that scaling. The changed statistics
entry points now persist precisely this mismatched value/unit pair.

src/Daqifi.Core/Channel/ChannelScaling.cs[91-105]
src/Daqifi.Core/Channel/DataSample.cs[36-45]
src/Daqifi.Core.Tests/Channel/ChannelScalingTests.cs[89-98]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[273-282]
PR-#523

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ChannelScaling.Apply` deliberately returns the original value when conversion arithmetic overflows, while `IDataSample.Unit` continues returning the configured engineering unit. Recording that pair makes acquisition statistics label unscaled volts as engineering-unit values.

## Issue Context
Overflow fallback is a supported and tested behavior, not an exception path. Add a way to distinguish successful engineering conversion from fallback, then define statistics behavior for failed conversions without folding unscaled values into an engineering-unit accumulator.

## Fix Focus Areas
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[273-282]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[533-539]
- src/Daqifi.Core/Channel/ChannelScaling.cs[91-105]
- src/Daqifi.Core/Channel/IDataSample.cs[48-62]
- src/Daqifi.Core.Tests/Device/AcquisitionStatisticsTests.cs[614-691]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This behavioral change modifies accumulation, scaling-transition semantics, snapshot contracts, and both recording paths, creating real correctness risk but not enough independent logic to justify redundant extended review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 2629b0f

Results up to commit 2bff46f ⚖️ Balanced


🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Mixed units share accumulator ✓ Resolved 🐞 Bug ≡ Correctness
Description
RecordCore overwrites state.Unit for every sample while retaining all prior values in the same
extrema and sum. If scaling changes from PSI to kPa during a window, the snapshot combines values
from both units and labels the result only as kPa, making min, max, and mean invalid.
Code

src/Daqifi.Core/Device/AcquisitionStatistics.cs[R412-415]

+                // Last seen wins, exactly as Name does. A channel's scaling can be reassigned
+                // mid-session, and the alternative -- freezing the first unit -- would label later
+                // samples with a unit they were not measured in.
+                state.Unit = unit;
Relevance

●● Moderate

The aggregate-unit inconsistency is plausible, but the PR explicitly chooses last-seen-wins and
history lacks a close rejection precedent.

PR-#518
PR-#523

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
AnalogChannel permits atomic scaling reassignment, and each emitted sample captures the scaling then
in force. RecordCore nevertheless accumulates every value into one sum/extrema while replacing the
unit, and Snapshot emits those lifetime aggregates with only the replacement unit.

src/Daqifi.Core/Channel/AnalogChannel.cs[128-142]
src/Daqifi.Core/Device/Internal/StreamFrameDecoder.cs[580-588]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[412-438]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[461-463]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[187-204]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`AcquisitionStatistics` combines values from different engineering units while retaining only the last unit. A scaling reassignment from PSI to kPa therefore produces dimensionally invalid min, max, and mean values labeled as kPa.

## Issue Context
Samples preserve the scaling active when they were created, and channel scaling can change at any time. The statistics implementation must not combine differently unit-labeled values into one accumulator; either segment/reset the channel statistics on a unit transition or represent separate unit-specific aggregates.

## Fix Focus Areas
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[403-463]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[187-204]
- src/Daqifi.Core.Tests/Device/AcquisitionStatisticsTests.cs[614-691]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Overflow fallback mislabels volts 🐞 Bug ≡ Correctness
Description
The new sample.ScaledValue and sample.Unit pairing records the original unscaled value but
labels it with the engineering unit whenever ChannelScaling.Apply overflows. Such samples are
consequently reported as PSI or another configured unit even though the accumulated number remains
in volts, and may be mixed with successfully converted readings.
Code

src/Daqifi.Core/Device/AcquisitionStatistics.cs[R281-282]

+            RecordCore(channel.Type, channel.ChannelNumber, channel.Name, sample.Timestamp,
+                sample.ScaledValue, sample.Unit);
Relevance

●●● Strong

Close precedent confirms overflow returns raw input; retaining engineering unit creates a concrete
correctness mismatch in statistics.

PR-#523

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ChannelScaling's documented implementation returns the input unchanged if the transformed result is
non-finite, while DataSample still derives Unit directly from that scaling. The changed statistics
entry points now persist precisely this mismatched value/unit pair.

src/Daqifi.Core/Channel/ChannelScaling.cs[91-105]
src/Daqifi.Core/Channel/DataSample.cs[36-45]
src/Daqifi.Core.Tests/Channel/ChannelScalingTests.cs[89-98]
src/Daqifi.Core/Device/AcquisitionStatistics.cs[273-282]
PR-#523

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ChannelScaling.Apply` deliberately returns the original value when conversion arithmetic overflows, while `IDataSample.Unit` continues returning the configured engineering unit. Recording that pair makes acquisition statistics label unscaled volts as engineering-unit values.

## Issue Context
Overflow fallback is a supported and tested behavior, not an exception path. Add a way to distinguish successful engineering conversion from fallback, then define statistics behavior for failed conversions without folding unscaled values into an engineering-unit accumulator.

## Fix Focus Areas
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[273-282]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[533-539]
- src/Daqifi.Core/Channel/ChannelScaling.cs[91-105]
- src/Daqifi.Core/Channel/IDataSample.cs[48-62]
- src/Daqifi.Core.Tests/Device/AcquisitionStatisticsTests.cs[614-691]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +281 to +282
RecordCore(channel.Type, channel.ChannelNumber, channel.Name, sample.Timestamp,
sample.ScaledValue, sample.Unit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Overflow fallback mislabels volts 🐞 Bug ≡ Correctness

The new sample.ScaledValue and sample.Unit pairing records the original unscaled value but
labels it with the engineering unit whenever ChannelScaling.Apply overflows. Such samples are
consequently reported as PSI or another configured unit even though the accumulated number remains
in volts, and may be mixed with successfully converted readings.
Agent Prompt
## Issue description
`ChannelScaling.Apply` deliberately returns the original value when conversion arithmetic overflows, while `IDataSample.Unit` continues returning the configured engineering unit. Recording that pair makes acquisition statistics label unscaled volts as engineering-unit values.

## Issue Context
Overflow fallback is a supported and tested behavior, not an exception path. Add a way to distinguish successful engineering conversion from fallback, then define statistics behavior for failed conversions without folding unscaled values into an engineering-unit accumulator.

## Fix Focus Areas
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[273-282]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[533-539]
- src/Daqifi.Core/Channel/ChannelScaling.cs[91-105]
- src/Daqifi.Core/Channel/IDataSample.cs[48-62]
- src/Daqifi.Core.Tests/Device/AcquisitionStatisticsTests.cs[614-691]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…mid-window

Recording the engineering value (the #534 fix) introduced a failure mode
that recording raw volts did not have: a window can now hold samples
measured in two DIFFERENT units.

IScaledChannel documents mid-session reassignment as supported -- "set it
and every sample decoded from then on carries it" -- and samples keep the
scaling in force when they were decoded. So a channel rescaled from PSI to
kPa mid-window produced one aggregate spanning both, labelled with only the
newest unit. A minimum in PSI and a maximum in kPa are not the extremes of
anything, and the label asserted they were kPa. Before this PR the window
was always volts and therefore at least unit-homogeneous, so this was a new
way to be wrong, introduced by the fix.

The VALUE accumulators now restart when the scaling changes, so Min, Max
and Mean always describe exactly one scaling. Timing, ordering and sample
counts are unit-independent and continue across the change untouched.

Compared on the whole ChannelScaling rather than just its unit label:
recalibrating a transducer leaves the unit reading "PSI" while making the
earlier numbers incomparable with the later ones. Aggregating those is the
same error wearing a matching label. ChannelScaling is a record, so this is
structural equality and a fresh-but-equal instance per sample does not look
like a reassignment.

ChannelAcquisitionStatistics gained ValueSampleCount so the restart is
visible rather than an unexplained gap: without it, MeanValue would silently
describe fewer samples than SampleCount reports.

Three tests, all of which fail without this change: a unit change restarts
the figures and reports the new unit; a gain-only change restarts them too;
and a stable scaling supplied as a new-but-equal instance each time does NOT
restart, which is the control that stops the rule collapsing every window to
its last sample.

ALSO FIXES THE #531 FLAKE, which this change would otherwise have made
deterministic. Record_AfterTheFirstSamplePerChannel_AllocatesNothing warmed
up over 100 iterations and measured 10,000. Tiered compilation promotes a
hot method partway through a long loop and that rejit allocates; with a
larger RecordCore the promotion moved inside the measured window, and the
test reported 7,128 bytes across 10,000 records. That is 0.71 bytes per
call -- not a per-call allocation at all, since a real one is at least 24
bytes an object and so at least 240 KB here -- but enough to fail an == 0
assertion.

So #531's "fails on a full run, passes in isolation" is not run-order
dependent, it is warmup-dependent: any change that grows RecordCore pushes
the promotion later. Warming over the same count as the measurement puts it
firmly before the measurement starts. The assertion keeps its teeth -- a
genuine per-call allocation is three orders of magnitude above the noise
this removes. Verified 3/3 runs green where it previously failed 3/3.

Baseline unchanged: the same 4 pre-existing LinuxUsbPortDescriptorProvider
failures, passing 3535 -> 3538.

Refs #531
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Audit round 1 and Qodo both landed on the same gap. Fixed — it was a way my own fix made things worse.

Recording the engineering value introduced a failure mode that recording raw volts did not have: a window can now hold samples measured in two different units.

IScaledChannel documents mid-session reassignment as supported — "set it and every sample decoded from then on carries it" — and samples keep the scaling in force when decoded. So a channel rescaled PSI → kPa mid-window produced one aggregate spanning both, labelled with only the newest unit. A minimum in PSI and a maximum in kPa are not the extremes of anything, and the label asserted they were kPa.

Before this PR the window was always volts and therefore at least unit-homogeneous. So this was a new way to be wrong that my fix created — not a pre-existing gap it inherited. That is the reason it had to be fixed here rather than filed.

The audit recommended resetting the accumulator on a scaling change; that is what landed.

What restarts, and what deliberately does not

The value accumulators restart — MinValue, MaxValue, ValueSum — so the three figures always describe exactly one scaling. Timing, ordering and SampleCount continue untouched: they are unit-independent, and a rescale is not a gap in the stream.

Compared on the whole ChannelScaling, not just its unit label. Recalibrating a transducer leaves the unit reading "PSI" while making the earlier numbers incomparable with the later ones — aggregating those is the same error wearing a matching label. ChannelScaling is a record, so this is structural equality, and a fresh-but-equal instance per sample does not read as a reassignment.

ValueSampleCount is now on the snapshot so the restart is visible. Without it MeanValue would silently describe fewer samples than SampleCount reports — an unexplained discrepancy is its own small trap.

Tests — all three fail without the change

test asserts
WhenTheScalingChangesMidWindow_RestartsTheValueFiguresAndSaysSo PSI then Bar → Unit="Bar", figures from the Bar samples only, ValueSampleCount=2 while SampleCount=4
WhenOnlyTheGainChanges_AlsoRestartsTheValueFigures same unit, different gain → still restarts
WithAStableScaling_CountsEveryValueSample a new but equal scaling per sample must not restart — the control that stops the rule collapsing every window to its last sample

Also in this commit: the #531 flake, which my change would have made deterministic

Record_AfterTheFirstSamplePerChannel_AllocatesNothing started failing 3/3 in the class run while passing in isolation. I did not assume it was the known flake — I A/B'd it: production change alone, 30/30 green; my three new tests added, 32/33. So my production code allocates nothing; the tests perturbed the run.

The failure said 7,128 bytes across 10,000 records. That is 0.71 bytes per call — not a per-call allocation at all, since a real one is ≥24 bytes an object and so ≥240 KB here. It is a one-off inside the measured window: tiered compilation promoting a now-larger RecordCore to tier-1, which the 100-iteration warmup no longer outran.

So #531's "fails on a full net10 run, passes in isolation" is not run-order dependent — it is warmup-dependent. Any change that grows RecordCore pushes the promotion later. Warming over the same count as the measurement puts it firmly before measurement begins: 3/3 green where it was 3/3 red. The assertion keeps its teeth, since a genuine per-call allocation is three orders of magnitude above the noise removed.

Baseline unchanged: same 4 pre-existing failures, passing 3532 → 3538.

Round 2 of the audit is running.

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/agentic_review

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Qodo pass 2: mixed-unit bug ✓ Resolved. One new finding — real mechanism, wrong place to fix it.

New: "Overflow fallback mislabels volts"

The mechanism is real, and I verified it in source rather than taking it on faith. ChannelScaling.Apply (ChannelScaling.cs:101):

var scaled = value * Gain + Offset;
return double.IsFinite(scaled) ? scaled : value;   // <- the original, in the original unit

So on overflow ScaledValue is volts while Unit still says "PSI", and nothing downstream can tell — the return type is a bare double with no signal that the fallback fired.

But this PR cannot fix it, and did not cause it. Given a bare double, AcquisitionStatistics has no way to know the conversion was skipped. The fix has to live where the information exists — a TryApply, or a documented contract change on Apply. Filed as #555 with three options and a recommendation.

Reachability, measured rather than asserted. Overflow needs value * Gain + Offset to leave double range, and both coefficients are already validated finite:

reading gain needed to overflow
1.0 V ~1.80e308
10.0 V ~1.80e307
4095 ~4.39e304

Not a transducer configuration anyone types — an adversarial input. And the non-finite-input case is unaffected: NaN/±∞ in gives a non-finite result out, which is visibly wrong rather than silently mislabelled. That is the fallback working as designed.

One thing worth saying plainly rather than burying: before this PR, no production code in Core read ScaledValue (per #534 — only StreamFrameDecoderTests did). So this PR is the first production consumer to expose the hazard, even though it did not introduce it. That is why it is filed rather than shrugged off.

The remaining open suggestion is the same item

Prevent mixed-unit statistical aggregation (importance 9) is the pass-1 finding, which is fixed in 2629b0f — Qodo's own bug counter marks it ✓ Resolved. It stays listed because /agentic_review does not un-list a finding once raised.

State

gate result
/improve 1 open — the already-fixed mixed-unit item
/agentic_review 2 bugs: 1 ✓ Resolved, 1 declined → #555
adversarial audit round 2 running against 2629b0f
tests 4 pre-existing failures, passing 3532 → 3538

Comment on lines +120 to +121
string? Unit = null,
long ValueSampleCount = 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Positional parameters break compatibility 🐞 Bug ≡ Correctness

Adding Unit and ValueSampleCount to the public record's primary constructor removes the previous
constructor and 14-output Deconstruct signatures; optional defaults only preserve recompiled
constructor calls. Existing compiled consumers can fail with MissingMethodException, while source
consumers using positional deconstruction no longer compile.
Agent Prompt
## Issue description
Adding positional record parameters changes the generated constructor and `Deconstruct` signatures, breaking existing binary and deconstruction consumers.

## Issue Context
`Daqifi.Core` is a public package, and `SdCardLogEntry` already preserves compatibility by adding new metadata as `init` properties rather than primary-constructor parameters. Preserve the original primary constructor and expose `Unit` and `ValueSampleCount` as properties, initializing `ValueSampleCount` from `SampleCount` for legacy construction; update snapshot creation with an object initializer. Alternatively, provide exact legacy constructor and `Deconstruct` overloads.

## Fix Focus Areas
- src/Daqifi.Core/Device/ChannelAcquisitionStatistics.cs[105-121]
- src/Daqifi.Core/Device/AcquisitionStatistics.cs[187-206]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2629b0f

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Audit round 2: ready_to_merge, treadmill: true

The round-1 mixed-unit defect is resolved — the arbiter confirms the structural ChannelScaling comparison, the mid-window accumulator restart, ValueSampleCount, and the three tests close it.

One finding remains, dispositioned defer_ticket at low severity, and it is a genuine one worth stating rather than glossing:

Appending positional record fields is binary-breaking

ChannelAcquisitionStatistics goes from 14 to 16 positional parameters. Optional defaults preserve source compatibility — anyone who rebuilds is fine — but not binary compatibility, because C# emits one .ctor/Deconstruct at the new arity and resolves defaults at the caller's compile time. A consumer compiled against v1.6.0's 14-parameter shape, run against the new DLL without recompiling, throws MissingMethodException.

The auditor reproduced this empirically with a minimal record rather than reasoning about it, which is why I am not arguing with the mechanism.

Why it does not block, and why that is not special pleading: it fails loud — a hard exception at the call site — where round 1's mixed-unit bug failed silently into a wrong-but-plausible number. That asymmetry is exactly why that one was fixed here and this one is deferred. The break is also narrow (property reads, with, Equals, GetHashCode, ToString all unaffected; only positional construction and full-arity Deconstruct), and this type is a read-only snapshot consumers overwhelmingly read rather than build.

Filed as #557 — a release-process decision, not a code change: accept-and-changelog, add a compatibility overload, or state a policy. My preference is accept-and-changelog plus writing the policy down once, because this type will keep gaining fields as the firmware reports more (#536 is adding eight accessors to a sibling type right now), so a shim per addition does not scale.

Reworking the record shape this late in the PR would be scope creep on a fix that is otherwise done, and the arbiter flagged treadmill: true.

Qodo

review_bugs=2: bug 1 (mixed units) ✓ Resolved; bug 2 (ChannelScaling.Apply's overflow fallback) declined with measured reachability and filed as #555. The single open suggestion is the already-fixed mixed-unit item, which /agentic_review re-lists rather than un-lists.

Final state

check result
adversarial audit round 2 ready_to_merge, 1 finding deferred → #557
Qodo bugs 1 resolved, 1 declined → #555
tests 4 pre-existing failures (unchanged), passing 3532 → 3538
tests that earn their place 5 fail on pre-change code; 2 are mutation-proved controls
bonus #531 root-caused as warmup-dependent, not run-order dependent, and fixed

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.

bug(streaming): acquisition statistics report volts for a channel configured in engineering units

1 participant