Skip to content

fix(ferroamp): give the float32 encoder a domain, and stop emitting inf#26

Merged
frahlg merged 2 commits into
mainfrom
26-ferroamp-float-domain
Jul 26, 2026
Merged

fix(ferroamp): give the float32 encoder a domain, and stop emitting inf#26
frahlg merged 2 commits into
mainfrom
26-ferroamp-float-domain

Conversation

@frahlg

@frahlg frahlg commented Jul 26, 2026

Copy link
Copy Markdown
Member

Found while getting an external review of the decode maths from #21. The decode side came back clean; the encode side did not.

The encoder had no domain

encode_f32_ws writes a battery setpoint. It accepted any number, and four kinds went wrong:

Input What happened
inf The normalising loop never terminates — inf / 2 is inf. The driver hangs.
1e39 Encodes as hi=0x803C, which decodes to -5.5e-39. A large charge becomes a small discharge.
NaN Returns {nan, nan} as register words.
< 1.2e-38 Yields a negative hi, which is not a 16-bit number at all.

None of these come from a sane setpoint. All of them come from the control plane, which is where setpoints originate — a unit slip, or a NaN from a division upstream, reaches them directly.

The encoder now refuses what float32 cannot hold, and driver_command returns false instead of writing something arbitrary. curtail encodes before enabling the limit, so a refused command cannot leave the site curtailed at whatever figure was in the register already.

Sign inversion on a control path is the same class of bug this function was rewritten to fix in #21 — it was still there, just moved to the far end of the range.

It also emitted inf

decode_f32_be refuses infinity: "Infinity and NaN would poison every downstream sum; report nothing." But the driver then scales kW to W, and an inverter answering with float32's largest value — which some firmware does for a register it cannot serve — decodes to 3.4e38 and overflows straight back into inf on the multiply.

All eleven scaled readings are now checked before they reach a site total.

Evidence

Verified against Python struct as ground truth, run on ./lua55 (which turned out to be a 32-bit float build — 0.1 + 0.2 prints 0.3):

  • decode_f32_be is exact. 22 686 bit patterns — the full exponent range 0x000xFF, both signs, subnormals including the smallest, 20 000 random words. 0 mismatches. The 94 inf/NaN patterns all return 0 as intended. The fix: use the canonical decode helpers, which 35 drivers were missing #21 rewrite holds on precisely the build where the old helper failed.
  • The encoder's working range is unchanged, byte for byte. Worst relative error across realistic setpoints and curtail limits is 4.75e-08 — float32 rounding. Round numbers round-trip exactly.
  • Classic Modbus sentinels on the read path were already safe: 0xFFFF 0xFFFF, NaN and ±inf all decode to 0. Only 0x7F7F 0xFFFF got through, via the scaling.

make check: 2782 passed, 577 skipped — unchanged from main.

Not addressed here

Whether a finite but implausible reading should be emitted at all is a broader question — it affects all 62 drivers and belongs in the host-API contract, not in one driver. This PR only stops values that are not numbers.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb22cc88a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +53
local function finite(value)
if value ~= value then return 0 end
if value == math.huge or value == -math.huge then return 0 end
return value

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject the sentinel before scaling on production runtimes

When firmware returns the stated 0x7F7FFFFF sentinel, both declared production runtimes—GopherLua and LuaJIT—represent numbers as float64, so 3.4028235e38 * 1000 remains the finite value 3.4028235e41; it is not equal to math.huge and this helper emits the bogus reading into site totals. The overflow only occurs in the repository's LUA_32BITS test interpreter, so reject the decoded float32 sentinel/domain before scaling rather than relying on host-number overflow.

AGENTS.md reference: AGENTS.md:L27-L27

Useful? React with 👍 / 👎.

frahlg and others added 2 commits July 26, 2026 09:31
The setpoint encoder accepted any number. Four ways that went wrong, all on
a path that writes to a battery:

  inf      the normalising loop never terminates; inf / 2 is inf
  1e39     encodes as hi=0x803C, which decodes to -5.5e-39 — a large charge
           command becomes a small discharge
  NaN      returns {nan, nan} as register words
  <1.2e-38 yields a negative hi, which is not a 16-bit number at all

None of these come from a sane setpoint. All of them come from the control
plane, which is where setpoints originate, so a unit slip or a NaN from a
division upstream reaches them directly. The encoder now refuses what float32
cannot hold and driver_command returns false rather than writing something
arbitrary. curtail encodes before enabling the limit, so a refused command
cannot leave the site curtailed at whatever was in the register already.

Verified against Python struct as ground truth: decode_f32_be is exact over
22686 bit patterns covering the full exponent range, both signs, subnormals
and 20000 random words — 0 mismatches on the LUA_32BITS build where the old
helper failed. The encoder's working range is unchanged byte for byte; worst
relative error over realistic setpoints is 4.75e-08, which is float32
rounding.

Separately, the driver emitted inf. decode_f32_be refuses infinity, but the
driver then scales kW to W, and an inverter answering with float32's largest
value — which some firmware does for a register it cannot serve — decodes to
3.4e38 and overflows back into inf on the multiply. That is the exact thing
decode_f32_be's comment sets out to prevent. All eleven scaled readings are
now checked before they reach a site total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
…its sign

Found by an external review of the same code. On a 32-bit integer build,
negating the smallest integer wraps to itself:

    -math.mininteger == math.mininteger   -- true
    math.abs(math.mininteger) < 0         -- true

So `-power_w / 1000` leaves the sign alone for power_w = -2147483648, and
the driver asks the battery to charge when the caller said discharge.
Ferroamp reads negative kW as charge, so the command inverts rather than
merely being wrong in magnitude. `math.abs(power_w)` on the curtail path
returns a negative number for the same input, which would encode a negative
export limit.

Verified on ./lua55:

    -power_w / 1000    ->  -2147483.75   (wrong sign)
    -(power_w / 1000)  ->   2147483.75   (right)

Dividing first, and multiplying by 1.0 before math.abs, makes the value a
float, where negation behaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg force-pushed the 26-ferroamp-float-domain branch from 179f651 to 59bd70b Compare July 26, 2026 07:31
@frahlg
frahlg merged commit 92f76c4 into main Jul 26, 2026
5 checks passed
@frahlg
frahlg deleted the 26-ferroamp-float-domain branch July 26, 2026 07:32
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