Skip to content

perf: integer energy bucket, no float or division in the ADC ISR - #163

Merged
FredM67 merged 7 commits into
devfrom
feat/integer-energy-bucket
Sep 30, 2026
Merged

FredM67 merged 7 commits into
devfrom
feat/integer-energy-bucket

Conversation

@FredM67

@FredM67 FredM67 commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

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_21

dev This PR
Library calls, usual path __divmodsi4, __addsf3, __subsf3, __mulsf3, __floatsisf, 2× __cmpsf2, 5× __gesf2 none
Contribution at a positive crossing roughly 900-1050 cycles (libgcc estimates) ~125 cycles (counted from the disassembly)
Threshold comparisons float compare calls 32-bit compares
basic flash / RAM 8666 / 435 8900 / 435

Flash grows by 234 bytes: the cal / n table (55 bytes) and the fallback path; loop() still links the float library for datalogging, so none of it goes away. RAM is unchanged.

How

  • Fixed-point bucket (energy_bucket.h, Arduino-free): 1 unit = 1/16 W × mains cycle, int32_t. The bucket, thresholds and limits become l_… (int32_t, per the naming convention).
  • Calibration stays float in 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.h rejects a zero, negative or too large (≥ 8) value.
  • No division: (sumP / n) × cal == sumP × (cal / n). n (sample sets per cycle) only takes a narrow range, so cal / n comes from a compile-time table in flash, one entry per phase and per n. 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 to N_MIN samples - the only path that still divides. Bonus: no truncation from the integer division either.
  • Multiply: multU24x16_to32_hi8() in mult_asm.h, (a × b) >> 8 with a on 24 bits, six hardware multiplies instead of a 64-bit __muldi3.
  • Sign symmetry: rounding is done on the magnitude, half away from zero, so import and export weigh exactly the same.

Tests - written first

test_energy_bucket, 25 native cases (in the CI matrix), each written red against a stub before the implementation:

  • calibration shift and rounding; quantisation error < 0.002% from 0.005 to 0.5;
  • agreement with the float reference within one unit over the full ±2^18 range, and with the exact energy for every phase and every n of the table;
  • no overflow at full scale, exact sign symmetry, no drift over 3000 accumulated cycles, the rescaled fallback.

Mutations that fail it: truncating instead of rounding, a 32-bit product, an off-by-one on n.

test_mult_asm gains 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

  • CI green, including test_mult_asm on Wokwi.
  • Bench: regulation unchanged (set point, import/export), n_lowestNoOfSampleSetsPerMainsCycle ~32, bucket value in the serial output as before.

Notes

Merge order

Three PRs are open against dev; checked with git merge-tree:

  1. feat: support several remote load units #160 (multiple remote units) and perf: integer energy bucket, no float or division in the ADC ISR #163 (integer energy bucket), in either order. The code merges cleanly; the second one only needs a small fix-up in .github/workflows/build.yml (test matrix) and test/README.md (test tables), where both add entries to the same lists.
  2. feat: decide the loads just before L1's zero crossing, on a predicted bucket #162 (predictive switching) last, rebased onto dev after 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 to Energy::contribution() and the cal / n table - which also removes its extra ISR cost.

🤖 Generated with Claude Code

FredM67 and others added 5 commits September 23, 2026 20:56
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>
…cket

# Conflicts:
#	.github/workflows/build.yml
#	Mk2_3phase_RFdatalog_temp/utils.h
@FredM67
FredM67 merged commit 8fa03d4 into dev Sep 30, 2026
10 checks passed
@FredM67
FredM67 deleted the feat/integer-energy-bucket branch September 30, 2026 17:58
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>
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.

1 participant