Skip to content

feat(demag): demag compensation — measure demag time, cut power on demag-past-ZC (PR #2 rebased)#24

Draft
AlexKlimaj wants to merge 13 commits into
ark-releasefrom
feat/demag-comp-ark
Draft

feat(demag): demag compensation — measure demag time, cut power on demag-past-ZC (PR #2 rebased)#24
AlexKlimaj wants to merge 13 commits into
ark-releasefrom
feat/demag-comp-ark

Conversation

@AlexKlimaj

@AlexKlimaj AlexKlimaj commented Jul 3, 2026

Copy link
Copy Markdown
Member

⚠️ Status: bench-validated — ship comp-OFF, do NOT enable demag compensation

Bench validation is complete (ARK 4IN1 F051 + Flight Stand 50). Full report:
hwci/DEMAG_BENCH_FINDINGS.md.

  • Inertness (comp OFF): PASS. A no-prop A/B against ark-release HEAD shows
    the compiled-in-but-off demag port adds no zero-cross jitter (2.31 % → 2.35 %
    mean, flat max) and no CPU regression.
  • Demag compensation (comp ON): NET-HARMFUL — do not enable. With a prop +
    6S, the demag_step_stress snap-steps ran clean with comp off but
    desynced hard with DEMAG_COMP=2: at 100 % throttle the motor collapsed from
    ~30 000 RPM to a mean of ~8 500 (595 bemf-timeout samples vs 0 off). Root
    cause: the F051 demag-edge ISR branch is an ungated/unconfirmed parallel edge
    path, the arm has no stability check (arms through fast accel), and the
    allOff + duty-cut + re-entrant interruptRoutine() response sustains the
    desync.

Demag comp is off by default (eeprom 184/185 default 0xff), so this branch
is safe to merge as the comp-OFF path — adaptive stall timeout + perf v5
telemetry + the review fixes below, all bench-clean. The compensation itself
needs the rework in the report (stability-gate the arm, gate/confirm the demag
edge, soften the response) before it is enabled; DEMAG_COMP=<n> stays a
bench-only experiment until then.


Summary

Rebase of the demag-compensation half of draft PR #2 onto ark-release. The original branch predates #14/#19/#21, which rewrote the F051 comparator ISR path it patches (inlined comparator read, RAM-resident hot path, glitch-tolerant confirm loop), so this is a hand re-application as a reviewable commit series rather than a textual rebase. The active demag half (freewheel through the FET channel instead of the body diode) is split out into stacked PR #25 so its hazard profile can be reviewed and bench-gated separately.

Rebased 2026-07-06 onto current ark-release. Since #29 now owns perf struct v4 (the 32-bin PWM-phase histogram) and #30 added the turn-on grid compensation, the demag perf fields were renumbered to struct v5 (offsets 148–154), appended after the histogram; host_cmd stays frozen at 60. Both features coexist; all 244 offline tests pass.

What it does

After each zero-cross (when demag_comp_level is set, throttle > 20%, and the motor is up to speed), interruptRoutine() arms auto_blanking: changeCompInput() then configures the comparator EXTI with reversed polarity, so the first edge seen after commutation is the demag release edge — the moment the freewheel diode stops clamping the floating phase. The new demagEdgeRoutine() (RAM_FUNC) timestamps it: blanking_length = time-since-ZC − waitTime (measured against the snapshotted scheduling delay, see fixes) is the measured demag duration, and the comparator is flipped back to normal polarity to catch the true zero-cross.

If demag runs past half the commutation interval (the ZC is being swallowed), it counts demag_happened, powers off the bridge, cuts duty by 25/50/75% per compensation level, and re-enters interruptRoutine(). Bench-proven inadequate — see the status banner: this response path sustains desync under load rather than recovering from it.

Also included, as a deliberately separable commit (feat(bemf-timeout)): the stall threshold adapts to the commutation rate (average_interval * 4 once zero_crosses > 50, clamped to 45000 — see fixes) instead of the fixed 22.5 ms — revert alone if it misbehaves on aggressive throttle chops.

Fixes to the original PR logic (found during the port + code review)

  • demag_happened was uint8_t on non-CAN builds (saturates at 255 — useless for hwci gating); now unconditionally volatile uint32_t.
  • The level-3 duty cut was a mislabeled default: case; now an explicit case 3: with a no-cut default so out-of-range values can't apply the harshest cut.
  • duty_cycle (volatile) was double-read while computing the cut; now snapshotted once.
  • An early/noise edge (time_since_zc <= waitTime) now zeroes both the measurement and the derived active-freewheel time, so a garbage measurement can't persist.
  • blanking_length is clamped to average_interval.
  • auto_blanking/measurement state is reset on startup, desync, and bemf-timeout paths (via resetDemagState(), cleared before commutate() on start so a stale arm can't set the wrong polarity).
  • [review] blanking_length is measured against a snapshotted scheduling delay (demag_wait_ticks), not the next-cycle waitTime that PeriodElapsedCallback recomputes before the demag edge fires.
  • [review] Adaptive bemf stall timeout clamped to 45000 — the 16-bit INTERVAL_TIMER (ARR 0xffff) would otherwise make average_interval*4 unreachable at low RPM, disabling stall detection.
  • [review] Demag duty cut handed to tenKhzRoutine via a latch/pending flag instead of a direct ISR write raced by the slew write-back.
  • [review] Stale comparator EXTI pending cleared before unmask (all ported MCUs) so ring latched while masked can't fire demagEdgeRoutine with a bogus timestamp.
  • [review] Makefile flag-stamp so HWCI_PERF/DEMAG_COMP changes force a rebuild (an A/B session could otherwise flash a stale artifact).

Scope & gating

MCU layer ported for f051, g071, g431, l431 (all compile; g431's uint32_t commutation_interval extern fix preserved). A HAS_DEMAG_COMP gate keeps every other MCU family fully inert (verified: SEQURE_4IN1_F421 builds with the feature compiled out). Both eeprom fields (bytes 184/185, in the CAN-block reserved space) default off via 0xff guards. New hot-path code is RAM_FUNC on F051.

hwci integration

  • perf struct v5 (append-only after feat(perf): PWM-phase histogram of zero-crossings (v4) — hump mechanism identified #29's v4 histogram, host_cmd frozen at 60): demag_events (offset 148, mirror of demag_happened), blanking_len_last (152), blanking_len_max (154); total 156 bytes. Host decode with full v1–v5 back-compat (used live to decode the v4 ark-release firmware during the inertness A/B), fw_demag_events metric (a new key alongside the host-derived detection, so old baselines stay comparable), report, sim, and tests: 244 passed.
  • Bench knob: make ARK_4IN1_F051 HWCI_PERF=1 DEMAG_COMP=2 forces the level at boot without touching the eeprom, so A/B sessions flip the feature by reflashing.

Memory (ARK_4IN1_F051, HWCI_PERF=1 DEMAG_COMP=2)

Region Used %age
FLASH 24496 B 89.3%
RAM 7856 B 98.2%

RAM headroom on HWCI builds is thin: the v5 struct now carries #29's 64-byte histogram and the demag block. The next RAM-costing F051 feature (or a comp-ON A/B that also wants the histogram) will need a trade — dropping the phase histogram from the bench build frees the most.

Bench validation — RESULTS (2026-07-06)

Full report and data pointers: hwci/DEMAG_BENCH_FINDINGS.md.

  1. Inertness A/B (no prop, 25 V/3 A supply, noprop_smoke_3a) — PASS. vs ark-release HEAD: zero-cross jitter flat (2.31 % → 2.35 % mean, 12.2 % → 12.2 % max), CPU 62 % → 61 %, demag/bemf-timeout 0/0. The auto_blanking branch adds no jitter when off.
  2. demag_step_stress A/B (HQ5136 prop + 6S) — comp-ON FAILS. comp-off ran the 90/95/100 % snap-steps clean (0 bemf timeouts, RPM held ~28 650 at 100 %); DEMAG_COMP=2 desynced hard (595 bemf-timeout samples, 3 host desync events, 290 fw_demag_events; step100 RPM collapsed to a mean ~8 500). fw_demag_events did not drop with comp on — it caused the desyncs. See report for the root cause and the rework fix path.
  3. efficiency_sweep gate vs baselines/ARK_4IN1_F051_HQ5136.json — not run (blocked on the comp-ON failure; efficiency is moot until the feature works).
  4. Adaptive-bemf-timeout — present in both A/B builds and clean with comp off (0 timeouts), so it is not implicated in the comp-ON desync.

🤖 Generated with Claude Code

https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6

@AlexKlimaj

Copy link
Copy Markdown
Member Author

Bench validation complete on the ARK 4IN1 F051 + Flight Stand 50. Summary (full report: hwci/DEMAG_BENCH_FINDINGS.md, description updated):

  • Inertness (comp OFF): PASS — no-prop A/B vs ark-release HEAD, zero added zero-cross jitter (2.31%→2.35% mean), no CPU regression.
  • Demag comp (comp ON): net-harmful — do not enable. demag_step_stress with a prop + 6S: comp-off held ~28,650 RPM at 100% throttle with 0 bemf timeouts; DEMAG_COMP=2 collapsed to a mean ~8,500 RPM with 595 bemf-timeout samples. Root cause is the ungated/unconfirmed demag-edge ISR path + no arm stability check + the allOff+cut+re-entrant interruptRoutine() response.

Recommendation: merge as the comp-OFF path (adaptive timeout + perf v5 + review fixes; demag is off by default via eeprom 0xff). The compensation itself needs the rework in the report before it's enabled — best tracked on a follow-up experimental branch.

claude and others added 10 commits July 7, 2026 16:34
… and globals

Split the CAN-section reserved[8] into demag_compensation (byte 184),
active_demag (byte 185) and reserved[6]. Add the runtime globals
(demag_comp_level, blanking_length, auto_blanking, active_demag,
active_demag_fet_on, active_demag_ticks, demag_happened) and load them
in loadEEpromSettings() with 0xff-erased-flash guards, gated on
HAS_DEMAG_COMP so non-supporting MCU layers compile the feature out.
demag_happened is an unconditional volatile uint32_t (monotonic counter
for the hwci perf export). last_duty_cycle becomes volatile since the
demag edge ISR will write it.

No behavior change: demag_comp_level stays 0 (HAS_DEMAG_COMP is not
defined by any MCU layer yet).

Co-authored-by: dakejahl <37091262+dakejahl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
…mag fet helpers

Per MCU layer (f051, g071, g431, l431):

- phaseouts.h: declare phaseA/B/CHIGH, LOW and FLOAT, plus
  activeDemagFetOn()/activeDemagFetOff(); define HAS_PHASE_HIGH and
  HAS_DEMAG_COMP so Src/main.c can gate the feature per MCU.
- phaseouts.c: enable HIGH_BITREG_ON and add phaseXHIGH() for both the
  direct-drive and PWM_ENABLE_BRIDGE variants (high fet held hard on,
  used for active demag).
- phaseouts.c: implement activeDemagFetOn()/activeDemagFetOff() here
  instead of main.c (deviation from the original PR) so gcc inlines the
  phase bodies in-TU. The helpers stay in flash (not RAM_FUNC): with
  the inlined phase bodies the pair costs ~500 B of RAM, which does not
  fit the F051's 8 KB alongside the HWCI_PERF instrumentation, and the
  1WS flash fetch penalty is well below the 0.5 us COM timer tick. The
  fet is picked by the floating phase (step) and conducting side
  (rising).
- comparator.c changeCompInput(): when auto_blanking is armed, program
  the EXTI trigger with reversed polarity so the first comparator edge
  after commutation is the demag release edge; f051 keeps RAM_FUNC.

Interrupt dispatch to demagEdgeRoutine() lands with the routine itself
in the next commit so every commit links.

Co-authored-by: dakejahl <37091262+dakejahl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
After each confirmed zero cross, interruptRoutine() arms auto_blanking
(when demag_comp_level is set, input > 400 and commutation_interval >
100). changeCompInput() then programs the comparator EXTI with reversed
polarity, so the first edge after commutation is the demag release edge
(the freewheel diode stops clamping the floating phase). The comparator
IRQ handlers route that edge to the new demagEdgeRoutine(), which:

- computes blanking_length = time since zero cross minus waitTime
  (the commutation point), clamped to average_interval
- derives active_demag_ticks (half the measured demag time, capped at
  waitTime/2) for the next commit's active freewheel scheduling
- on an invalid measurement (edge before the commutation point) zeroes
  both blanking_length and active_demag_ticks instead of leaving stale
  ticks behind (fix over the original PR)
- restores normal comparator polarity via changeCompInput()
- if demag ran past half the commutation interval: counts
  demag_happened, powers off with allOff(), cuts duty per
  demag_comp_level (1: -25%, 2: -50%, 3: -75%, snapshotting the
  volatile duty_cycle once) and re-enters interruptRoutine()

demagEdgeRoutine() is RAM_FUNC since it is called from the RAM-resident
F051 comparator ISR. The ark-release glitch-tolerant zero-cross confirm
loop is untouched and guards the re-entry. auto_blanking and the active
demag state are reset on startMotor(), desync and bemf timeout.

Co-authored-by: dakejahl <37091262+dakejahl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
Replace the fixed INTERVAL_TIMER_COUNT > 45000 bemf timeout with an
adaptive threshold once the motor is running steadily (zero_crosses >
50): four times the average commutation interval. A demag-stalled or
desynced motor is then caught within a few expected commutations
instead of after a fixed 45 ms worth of timer ticks. During startup the
original 45000 threshold is kept.

Separate commit so it can be reverted alone if bench testing shows the
tighter threshold trips on aggressive throttle chops.

Co-authored-by: dakejahl <37091262+dakejahl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
Appends the demag compensation block after v4's zc_phase_hist histogram
(renumbered from v4 during the rebase: #29 took v4 on ark-release):
demag_events (u32, monotonic mirror of main.c demag_happened),
blanking_len_last/max (u16, measured demag time in INTERVAL_TIMER ticks
from demagEdgeRoutine). Fed from the main-loop snapshot path - zero ISR
cost. host_cmd stays frozen at offset 60; total size 148 -> 156 bytes.

Host: FIELDS_V5 + decode, DWARF vintage probe (demag_events marker),
model columns, fw_demag_events metric (per steady tail and per run,
wrap-safe u32 delta, None on pre-v5 firmware), report column, sim demag
mirror of injected desyncs, tests incl. u32 wrap and v2/v3/v4 back-compat.

Co-authored-by: dakejahl <37091262+dakejahl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
'make <TARGET> DEMAG_COMP=<1|2|3>' forces the demag compensation level at
boot via -DHWCI_DEMAG_COMP, applied in loadEEpromSettings() after the 0xff
guards and inside the HAS_DEMAG_COMP gate. The eeprom byte 184 stays
untouched, so A/B bench sessions flip the feature by reflashing firmware
without a config-tool pass. Production builds leave it unset and are
unaffected. (The ACTIVE_DEMAG companion knob rides with the active-demag
feature branch.)

Co-authored-by: dakejahl <37091262+dakejahl@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvp2XLcgsviGSVn9PaaP6
Firmware fixes found during review of the demag compensation path:

- blanking_length measured the demag duration against waitTime, but
  PeriodElapsedCallback recomputes waitTime for the NEXT cycle before the
  demag edge fires. Snapshot the scheduling delay (demag_wait_ticks) when
  auto_blanking is armed and measure against that, so a throttle transient
  no longer inflates the measurement and spuriously trips the duty cut.

- Adaptive bemf stall timeout (average_interval * 4) can exceed the 16-bit
  INTERVAL_TIMER range (ARR = 0xffff), making stall detection unreachable
  at low commutation rates. Clamp to 45000 so a slow-turning stall is
  always caught (the pre-PR fixed threshold was always reachable).

- The demag duty cut wrote duty_cycle/last_duty_cycle directly from the
  comparator ISR, which preempts tenKhzRoutine and can be clobbered by its
  unguarded "last_duty_cycle = duty_cycle" slew write-back (lost cut, phase
  re-energizes at full duty). Hand the cut to tenKhzRoutine via a latch +
  pending flag; it applies on the same tick the CCRs actually change, so
  the cut can no longer be raced away.

- startMotor cleared auto_blanking AFTER commutate(), but commutate()->
  changeCompInput() programs comparator polarity off auto_blanking, so a
  stale arm from a prior run (e.g. a stop path that didn't clear it) could
  arm reversed polarity for the first commutation. Clear demag state before
  commutate() via a new resetDemagState() helper (also used at the desync
  and bemf-timeout reset sites so a future state field can't be missed).

- The HWCI_DEMAG_COMP bench override was applied after active_demag was
  derived from demag_comp_level, so a forced level never propagated to the
  derived gate. Apply the override before the derivation.

- Gate the auto_blanking arm in interruptRoutine with HAS_DEMAG_COMP so the
  non-ported MCU families no longer pay a volatile test in the hottest ISR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…MCUs)

enableCompInterrupts() unmasked the comparator EXTI without clearing the
pending flag. An edge latched while masked during the zero-cross ->
commutation wait window (comStep/PWM ring) therefore fires the ISR the
instant of unmask. On the demag path that hits demagEdgeRoutine, whose
branch has none of the interval/confirm gates the normal path uses, so it
records a near-zero blanking_length and disarms the measurement - the
feature silently reads ~0 under exactly the noisy high-load conditions it
targets. Clear the pending flag(s) immediately before unmasking so the
first edge seen after unmask is genuine.

On the two-comparator ports (g071/g431) clear both comparator lines so a
stale flag on the non-current line cannot be consumed by the ungated
two-line ISR dispatch either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
These command-line flags become -D defines but are not part of any
object's path, so "make <target> DEMAG_COMP=2" after a prior build
returned "Nothing to be done" and an A/B bench run could flash firmware
built at the wrong demag level (or with no perf struct). Record the flags
in a stamp file that every ELF depends on; when they change, the stamp's
contents and mtime change and the ELF is rebuilt. Uses $(file) rather than
the shell to stay portable across the repo's Windows/Unix make wrappers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the hardware-CI results for this PR:

- Inertness A/B (no prop, 3A supply) vs ark-release HEAD: PASS. The
  compiled-in-but-off demag port does not add zero-cross jitter (2.31%->2.35%
  mean, flat max) and leaves CPU headroom intact.

- Demag-event A/B (HQ5136 prop + 6S): comp-ON (DEMAG_COMP=2) is NET-HARMFUL.
  Identical demag_step_stress snap-steps ran clean with comp off but desynced
  hard with it on - at 100% throttle the motor collapsed from ~30000 RPM to a
  mean of ~8500 (595 bemf-timeout samples, 290 fw_demag_events) where comp-off
  had zero. Root cause: the F051 demag-edge ISR branch is an ungated,
  unconfirmed parallel edge path (no interval gate, no confirm loop), the arm
  gate has no stability check so it arms through fast accel, and the
  allOff+cut+re-entrant interruptRoutine response sustains the desync.

Verdict: demag comp ships OFF by default (eeprom 0xff), so this branch is safe
to merge as comp-OFF infra (adaptive timeout + perf v5 + review fixes). The
compensation itself needs the rework in the doc before it is enabled. Adds
noprop_smoke_3a.yaml (2.8A abort) for current-limited-supply bring-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AlexKlimaj AlexKlimaj force-pushed the feat/demag-comp-ark branch from 1abe208 to b5c7288 Compare July 7, 2026 22:34
- Introduced ACTIVE_DEMAG option in Makefile to enable active demag at boot.
- Enhanced ADC interrupt handlers across multiple MCU files to include a minimum time gate for demag release edges.
- Updated main.c to manage demag state and track consecutive failed demag measures.
- Added new metrics for active demag interlock skips in performance tracking.
- Updated performance structures and metrics processing to accommodate new interlock metrics.
- Created new YAML profiles for no-prop smoke tests at various throttle levels.
- Enhanced test coverage for new metrics and performance structures.
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.

2 participants