Skip to content

Configurable per H-bridge ETB duty ceiling (#9799) - #10086

Open
TokenGoblin wants to merge 3 commits into
rusefi:masterfrom
TokenGoblin:etb-configurable-duty-limit-9799
Open

Configurable per H-bridge ETB duty ceiling (#9799)#10086
TokenGoblin wants to merge 3 commits into
rusefi:masterfrom
TokenGoblin:etb-configurable-duty-limit-9799

Conversation

@TokenGoblin

Copy link
Copy Markdown

Fixes #9799.

Problem

ETB_DUTY_LIMIT was a hard-coded 0.9, reaching the motor through one macro at one call site:

#define ETB_DUTY_LIMIT 0.9
#define ETB_PERCENT_TO_DUTY(x) (clampF(-ETB_DUTY_LIMIT, 0.01f * (x), ETB_DUTY_LIMIT))

Per the report, that H-bridge trips its own overcurrent/thermal protection as the PID pushes duty toward the ceiling, on a throttle that is mechanically wide open around 45% duty. The top half of the range isn't merely unused on that hardware — it's actively driving the part into a fault state.

Now engineConfiguration->etbMaxDutyCycle[ETB_COUNT], default 90.

Two decisions I'd particularly like checked

90 is both the default and the upper bound. The field range is 10–90, not 10–100. This issue is about lowering the ceiling; letting a tune raise it past what the hardware has always been limited to is a separate call with hardware-protection implications, and not mine to make. One-character change if you'd rather allow 100.

0 means "not configured", and there are two independent fallbacks. Tunes predating the field read 0, which would otherwise mean never open the throttle. applyDefaultsOrFixAfterBurn maps 0 → 90 per docs/calibration-compatibility.md, and getMaxDutyCycle() separately falls back to 90 for anything outside the valid range. So a config that somehow reaches the controller before migration runs still can't shut the throttle. Belt and braces is deliberate here given what this path controls — happy to drop one layer if you consider it noise.

Implementation note

init() keeps its signature. It's a pure virtual with roughly two dozen call sites across the tests, and churning all of them to thread an index through seemed worse than recovering the index from etbFunctions[] — the array that defines the function → H-bridge mapping in the first place.

File Change
rusefi_config.txt uint8_t[ETB_COUNT iterate] etbMaxDutyCycle, appended at the end of engine_configuration_s
electronic_throttle.h ETB_DEFAULT_MAX_DUTY_CYCLE (90), ETB_MIN_MAX_DUTY_CYCLE (10)
electronic_throttle.cpp Macro → getHBridgeIndex() / getMaxDutyCycle() / percentToDuty()
default_base_engine.cpp Default in setDefaultBaseEngine, 0 → 90 migration in applyDefaultsOrFixAfterBurn
tunerstudio.template.ini Two fields in "Base ETB settings"; the second gated on etbFunctions2 != 0
test_etb.cpp 5 tests

Generated artifacts (*_generated_structures_*.h, per-board .ini, VariableRegistryValues.java) are intentionally not committed, per CLAUDE.md.

Validation

  • Full suite: 1164 pass, up from 1159. No regressions.
  • The default test deliberately does not set the field, so it pins that a fresh config already carries 90 out of setDefaultBaseEngine rather than only testing my own assignment.
  • The migration test zeroes the array, asserts applyDefaultsOrFixAfterBurn reports a change and fills 90, then asserts a user-chosen 45 survives a second call.

Two things I did not do, stated plainly:

  • No ARM firmware build locally — board builds and per-board .ini regeneration are on CI, not verified by me. My host toolchain is MinGW GCC 16.1, ahead of CI's, so warning-level parity isn't guaranteed either.
  • Not tested on hardware. The claim that a lower ceiling avoids the driver fault comes from the reporter, not from me. I've made the ceiling settable; I can't confirm it solves their fault.

While looking for this one I checked a number of other open issues against the code and found several already fixed — I left notes on #3040, #8070, #9101, #9103, #9638 and #9641 with the implementing commits, in case they're closable.

🤖 Generated with Claude Code

@TokenGoblin
TokenGoblin force-pushed the etb-configurable-duty-limit-9799 branch from f61be27 to b1820c9 Compare August 14, 2026 15:41
ETB_DUTY_LIMIT was a hard-coded 0.9, applied through the ETB_PERCENT_TO_DUTY
macro at the single point where the controller writes to the motor. The
reporter's H-bridge trips its own overcurrent/thermal protection as the PID
pushes duty toward that ceiling, on a throttle which is mechanically wide open
around 45% duty - so the top half of the range is not merely unused, it is
actively harmful on that hardware.

The limit is now engineConfiguration->etbMaxDutyCycle[ETB_COUNT], default 90.

90 stays both the default and the upper bound. This issue is about lowering the
ceiling; letting a tune raise it past what the hardware was always limited to is
a separate call with hardware-protection implications, so the field range stops
at 90 rather than 100. Easy to relax if that is not the preference.

0 means "not configured". Tunes predating the field read 0, which would
otherwise mean "never open the throttle". applyDefaultsOrFixAfterBurn maps it to
90 per docs/calibration-compatibility.md, and getMaxDutyCycle() independently
falls back to 90 for anything outside the valid range, so even a config reaching
the controller before migration runs cannot shut the throttle. Two layers on
purpose, given what this path controls.

init() keeps its signature - it is a pure virtual with roughly two dozen call
sites in the tests. The H-bridge index is instead recovered by finding this
controller's function in etbFunctions[], which is the array that defines the
mapping in the first place.

Full unit test suite: 1164 pass, up from 1159. Host build only; no ARM firmware
build was run locally, and this is not tested on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
startBenchTest() and doAutocal() drive the motor with a fixed 0.5 duty rather
than going through setOutput(), so they ignored the configurable ceiling
entirely. On the hardware which motivated this issue that is the worst case:
50% is above the limit the user lowered the ceiling to in order to stop the
driver faulting, so the two paths most likely to be run on the bench were the
two that could still trip it.

New clampToMaxDutyCycle() is used by all three call sites.

Trade-off worth flagging: autocal can be less accurate on a throttle which
needs more than the configured ceiling to reach its mechanical stops. Exceeding
a limit the user set for hardware protection is the worse of the two.

Full suite: 1172 tests pass.

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

Copy link
Copy Markdown
Author

Pushed a follow-up commit after tracing the subsystem properly rather than just the call site — the first version had a hole in it.

startBenchTest() and doAutocal() in electronic_throttle_impl.h drive the motor with a fixed 0.5 duty directly, not through setOutput(), so they ignored the new ceiling entirely:

motor->set(0.5f);      // startBenchTest
motor->set(0.5f);      // autocal open
motor->set(-0.5f);     // autocal close

On the hardware that motivated this issue that is the worst case: 50% is above the limit the user lowered the ceiling to in order to stop the driver faulting. So the two routines most likely to be run on a bench were the two that could still trip it — while the PR claimed to have fixed exactly that.

All three now go through a new clampToMaxDutyCycle().

Trade-off worth your judgement: autocal can be less accurate on a throttle that needs more than the configured ceiling to reach its mechanical stops. I took the view that exceeding a limit the user set for hardware protection is the worse of the two, but if you'd rather autocal keep full authority and only the runtime path be limited, that's a one-line change.

Full suite: 1172 tests pass.

TokenGoblin pushed a commit to TokenGoblin/rusefi that referenced this pull request Aug 15, 2026
…#9123 review)

Review rework per FDSoftware:

1. The valve now parks at a dedicated idleSolenoidParkPosition instead of
   following the idle loop's calculated position - "I want the off position to
   be X place instead of 0" is now literally a field.

2. The parked position is only held for idleSolenoidParkTimeout seconds after
   the engine stops turning, then the outputs switch off anyway - holding a
   solenoid energized with the engine off drains the battery and can overheat
   the coil.

The timeout check uses Timer::hasElapsedSec on the trigger event timer rather
than comparing getSecondsSinceTriggerEvent against the setting, because elapsed
readings saturate at 2^32 ticks - under 26 seconds on the kinetis/cypress ports
- which would make any larger timeout never expire and leave the valve powered
forever. hasElapsedSec treats a saturated delta as expired. It also means a
freshly booted ECU which has never seen the engine turn parks nothing.

Both new fields are appended at the end of engine_configuration_s so no offsets
move. Tunes which predate the timeout carry 0, which applyDefaultsOrFixAfterBurn
migrates to the 60 second default so enabling the flag cannot silently do
nothing - same pattern as etbMaxDutyCycle in rusefi#10086.

Full suite: 1170 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUaUv4kBBZhtCkMpZnh8So
…TY_CYCLE

default_base_engine.cpp uses the constant but never included electronic_throttle.h,
where it lives. The unit test pch happens to pull the header in, which is why the
test build passed while all 51 firmware/simulator CI jobs failed with

    default_base_engine.cpp:152: error: 'ETB_DEFAULT_MAX_DUTY_CYCLE' was not declared

The header is already compiled in every build context via engine_controller.cpp
and engine_configuration.cpp, so including it here is safe everywhere.

Local verification: full unit suite passes (1172 tests). The 32-bit MinGW
simulator toolchain CI uses is not available on this machine, so the simulator
build itself is left to CI - the include is what the error asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUaUv4kBBZhtCkMpZnh8So
rusefillc pushed a commit that referenced this pull request Aug 19, 2026
…review)

Review rework per FDSoftware:

1. The valve now parks at a dedicated idleSolenoidParkPosition instead of
   following the idle loop's calculated position - "I want the off position to
   be X place instead of 0" is now literally a field.

2. The parked position is only held for idleSolenoidParkTimeout seconds after
   the engine stops turning, then the outputs switch off anyway - holding a
   solenoid energized with the engine off drains the battery and can overheat
   the coil.

The timeout check uses Timer::hasElapsedSec on the trigger event timer rather
than comparing getSecondsSinceTriggerEvent against the setting, because elapsed
readings saturate at 2^32 ticks - under 26 seconds on the kinetis/cypress ports
- which would make any larger timeout never expire and leave the valve powered
forever. hasElapsedSec treats a saturated delta as expired. It also means a
freshly booted ECU which has never seen the engine turn parks nothing.

Both new fields are appended at the end of engine_configuration_s so no offsets
move. Tunes which predate the timeout carry 0, which applyDefaultsOrFixAfterBurn
migrates to the 60 second default so enabling the flag cannot silently do
nothing - same pattern as etbMaxDutyCycle in #10086.

Full suite: 1170 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUaUv4kBBZhtCkMpZnh8So
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.

Configurable max duty cycle per H-bridge (currently hardcoded to 90%)

1 participant