perf: integer energy bucket, no float or division in the ADC ISR - #163
Merged
Merged
Conversation
First step of #120: the arithmetic that will replace the float bucket in the ADC ISR, bucket += (l_sumP / n) * f_powerCal[phase], written against a native test suite that uses the float expression as its reference. energy_bucket.h (Arduino-free): - the bucket unit is 1/16 W x mains cycle (FRACTION_BITS = 4); - calibration values stay floats in calibration.h and become 16-bit fixed-point constants at compile time, with one shift shared by all phases, chosen so that the largest value fills the 16 bits (Q20 for { 0.042346, 0.043453, 0.042969 }, within 0.0006%); - contribution() rounds half away from zero on the magnitude, so import and export weigh exactly the same. mult_asm.h: multU24x16_to32_hi8(), (a x b) >> 8 with a on 24 bits, six hardware multiplies instead of a 64-bit __muldi3. The product of a full-scale average power (2^18) and a 16-bit constant needs ~35 bits. test_energy_bucket (15 cases, in the CI matrix): shift and rounding of the calibration, quantisation error below 0.002% from 0.005 to 0.5, agreement with the float reference within one unit over the full +-2^18 range, no overflow at full scale, exact sign symmetry, and no drift over 3000 accumulated cycles. Truncating instead of rounding, or a 32-bit product, each make it fail. test_mult_asm gains the matching case for the new multiply (runs on Wokwi in CI). Nothing in the firmware uses this yet: basic is unchanged apart from the embedded branch name. Refs #120 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The energy bucket, its thresholds and its limits are now int32_t in the fixed-point units of energy_bucket.h (Joules * SUPPLY_FREQUENCY * 16) instead of float, and processLatestContribution() uses Energy::contribution() with a calibration table built at compile time from f_powerCal. calibration.h is unchanged for users. __vector_21 no longer calls any floating-point routine: __addsf3, __subsf3, __mulsf3, __floatsisf, 2x __cmpsf2 and 5x __gesf2 are gone. From the division to the bucket being written back, a contribution now takes ~86 cycles (worst case, from the disassembly), where the float routines took several hundred. The comparisons against the thresholds are plain 32-bit compares. The one remaining library call is the __divmodsi4 of l_sumP / n, the next step of #120. - f_ -> l_ for the bucket, thresholds and limits, following the naming convention (l_ = int32_t) - Energy::toFixed(cal[]) and Energy::isValidCalibration(), tests first (18 cases in test_energy_bucket) - validation.h rejects a calibration value that is not positive or too large (>= 8) - Shared::copyOf_energyInBucket_main is an int32_t; the conversion to joules for display stays in loop() basic: +80 bytes flash (loop() still needs the float library), +6 bytes RAM (the calibration table). Not yet checked on hardware. Refs #120 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One cycle's contribution is (sumP / n) x cal, which is sumP x (cal / n). n, the number of sample sets in the cycle, only takes a narrow range of values, so cal / n now comes from a table built at compile time - one 16-bit entry per phase and per n - and the ISR multiplies sumP directly. That removes the __divmodsi4 from the usual path, and the truncation of that division with it. - The range of n is derived at compile time from the ADC timing (13 ADC clocks at F_CPU / 128 per conversion, 6 conversions per sample set): 32 +- 6 at 50 Hz, 26 +- 6 at 60 Hz. - The table lives in flash (PROGMEM, 78 bytes for 3 phases). The step-1 calibration table is no longer needed, so RAM is back to the dev baseline. - A cycle whose length falls outside the range (start-up, missing phase) rescales its sum to N_MIN samples and uses the N_MIN entry: one shared shift, one inlined contribution(). Only that rare path still divides. Tests first, 8 more cases in test_energy_bucket (26 in total): table shift and values for 50 and 60 Hz, agreement with the exact energy for every phase and every n, agreement with the division path, the rescaled fallback, no overflow at N_MAX samples of full scale, no drift with n wandering around 32. An off-by-one on n fails six of them. __vector_21, usual path: no library call left. A positive-crossing contribution is ~125 cycles from the disassembly (range check, table lookup, contribution, bucket update), where the division and the float routines took roughly 900-1050. basic: +152 bytes flash over the previous commit, RAM unchanged (435 bytes, as on dev). Not yet checked on hardware. Refs #120 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In practice n stays between 31 and 33 at 50 Hz. A margin of 4 (28..36, or 22..30 at 60 Hz) still leaves 3 sample sets on each side of what is observed; anything outside falls back to the rescaled path anyway. The table shrinks from 13 to 9 entries per phase (55 bytes of flash, -24); the shift, hence the precision, is unchanged. The tests follow the new 50 Hz range, and the fallback test now covers 27 and 37, just outside it. Refs #120 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Energy::toFixed(cal[]) and FixedCalibration have had no user since the fallback was folded into the per-sample table; removed with their test. The division-path comparison now builds its constant with the scalar toFixed(). Firmware unchanged. - docs/performance.md: the bucket copy is an int32_t, and the bucket update is an integer multiply, not "simple addition" (it never was: it was a division plus float calls). - test/README.md lists test_energy_bucket. Refs #120 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 24, 2026
…cket # Conflicts: # .github/workflows/build.yml # Mk2_3phase_RFdatalog_temp/utils.h
This was referenced Sep 30, 2026
FredM67
added a commit
that referenced
this pull request
Sep 30, 2026
The firmware files the conversions as V1 I1 V2 I2 V3 I3 in turn and writes ADMUX at the end of its ISR, for the conversion after next. When the ISR runs past the start of that conversion, it runs on the previous channel and its sample is filed under the wrong one. grid_sim now follows the firmware's sequence and counts those conversions (ignoring the first two rounds after the ADC starts), and the scenarios expect none: `expect misfiled_samples 0`. Measured on the surplus-step scenario: dev before #163 misfiled 10 samples (one per ISR overrun, at datalog time), dev with #163 none. It is a more precise check than the overrun count: of #162's 1928 overruns, only 14 misfiled a sample. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #120.
The ADC ISR kept the energy bucket as a float: every positive zero crossing ran
bucket += (l_sumP / n) * f_powerCal[phase]- a 32-bit division plus libgcc float calls - and the load decision compared floats. This makes the whole bucket integer and removes the division from the usual path.Result in
__vector_21dev__divmodsi4,__addsf3,__subsf3,__mulsf3,__floatsisf, 2×__cmpsf2, 5×__gesf2basicflash / RAMFlash grows by 234 bytes: the
cal / ntable (55 bytes) and the fallback path;loop()still links the float library for datalogging, so none of it goes away. RAM is unchanged.How
energy_bucket.h, Arduino-free): 1 unit = 1/16 W × mains cycle,int32_t. The bucket, thresholds and limits becomel_…(int32_t, per the naming convention).calibration.h- nothing changes for users. It is converted at compile time, with a shift chosen so that the largest value fills 16 bits: a typical set{ 0.042346, 0.043453, 0.042969 }lands within 0.0006%.validation.hrejects a zero, negative or too large (≥ 8) value.(sumP / n) × cal == sumP × (cal / n).n(sample sets per cycle) only takes a narrow range, socal / ncomes from a compile-time table in flash, one entry per phase and pern. The range is derived from the ADC timing: 32 ± 4 at 50 Hz, 26 ± 4 at 60 Hz (observed in practice: 31-33). A cycle outside it (start-up, missing phase) rescales its sum toN_MINsamples - the only path that still divides. Bonus: no truncation from the integer division either.multU24x16_to32_hi8()inmult_asm.h,(a × b) >> 8withaon 24 bits, six hardware multiplies instead of a 64-bit__muldi3.Tests - written first
test_energy_bucket, 25 native cases (in the CI matrix), each written red against a stub before the implementation:nof the table;Mutations that fail it: truncating instead of rounding, a 32-bit product, an off-by-one on
n.test_mult_asmgains the case for the new multiply. It runs on Wokwi in CI only; locally the routine was built for the Uno and its exact instruction sequence emulated against ~1 M exact products, including all byte-boundary edges.Before merge
test_mult_asmon Wokwi.n_lowestNoOfSampleSetsPerMainsCycle~32, bucket value in the serial output as before.Notes
alpha/lpf_gain) is still float but disabled at compile time; enabling it would bring floats back into the ISR.Merge order
Three PRs are open against
dev; checked withgit merge-tree:.github/workflows/build.yml(test matrix) andtest/README.md(test tables), where both add entries to the same lists.devafter perf: integer energy bucket, no float or division in the ADC ISR #163. That is a real rework, not only a text conflict: its prediction uses the float bucket, and must move toEnergy::contribution()and thecal / ntable - which also removes its extra ISR cost.🤖 Generated with Claude Code