V10 — Nabla against FEMM on identical models
Tier X - Cross-code
Tier X (cross-code) — priority P0
Tolerances frozen on 2026-08-16. Nabla 0.1.0,
solver licence mode enforcing,
run in 315.3 s.
1. Problem
This is the comparison every prospective user runs anyway, so it is better done here, in public, with the models downloadable.
Four physics, six models, two codes, one process. Each model is built in Nabla,
solved, read — and then, in the same run and the same minute, rebuilt in
FEMM 4.2 through pyfemm, solved there, and read at the same points.
| Sub-model | Physics | What it exercises |
|---|---|---|
ccore |
nonlinear magnetostatic, planar | BH inversion, coil linkage, body force, stored energy |
round_50Hz / round_500Hz / round_5kHz |
time-harmonic with eddy currents, planar | AC resistance, field displacement |
spm |
magnetostatic with motion, planar, anti-periodic | sliding band vs air-gap element, machine torque, cogging, back-EMF |
annulus |
steady heat conduction, axisymmetric | the thermal module, and the r weighting of an axisymmetric kernel |
None of the four models is defined by this case. All four already exist in the dossier, each validated against a closed form in its own chapter, so V10 imports the sibling case's builder rather than typing the geometry a third time:
| Sub-model | Nabla side built by | FEMM side built by |
|---|---|---|
ccore |
V02_ccore/build.py |
V02_ccore/reference/femm_ccore.py |
spm |
V05_spm_pmsm/build.py |
V05_spm_pmsm/reference/femm_spm.py |
round_* |
V12_skin_effect/build.py |
this case's femm_side.py |
annulus |
V08_heat_conduction/build.py |
this case's femm_side.py |
The judged row shared_source_files_missing is what turns "nothing was
re-typed" from a sentence into a check: it counts the eleven sibling files the
chapter's models are built from and fails the case if one of them is not where
the case says it is. It reads 0.
Only two FEMM models belong to V10 — the wire and the cylinder wall — because V12's and V08's references are closed forms and neither case had a FEMM script to borrow.
What V10 owns for all six is in models.json: the operating point of each
family, the length of the machine's rotor sweep, and where each field map is
sampled. That last one turned out to matter more than anything else in the
case; §5.1 is about it.
2. Reference
FEMM 4.2 (28 Feb 2018 build), http://www.femm.info, driven live.
Every other cross-code case in this dossier freezes its FEMM numbers — the pattern V02 established, and what §3.5 of the plan asks for, so the nightly gate needs no FEMM installation and a third-party answer cannot move under a case between runs. §3.5 names exactly one exception, and it is this chapter:
Reserve the live-FEMM form for V10, whose subject is the side-by-side run.
So expected.json holds no reference values at all. Every quantity is
declared "reference": "computed", and what was frozen before Nabla was pointed
at anything is the list of compared quantities, the sampling rule for each field
map, and the tolerance on each row — 1 % on integral quantities, 2 % on force
and torque, 2 % L2 on field maps, transcribed from the plan. One tolerance is
deliberately different and says so in its own row: torque ripple is judged at
5 %, because a ripple is a difference of two torques over their mean and at this
machine's 4.5 % ripple it carries each code's torque error amplified twentyfold.
On a host without FEMM the case reports SKIP, never a silent pass.
The conventions each code's numbers are read under — FEMM's harmonic amplitudes against time-averaged block integrals, its air-gap-element torque covering the whole machine rather than the sector, its heat-flow line integral already being a revolved power — are set out in the case's reference notes rather than assumed here.
3. Nabla model
| Sub-model | Element order | Nodes | Elements |
|---|---|---|---|
ccore |
P1 | 5196 | 10260 |
round_50Hz |
P1 | 1803 | 3541 |
round_500Hz |
P1 | 1805 | 3545 |
round_5kHz |
P1 | 5712 | 11359 |
spm |
P1 | 3122 | 6009 |
annulus |
P1 | 1770 | 3400 |
Everything geometric and material is the sibling case's. Three run settings are V10's own and are worth naming:
- The machine runs 36 steps per electrical period, not V05's 180 — 2.5
mechanical degrees per step, six samples per 15-degree cogging period, which
resolves the cogging harmonic
h6and its first overtone and nothing above. V05 owns the fine sweep and the spectrum. FEMM is driven at steps 0, 5, 10 … of its own 180-step convention so that step k is the same physical angle in both codes; the judged rowspm_femm_angle_match_max_degis what says so rather than leaves it to arithmetic done twice, and it reads 0. - The wire runs at 50, 500 and 5000 Hz, which is the plan's list and not V12's decade ladder. The mesh rule is V12's — 16 elements per skin depth, which is V12's own measured requirement for a loss integral rather than for a field.
- The C-core runs at 5000 A-turns, V02's sweep point
ni5: past the knee, so both codes are solving a nonlinear problem rather than two linear ones that happen to share a permeability.
4. Results
| Quantity | Metric | Nabla | Reference | Error | Tolerance | Verdict |
|---|---|---|---|---|---|---|
ccore_B_gap_T |
rel | 1.55937 T | 1.55578 T | +0.231 % | 1.000 % | PASS |
ccore_flux_linkage_Wb |
rel | 0.810007 Wb | 0.810767 Wb | -0.094 % | 1.000 % | PASS |
ccore_field_energy_J |
rel | 3.09643 J | 3.08774 J | +0.281 % | 1.000 % | PASS |
ccore_force_x_N |
rel | -2017.78 N | -2013.43 N | -0.216 % | 2.000 % | PASS |
ccore_B_map_T |
profile | min=0.004373, max=1.998, rms=1.046 T | min=0.003781, max=2, rms=1.045 T | L2 +0.747 %, max +4.619 % | L2 2.000 %, max - | PASS |
round_R_ac_vs_f_ohm_per_m |
profile | min=0.002457, max=0.002755, rms=0.002561 ohm/m | min=0.00244, max=0.002744, rms=0.002546 ohm/m | L2 +0.601 %, max +0.623 % | L2 1.000 %, max - | PASS |
round_L_int_vs_f_H_per_m |
profile | min=4.72e-08, max=5.016e-08, rms=4.918e-08 H/m | min=4.69e-08, max=4.987e-08, rms=4.889e-08 H/m | L2 +0.597 %, max +0.593 % | L2 1.000 %, max - | PASS |
round_J_profile_per_amp |
profile | min=1.33e+05, max=1.657e+05, rms=1.407e+05 A/m^2 per A rms | min=1.326e+05, max=1.653e+05, rms=1.402e+05 A/m^2 per A rms | L2 +0.326 %, max +0.600 % | L2 2.000 %, max - | PASS |
round_J_map_per_amp |
profile | min=1.33e+05, max=1.662e+05, rms=1.419e+05 A/m^2 per A rms | min=1.326e+05, max=1.654e+05, rms=1.415e+05 A/m^2 per A rms | L2 +0.319 %, max +0.561 % | L2 2.000 %, max - | PASS |
spm_flux_linkage_peak_Wb |
rel | 0.218489 Wb | 0.218662 Wb | -0.079 % | 1.000 % | PASS |
spm_bemf_fundamental_V |
rel | 132.679 V | 133.473 V | -0.594 % | 1.000 % | PASS |
spm_average_torque_Nm |
rel | 37.412 N.m | 37.4816 N.m | -0.186 % | 2.000 % | PASS |
spm_torque_ripple_pct |
rel | 4.4696 % | 4.46673 % | +0.064 % | 5.000 % | PASS |
spm_cogging_pp_Nm |
rel | 0.665943 N.m | 0.653244 N.m | +1.944 % | 2.000 % | PASS |
spm_cogging_h6_Nm |
rel | 0.263681 N.m | 0.259093 N.m | +1.771 % | 2.000 % | PASS |
spm_arc_Br_T |
profile | min=-0.2394, max=1.024, rms=0.604 T | min=-0.2396, max=1.027, rms=0.6039 T | L2 +1.062 %, max +3.088 % | L2 2.000 %, max - | PASS |
spm_coil_Jz_abs_A_per_m2 |
rel | 1.06458e+07 A/m^2 | 1.06458e+07 A/m^2 | -0.000 % | 1.000 % | PASS |
spm_B_map_T |
profile | min=0.003631, max=1.941, rms=0.7259 T | min=0.004021, max=1.969, rms=0.7674 T | L2 +18.210 %, max +38.828 % | L2 -, max - | REPORT |
annulus_T_rise_profile_K |
profile | min=1.463, max=74.51, rms=37.1 K | min=1.462, max=74.51, rms=37.1 K | L2 +0.015 %, max +0.038 % | L2 1.000 %, max - | PASS |
annulus_T_rise_map_K |
profile | min=1.462, max=74.54, rms=37.43 K | min=1.461, max=74.51, rms=37.42 K | L2 +0.016 %, max +0.046 % | L2 1.000 %, max - | PASS |
annulus_heat_flow_W |
rel | 108.789 W | 108.814 W | -0.023 % | 1.000 % | PASS |
spm_phase_current_max_err_A |
abs | 4.26326e-14 A | 0 A | 4.263e-14 | 0.001 | PASS |
spm_femm_angle_match_max_deg |
abs | 0 degrees | 0 degrees | 0 | 1e-09 | PASS |
shared_source_files_missing |
abs | 0 - | 0 - | 0 | 0 | PASS |
spm_femm_cogging_pp_vs_frozen_pct |
report | -14.4454 % | - % | - | - | REPORT |
spm_femm_cogging_h6_vs_frozen_pct |
report | -9.42147 % | - % | - | - | REPORT |
spm_femm_torque_vs_frozen_pct |
report | 0.137958 % | - % | - | - | REPORT |
spm_femm_linkage_vs_frozen_pct |
report | 0.193932 % | - % | - | - | REPORT |
round_current_ratio_at_profile_f |
report | 0.150698 - | - - | - | - | REPORT |
annulus_nabla_vs_exact_l2_pct |
report | 0.022385 % | - % | - | - | REPORT |
annulus_femm_vs_exact_l2_pct |
report | 0.00848607 % | - % | - | - | REPORT |
annulus_nabla_vs_exact_flow_pct |
report | 0.0110999 % | - % | - | - | REPORT |
annulus_femm_vs_exact_flow_pct |
report | 0.0343397 % | - % | - | - | REPORT |
round_current_ratio_vs_f |
report | (series) | - | - | - | REPORT |
round_kr_vs_f |
report | (series) | - | - | - | REPORT |
spm_cogging_vs_angle_Nm |
report | (series) | - | - | - | REPORT |
spm_torque_vs_angle_Nm |
report | (series) | - | - | - | REPORT |
spm_femm_cogging_pp_vs_frozen_pct: How far THE REFERENCE moved. This run's live FEMM peak-to-peak cogging against the value committed in validation/cases/V05_spm_pmsm/reference/femm_reference.json on 2026-08-09, at the same rotor angles, from the same script on the same unmodified inputs. Nothing about Nabla is measured herespm_femm_cogging_h6_vs_frozen_pct: The same comparison on the physical cogging harmonic, which is the quantity V05's cross-code claim is quoted onspm_femm_torque_vs_frozen_pct: The CONTROL for the two rows above: the same two FEMM runs' mean rated torque. A quantity that reproduces where cogging does not is what makes the finding a statement about cogging rather than about the machinespm_femm_linkage_vs_frozen_pct: The second control: peak open-circuit flux linkage from the same two FEMM runsround_current_ratio_at_profile_f: The current Nabla's wire actually carried at 5 kHz, as a fraction of the 1 A the coil source asked for. A time-harmonic coil source in Nabla is a VOLTAGE source (V12's finding), so a solid conductor does not carry its source current; FEMM's series circuit is 1.000 by construction. Every judged row of this family normalises by the carried current, which is why the difference is reportable rather than fatalannulus_nabla_vs_exact_l2_pct: Nabla's own error against the closed form, as a percentage of the L2 norm of the temperature riseannulus_femm_vs_exact_l2_pct: FEMM's own error against the same closed form, computed the same wayannulus_nabla_vs_exact_flow_pct: Nabla's heat flow through r = 25 mm against 2pikH(T1-T2)/ln(r2/r1)annulus_femm_vs_exact_flow_pct: FEMM's heat flow through the same contour against the same closed formround_current_ratio_vs_f: extracted but not frozen in expected.json - reported onlyround_kr_vs_f: extracted but not frozen in expected.json - reported onlyspm_cogging_vs_angle_Nm: extracted but not frozen in expected.json - reported onlyspm_torque_vs_angle_Nm: extracted but not frozen in expected.json - reported only
4.1 The integrals
The quantities a 2D code is actually bought for agree across the board:
| Nabla − FEMM | Tolerance | |
|---|---|---|
| C-core gap flux density | +0.231 % | 1 % |
| C-core coil flux linkage | -0.094 % | 1 % |
| C-core stored field energy | +0.281 % | 1 % |
| C-core armature force | -0.216 % | 2 % |
| SPM peak flux linkage | -0.079 % | 1 % |
| SPM back-EMF fundamental | -0.594 % | 1 % |
| SPM mean rated torque | -0.186 % | 2 % |
| SPM torque ripple | +0.064 % | 5 % |
| Wire AC resistance, 3 frequencies | L2 +0.601 % | 1 % |
| Wire internal inductance, 3 frequencies | L2 +0.597 % | 1 % |
| Cylinder-wall heat flow | -0.023 % | 1 % |
Two of these are worth a sentence each. The stored field energy is the plan's first named quantity and the only integral here that reads the nonlinear energy density rather than a flux or a force; it lands at +0.281 %. And the armature force is compared like for like — Nabla's eggshell region integral against FEMM's weighted stress tensor over the same block, both weighted volume integrals over the body — for the reason V02 established: a single closed-line stress contour does not converge where a volume integral does, in either code, and pairing one against the other measures the two methods rather than the two codes.
The AC resistance rows carry a wrinkle that had to be dealt with rather than
assumed away. The two codes are not driven the same way and cannot be: a
time-harmonic coil source in Nabla is a voltage source, so a solid conductor
given a 1 A source carries 0.150698 of it at
5 kHz, while FEMM's series circuit imposes the current exactly. That is V12's
own finding, and the answer is the same one V12 used — every judged quantity is
normalised by the current each code actually carried, measured from the slope
of A against ln r outside the wire. R_ac, L_int and J per ampere are
all drive-independent; the raw loss is not, and is not judged.
4.2 The field maps
An integral comparison is blind to a local disagreement, which is exactly what this chapter is for.



| Map | L2 | worst point | Tolerance |
|---|---|---|---|
| C-core, |B| | +0.747 % | +4.619 % | L2 2 % |
| Wire, |J| per ampere | +0.319 % | +0.561 % | L2 2 % |
| Cylinder wall, temperature rise | +0.016 % | +0.046 % | L2 1 % |
SPM sleeve arc, B_r |
+1.062 % | +3.088 % | L2 2 % |

The machine's flux wave is read through the stator sleeve at r = 34.5 mm and not in the airgap, and the reason is structural rather than a preference: FEMM's air-gap element replaces the whole 33 → 34 mm annulus with a boundary, so there is no mesh there and no field to read. The sleeve is non-magnetic in both codes, so it is the same flux wave half a millimetre further out. The chapter says so rather than calling it the airgap field.
Both codes' radial component is assembled from Bx and By here, never from
either code's own normal-component convention — the same discipline V02 used on
its Ampère contour, and for the same reason: a sign convention is exactly the
kind of thing that makes a waveform comparison silently compare a curve against
its own reflection.
4.3 The one map that is reported and not judged

spm_B_map_T runs a polar grid over the whole stator annulus and reads
+18.210 % L2, worst point +38.828 %.
It was declared REPORT before the run, and it is in the chapter as the control
for §5.1: it is what a field map looks like when it is not kept clear of
material interfaces. The grid crosses a tooth/slot boundary at every tooth, and
a sample on such a line reads 1.9 T or 0.05 T depending on which side its
element fell on, independently in each code. Nothing about the two solutions is
in disagreement there; the sampling is.
4.4 The one sub-model where "which code is right" has an answer
The cylinder wall has a closed form —
T(r) = T1 + (T2 − T1)·ln(r/r1)/ln(r2/r1) — so both codes can be scored against
it rather than only against each other:
| vs the closed form | |
|---|---|
| Nabla, temperature profile | 0.022385 % |
| FEMM, temperature profile | 0.00848607 % |
| Nabla, heat flow through r = 25 mm | 0.0110999 % |
| FEMM, heat flow through r = 25 mm | 0.0343397 % |
Both are inside a twentieth of a percent on a logarithmic solution that is genuinely outside the P1 space, and the two conservation numbers straddle the exact answer. This is the strongest form the comparison takes anywhere in the chapter, and it is worth noting that it is the thermal module — the newest physics in Nabla — that supplies it.
5. Discussion
5.1 A field map is a sampling problem before it is a comparison
The first run of this case failed two rows, and both were the case's own construction rather than either solver's.
models.json originally dropped map points within 2.5 mm of a material
corner, which is the plan's words — "away from material corners" — taken
literally. That is not what the physics asks for. |B| is discontinuous across
the whole length of an iron/air interface, not only where two of them meet: at
5000 A-turns this model carries 2.0 T on the iron side of x = 0 and 0.04 T on
the air side. The grid landed exactly on the core's x = 0 and x = 20 faces,
each code returned whichever side its own element fell on, and the row read
+17.8 % L2 and +58 % max about two solvers that were in fact agreeing to a
few tenths of a percent everywhere they were legitimately sampled. Excluding a
strip around every interface — a strictly stronger rule, since every corner lies
on a boundary — takes the same row to +0.747 %.
The lesson generalises past this case: a cross-code field-map comparison measures the sampling rule until the sampling rule is right, and a rule stated in terms of corners will pass a coarse grid and fail a fine one for no physical reason. §4.3 is that effect left visible on purpose.
5.2 Which solution is live is not obvious, and now has a witness
The second failing row read +39 % L2 on the machine's sleeve arc, and the
cause was that Nabla's machine pipeline runs the no-load sweep and then the
loaded one, and the solution file left live at the end is the second. So
record 0 of a Nabla machine result is the loaded field at the as-drawn angle,
not the open-circuit field — while getCoggingTorque and getFromPhase read
stored per-stage data and are unaffected. The case was comparing Nabla's loaded
field against FEMM's no-load field, and every waveform row still passed.
That is now impossible to repeat: spm_coil_Jz_abs_A_per_m2 probes the source
current density at the centre of the first phase-A coil side in both codes, at
the record the maps are read from, and a stage mismatch would take it from
1.06 × 10⁷ A/m² to zero. It reads -0.000 %.
Magnitudes are compared so that neither code's current-direction convention can
fail it for a reason that is not a disagreement.
5.3 The finding: FEMM's cogging torque does not reproduce
The four spm_femm_*_vs_frozen_pct rows measure nothing about Nabla. They
compare this run's live FEMM answer against the FEMM answer V05 committed on
2026-08-09, at the same rotor angles, from the same script, on inputs that git
says have not been touched since:
| FEMM quantity | live vs V05's committed |
|---|---|
| cogging, peak to peak | -14.4454 % |
cogging, physical harmonic h6 |
-9.42147 % |
| mean rated torque | 0.137958 % |
| peak open-circuit flux linkage | 0.193932 % |
FEMM is bit-reproducible today — three consecutive rebuild-and-solve trials in one session returned identical torques to every printed digit — and the two control rows reproduce V05's committed values to under 0.2 %. Only cogging has moved, and it has moved by 9–14 %.
This matters because V05's headline cross-code finding is quoted on that
number. V05 reports a converged −15 % between Nabla's cogging and FEMM's,
and concludes that with Nabla's band and torque instrument exonerated, FEMM's
air-gap element is the remaining hypothesis. Against a FEMM run made in the same
minute, this case reads +1.771 % on h6 and
+1.944 % peak-to-peak — inside the 2 % the plan sets for
a torque. The discrepancy V05 reports is of the same order as the run-to-run
scatter in the reference it is measured against, and is therefore not
established at the precision it is quoted to.
What has not been established is what changed. The only difference this case could find between the two FEMM runs is that the meshed areas of the three arc-bounded regions differ by up to 1.6 × 10⁻⁴ relative while every straight-edged region matches to under 10⁻⁹ — so the two runs did not discretise the arcs identically, and cogging is a 0.65 N·m residual of a 40 N·m integrand and is hypersensitive to exactly that. The evidence, the ranked hypotheses, what would falsify each and a one-line reproduction apiece are in case V10.
The generalisable point is the one this chapter is best placed to make: a frozen third-party number is a measurement with its own reproducibility, and a cross-code case should say what that is. Freezing is still the right default — it is what stops a reference drifting silently — but a frozen number quoted to better than its own run-to-run scatter is over-quoted, and the only way to find out is to run it twice. That is the argument for keeping one live-FEMM case in the dossier, and it is now backed by a measurement rather than by the plan's say-so.
5.4 What the comparison is actually evidence for
Nabla reproduces FEMM where FEMM applies — nonlinear magnetostatics, harmonic eddy currents, a slotted machine with motion, axisymmetric heat conduction — to a few tenths of a percent on every integral quantity and to about a percent on a field map. That is the whole of the claim, and it is deliberately modest: two 2D finite-element codes solving the same weak form on the same geometry should agree, and the value of showing it is that a reader can check rather than believe.
The product argument sits next to it rather than inside it: transient motion, circuit coupling, the thermal chain, second-order elements and the automation this very case is written in are all outside what FEMM does, so they are validated elsewhere in this dossier — against closed forms — and not here.
6. Limitations
- This is not a benchmark of FEMM. FEMM is a well-made free code that this audience already trusts, which is exactly why it is the reference. Where the two disagree, §4.4 is the only place this chapter is entitled to say which one is right, and there they agree.
- Every model here is one FEMM can represent, which excludes most of what Nabla is for. There is no transient with a time derivative, no circuit coupling, no second-order element, no coupled thermal run, no multi-motion model, and no skew. Those are validated in V04, V08, V09, V11, V12, V19, V22 and V23 against references that are not another code.
- One mesh per model. Nothing here is a convergence study; both codes are run at the mesh their sibling case fixed. V09 is the convergence case.
- The machine's cogging is sampled at 36 points, which resolves
h6andh12and nothing above. V05 owns the 180-point sweep and the spectrum. - The stator field map is not judged, by declaration and for the reason in §4.3.
spm_femm_*_vs_frozen_pctis a two-point comparison, not a distribution. It says the reference moved; it does not say how often, or how far it can.- FEMM's build is not pinned. The case records the FEMM installation it found but cannot make a third-party binary reproducible, which is half of §5.3's point.
7. Reproduce
cd validation
python -m harness.run_case V10
Needs triangle.exe (32-bit on Windows), a FEMM 4.2
installation and the pyfemm package. Without FEMM the case reports SKIP.
About six minutes: six Nabla models — one of which is a 72-step two-stage rotor
sweep — plus 77 FEMM solves, of which 72 are the same two sweeps.
--keep-work leaves every model, mesh and solution behind under
artifacts/work/. The published .nbl models are in artifacts/models/, and
every compared series — including both cogging waveforms and all four field maps
— is written out point by point under artifacts/series/.
8. Frozen-reference changelog
| Date | Change | Reason |
|---|---|---|
| 2026-08-16 | The C-core field map's exclusion rule changed from 'within 2.5 mm of a material CORNER' to 'within 2.5 mm of a material BOUNDARY'. Two rows were added that did not exist at the freeze: spm_coil_Jz_abs_A_per_m2 (judged, 1 %) and the four spm_femm_*_vs_frozen_pct report rows. No reference value moved, because this case has none - every reference is the live FEMM run - and no tolerance on a pre-existing row was touched. | Both are construction defects in the case, found on its first run and fixed before it was published, and they are recorded here rather than quietly because the freeze date is stated. (1) The corner rule was a mis-implementation of the plan's 'away from material corners': |