Skip to content

axfluxmdo.models

Fidelity layers 1–2 plus losses, thermal, constraints, and efficiency maps.

axfluxmdo.models.analytical

Fast analytical sizing model (Phase 1, Layer 1).

Electromagnetic formulation

The air-gap field comes from the magnet load line (materials.magnetic), its fundamental being B1 = (4/pi) * B_g * sin(alpha_m * pi / 2). The fundamental flux per pole over the annulus is

Phi_p = (2/pi) * B1 * A_g / (2p) = B1 * A_g / (pi * p)

and the peak phase flux linkage lambda = k_w * N * Phi_p. Torque and back-EMF are BOTH derived from this same flux linkage (sinusoidal machine, currents aligned with EMF / MTPA for a surface-PM machine):

E_rms = omega_e * lambda / sqrt(2)        (equivalently 4.44 f N k_w Phi_p)
T     = m * p * lambda * I_rms / sqrt(2)

so the power identity m * E_rms * I_rms == T * omega_m holds to machine precision by construction. The SPEC's shear-stress form T = (2 pi sigma_t / 3)(r_o^3 - r_i^3) is the equivalent integral formulation; it differs from the flux-linkage form only by the geometry factor (2/3)(r_o^3 - r_i^3) / (r_m (r_o^2 - r_i^2)) (~1.09 for the reference motor). The result reports shear_stress_pa as the average shear implied by the computed torque, T / ((2 pi / 3)(r_o^3 - r_i^3)).

Phase-1 simplifications (documented, lifted in later phases): slotless field (Carter factor 1 unless supplied), single air gap, magnets evaluated at ambient + 40 C (NOT coupled to the solved winding temperature — estimate the magnet thermal path separately for temperature-sensitive designs), inductive voltage drop neglected, zero mechanical loss by default.

AnalyticalResult dataclass

AnalyticalResult(torque_nm: float, back_emf_v_rms: float, electrical_frequency_hz: float, airgap_flux_density_t: float, shear_stress_pa: float, phase_resistance_ohm: float, current_density_a_mm2: float, copper_loss_w: float, core_loss_w: float, mechanical_loss_w: float, output_power_w: float, input_power_w: float, efficiency: float, winding_temp_c: float, mass_kg: float, mass_breakdown: dict[str, float], constraints: list[ConstraintRecord])

Evaluated performance of one motor at one operating point (SI + named units).

to_dict

to_dict() -> dict[str, float]

Flat scalar dict for sweeps/DataFrames; keys match constraint names.

Source code in src/axfluxmdo/models/analytical.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def to_dict(self) -> dict[str, float]:
    """Flat scalar dict for sweeps/DataFrames; keys match constraint names."""
    return {
        "torque_nm": self.torque_nm,
        "back_emf_v_rms": self.back_emf_v_rms,
        "electrical_frequency_hz": self.electrical_frequency_hz,
        "airgap_flux_density_t": self.airgap_flux_density_t,
        "shear_stress_pa": self.shear_stress_pa,
        "phase_resistance_ohm": self.phase_resistance_ohm,
        "current_density_a_mm2": self.current_density_a_mm2,
        "copper_loss_w": self.copper_loss_w,
        "core_loss_w": self.core_loss_w,
        "mechanical_loss_w": self.mechanical_loss_w,
        "output_power_w": self.output_power_w,
        "input_power_w": self.input_power_w,
        "efficiency": self.efficiency,
        "winding_temp_c": self.winding_temp_c,
        "mass_kg": self.mass_kg,
        "torque_density_nm_kg": self.torque_density_nm_kg,
        "feasible": float(self.feasible),
    }

AnalyticalModel

AnalyticalModel(limits: Limits | None = None, carter_factor: float = 1.0)

Layer-1 analytical sizing model.

carter_factor multiplies the magnetic air gap in the load line (default 1.0 = slotless). The Phase-4 FEA validation found the uncorrected load line OVERESTIMATES the gap field (about -11% on the under-magnet mean and -7% on the fundamental for the reference motor, from inter-magnet leakage and fringing), and measured an effective k_C = 1.44 for the slotted variant — measure your own with :func:axfluxmdo.validation.measured_carter_factor and pass it here for corrected predictions.

Source code in src/axfluxmdo/models/analytical.py
199
200
201
def __init__(self, limits: Limits | None = None, carter_factor: float = 1.0):
    self.limits = limits or Limits()
    self.carter_factor = carter_factor

build_constraints

build_constraints(motor: AxialFluxMotor, op: OperatingPoint, limits: Limits, *, winding_temp_c: float, f_e_hz: float, current_density_a_mm2: float, back_emf_v_rms: float, phase_resistance_ohm: float, b_yoke_t: float, magnet_temp_c: float) -> list[ConstraintRecord]

The Phase-1 constraint set, shared by all fidelity layers.

None limits resolve from the motor/operating point (see Limits). Voltage: V_required = sqrt(3)(E + IR) vs V_dc/sqrt(2) (SVPWM line-line fundamental). The INDUCTIVE drop I*X_L is neglected — fine at low electrical frequency, but materially optimistic when f_e is high AND the voltage margin is tight (e.g. ~100 uH at several kRPM adds tens of volts). Measure or estimate winding inductance before trusting a near-binding voltage constraint at high speed.

Source code in src/axfluxmdo/models/analytical.py
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def build_constraints(
    motor: AxialFluxMotor,
    op: OperatingPoint,
    limits: Limits,
    *,
    winding_temp_c: float,
    f_e_hz: float,
    current_density_a_mm2: float,
    back_emf_v_rms: float,
    phase_resistance_ohm: float,
    b_yoke_t: float,
    magnet_temp_c: float,
) -> list[ConstraintRecord]:
    """The Phase-1 constraint set, shared by all fidelity layers.

    ``None`` limits resolve from the motor/operating point (see ``Limits``).
    Voltage: V_required = sqrt(3)*(E + I*R) vs V_dc/sqrt(2) (SVPWM line-line
    fundamental). The INDUCTIVE drop I*X_L is neglected — fine at low
    electrical frequency, but materially optimistic when f_e is high AND the
    voltage margin is tight (e.g. ~100 uH at several kRPM adds tens of
    volts). Measure or estimate winding inductance before trusting a
    near-binding voltage constraint at high speed.
    """
    v_limit = (
        limits.max_line_voltage_v
        if limits.max_line_voltage_v is not None
        else op.dc_bus_voltage / math.sqrt(2.0)
    )
    v_required = (
        math.sqrt(3.0) * (back_emf_v_rms + op.current_rms * phase_resistance_ohm)
        if math.isfinite(phase_resistance_ohm)
        else math.inf
    )
    b_limit = (
        limits.max_core_flux_density_t
        if limits.max_core_flux_density_t is not None
        else motor.steel.b_sat_t
    )
    magnet_temp_limit = (
        limits.max_magnet_temp_c
        if limits.max_magnet_temp_c is not None
        else motor.magnet.max_operating_temp_c
    )
    return [
        make_upper_bound("winding_temp_c", winding_temp_c, limits.max_winding_temp_c),
        make_upper_bound("electrical_frequency_hz", f_e_hz, limits.max_electrical_freq_hz),
        make_upper_bound(
            "current_density_a_mm2", current_density_a_mm2, limits.max_current_density_a_mm2
        ),
        make_upper_bound("line_voltage_v", v_required, v_limit),
        make_upper_bound("core_flux_density_t", b_yoke_t, b_limit),
        make_upper_bound("magnet_temp_c", magnet_temp_c, magnet_temp_limit),
    ]

axfluxmdo.models.annular_2p5d

2.5D annular slice model (Phase 2, Layer 2).

The disk machine is split into n_slices radial annuli; the Phase-1 flux-linkage chain is evaluated per slice and summed:

dA_k     = pi * (r_{k+1}^2 - r_k^2)                 (exact annulus areas)
dlambda_k = k_w * N * B1(r_k) * dA_k / (pi * p)
lambda    = fsum(dlambda_k)
T = m*p*lambda*I/sqrt(2),   E_rms = p*omega_m*lambda/sqrt(2)

Because torque and EMF come from the same summed flux linkage, the power identity mEI == T*omega and the energy balance hold at any slice count, and with radius-uniform parameters the model reproduces AnalyticalModel to machine precision (n_slices=1 matches on every to_dict() key — the parity tests pin this).

Radius dependence enters through: the local axisymmetric gap (offset + coning from motor.tolerances), the local magnet arc (magnet_shape == "rectangular" gives alpha(r) = min(1, alpha_m * r_m / r)), the local pole pitch in the yoke flux proxy (low pole counts saturate first at the outer radius), optional edge fringing, and the 1/r current loading. Runout enters as analytic circumferential averages of the load line (:mod:axfluxmdo.materials.magnetic); note the convexity consequence — mean torque slightly increases with runout, the penalty being the 1/rev modulation reported as torque_ripple_proxy and the axial pull axial_force_n. Rotor tilting moment is not modeled in Phase 2.

Copper resistance and the thermal network stay lumped (single RC), per SPEC.

AnnularResult dataclass

AnnularResult(torque_nm: float, back_emf_v_rms: float, electrical_frequency_hz: float, airgap_flux_density_t: float, shear_stress_pa: float, phase_resistance_ohm: float, current_density_a_mm2: float, copper_loss_w: float, core_loss_w: float, mechanical_loss_w: float, output_power_w: float, input_power_w: float, efficiency: float, winding_temp_c: float, mass_kg: float, mass_breakdown: dict[str, float], constraints: list[ConstraintRecord], torque_ripple_proxy: float, axial_force_n: float, n_slices: int, slice_radii_m: ndarray, slice_airgap_b_t: ndarray, slice_b1_t: ndarray, slice_torque_nm: ndarray, slice_shear_pa: ndarray, slice_yoke_b_t: ndarray, slice_current_loading_a_m: ndarray)

Bases: AnalyticalResult

AnalyticalResult plus imperfection metrics and per-slice field/torque profiles.

to_dict

to_dict() -> dict[str, float]

Phase-1 keys (stable interface) plus the Phase-2 scalars.

Source code in src/axfluxmdo/models/annular_2p5d.py
78
79
80
81
82
83
def to_dict(self) -> dict[str, float]:
    """Phase-1 keys (stable interface) plus the Phase-2 scalars."""
    return super().to_dict() | {
        "torque_ripple_proxy": self.torque_ripple_proxy,
        "axial_force_n": self.axial_force_n,
    }

AnnularModel

AnnularModel(limits: Limits | None = None, n_slices: int = 32, edge_fringe_length_m: float = 0.0, k_bearing: float = 0.0, k_windage: float = 0.0, carter_factor: float = 1.0)

Layer-2 annular slice model.

Parameters

n_slices : torque/EMF are exact at any count (the flux-linkage sum is additive); only core loss and the saturation constraint are discretized, and both are smooth in radius — 32 slices is well within 0.1 % of converged. edge_fringe_length_m : optional flux derating length at the annulus edges, B1 = (1-exp(-(r-r_i)/L))(1-exp(-(r_o-r)/L)). Zero (default) disables it and preserves exact Phase-1 parity. k_bearing, k_windage : mechanical loss coefficients (see :func:~axfluxmdo.models.losses.mechanical_loss), default zero. carter_factor : multiplies the magnetic gap in the load line (default 1.0 = slotless). Phase-4 FEA measured k_C = 1.44 for the slotted reference motor; see :func:axfluxmdo.validation.measured_carter_factor.

Source code in src/axfluxmdo/models/annular_2p5d.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def __init__(
    self,
    limits: Limits | None = None,
    n_slices: int = 32,
    edge_fringe_length_m: float = 0.0,
    k_bearing: float = 0.0,
    k_windage: float = 0.0,
    carter_factor: float = 1.0,
):
    if n_slices < 1:
        raise ValueError("n_slices must be at least 1")
    self.limits = limits or Limits()
    self.n_slices = n_slices
    self.edge_fringe_length_m = edge_fringe_length_m
    self.k_bearing = k_bearing
    self.k_windage = k_windage
    self.carter_factor = carter_factor

axfluxmdo.models.efficiency_map

Efficiency map over the speed-torque plane.

The Layer-1/2 models have no saturation, so torque is exactly linear in current at fixed geometry (flux linkage is independent of current and speed; the magnet temperature is the fixed Phase-1/2 assumption). One probe evaluation yields torque-per-amp, then each grid cell is a single model.evaluate at the inverted current. Cells whose result violates any constraint are masked NaN, with the first violated constraint recorded.

EfficiencyMap dataclass

EfficiencyMap(speeds_rpm: ndarray, torques_nm: ndarray, efficiency: ndarray, current_rms_a: ndarray, copper_loss_w: ndarray, core_loss_w: ndarray, winding_temp_c: ndarray, feasible: ndarray, binding_constraint: ndarray)

Gridded results over (torque, speed); arrays are (n_torque, n_speed).

compute_efficiency_map

compute_efficiency_map(motor: AxialFluxMotor, base_op: OperatingPoint, *, max_speed_rpm: float, max_torque_nm: float, n_speed: int = 40, n_torque: int = 40, model: Model | None = None) -> EfficiencyMap

Evaluate the motor over a speed-torque grid (bus voltage and ambient from base_op).

Source code in src/axfluxmdo/models/efficiency_map.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def compute_efficiency_map(
    motor: AxialFluxMotor,
    base_op: OperatingPoint,
    *,
    max_speed_rpm: float,
    max_torque_nm: float,
    n_speed: int = 40,
    n_torque: int = 40,
    model: Model | None = None,
) -> EfficiencyMap:
    """Evaluate the motor over a speed-torque grid (bus voltage and ambient from base_op)."""
    if model is None:
        from axfluxmdo.models.annular_2p5d import AnnularModel

        model = AnnularModel()

    # Linear torque-current inversion from one probe evaluation
    probe = model.evaluate(motor, dataclasses.replace(base_op, current_rms=1.0))
    torque_per_amp = probe.torque_nm
    if torque_per_amp <= 0.0:
        raise ValueError("motor produces no torque per amp; check the design")

    # Start one step above zero on both axes (efficiency is degenerate at 0)
    speeds = np.linspace(max_speed_rpm / n_speed, max_speed_rpm, n_speed)
    torques = np.linspace(max_torque_nm / n_torque, max_torque_nm, n_torque)

    shape = (n_torque, n_speed)
    eff = np.full(shape, np.nan)
    current = np.zeros(shape)
    p_cu = np.full(shape, np.nan)
    p_core = np.full(shape, np.nan)
    temp = np.full(shape, np.nan)
    feasible = np.zeros(shape, dtype=bool)
    binding = np.full(shape, "", dtype=object)

    for i, torque in enumerate(torques):
        i_rms = torque / torque_per_amp
        for j, speed in enumerate(speeds):
            r = model.evaluate(
                motor, dataclasses.replace(base_op, speed_rpm=speed, current_rms=i_rms)
            )
            current[i, j] = i_rms
            feasible[i, j] = r.feasible
            if r.feasible:
                eff[i, j] = r.efficiency
                p_cu[i, j] = r.copper_loss_w
                p_core[i, j] = r.core_loss_w
                temp[i, j] = r.winding_temp_c if math.isfinite(r.winding_temp_c) else np.nan
            else:
                binding[i, j] = next(c.name for c in r.constraints if not c.satisfied)

    return EfficiencyMap(
        speeds_rpm=speeds,
        torques_nm=torques,
        efficiency=eff,
        current_rms_a=current,
        copper_loss_w=p_cu,
        core_loss_w=p_core,
        winding_temp_c=temp,
        feasible=feasible,
        binding_constraint=binding,
    )

axfluxmdo.models.losses

Loss components and mass rollup — small pure functions reused by all models.

phase_resistance

phase_resistance(motor: AxialFluxMotor, temp_c: float) -> float

DC phase resistance at winding temperature T: rho(T) * N * L_turn / A_cond.

Source code in src/axfluxmdo/models/losses.py
 9
10
11
12
def phase_resistance(motor: AxialFluxMotor, temp_c: float) -> float:
    """DC phase resistance at winding temperature T: rho(T) * N * L_turn / A_cond."""
    rho = resistivity(motor.conductor, temp_c)
    return rho * motor.turns_per_phase * motor.mean_turn_length / motor.conductor_area

copper_loss

copper_loss(phases: int, current_rms: float, resistance_ohm: float) -> float

Total winding copper loss P_cu = m * I_rms^2 * R_phase.

Source code in src/axfluxmdo/models/losses.py
15
16
17
def copper_loss(phases: int, current_rms: float, resistance_ohm: float) -> float:
    """Total winding copper loss P_cu = m * I_rms^2 * R_phase."""
    return phases * current_rms**2 * resistance_ohm

steinmetz_core_loss

steinmetz_core_loss(motor: AxialFluxMotor, f_hz: float, b_peak_t: float) -> float

Stator core loss: specific Steinmetz loss times stator core mass.

Source code in src/axfluxmdo/models/losses.py
20
21
22
def steinmetz_core_loss(motor: AxialFluxMotor, f_hz: float, b_peak_t: float) -> float:
    """Stator core loss: specific Steinmetz loss times stator core mass."""
    return motor.steel.core_loss_w_per_kg(f_hz, b_peak_t) * stator_core_mass(motor)

mechanical_loss

mechanical_loss(omega_m_rad_s: float, k_bearing: float = 0.0, k_windage: float = 0.0) -> float

Bearing + windage placeholder: k_b * omega + k_w * omega^3 (defaults zero).

Fidelity item for Phase 2; the field exists so the efficiency rollup and result schema do not change later.

Source code in src/axfluxmdo/models/losses.py
25
26
27
28
29
30
31
def mechanical_loss(omega_m_rad_s: float, k_bearing: float = 0.0, k_windage: float = 0.0) -> float:
    """Bearing + windage placeholder: k_b * omega + k_w * omega^3 (defaults zero).

    Fidelity item for Phase 2; the field exists so the efficiency rollup and
    result schema do not change later.
    """
    return k_bearing * omega_m_rad_s + k_windage * omega_m_rad_s**3

stator_core_mass

stator_core_mass(motor: AxialFluxMotor) -> float

Stator yoke lamination mass including stacking factor.

Source code in src/axfluxmdo/models/losses.py
34
35
36
def stator_core_mass(motor: AxialFluxMotor) -> float:
    """Stator yoke lamination mass including stacking factor."""
    return motor.stator_core_volume * motor.steel.stacking_factor * motor.steel.density_kg_m3

core_flux_density_proxy

core_flux_density_proxy(motor: AxialFluxMotor, b_gap_t: float) -> float

Peak stator-yoke flux density proxy for core loss and saturation checks.

Half of one pole's air-gap flux returns through the yoke cross-section (active_length * stator_core_thickness * stacking), giving

B_yoke = B_g * alpha_m * tau_p / (2 * t_core * stacking)
Source code in src/axfluxmdo/models/losses.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def core_flux_density_proxy(motor: AxialFluxMotor, b_gap_t: float) -> float:
    """Peak stator-yoke flux density proxy for core loss and saturation checks.

    Half of one pole's air-gap flux returns through the yoke cross-section
    (active_length * stator_core_thickness * stacking), giving

        B_yoke = B_g * alpha_m * tau_p / (2 * t_core * stacking)
    """
    return (
        b_gap_t
        * motor.magnet_arc_ratio
        * motor.pole_pitch
        / (2.0 * motor.stator_core_thickness * motor.steel.stacking_factor)
    )

mass_rollup

mass_rollup(motor: AxialFluxMotor) -> dict[str, float]

Component masses in kg; 'total' includes the structure factor.

Source code in src/axfluxmdo/models/losses.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def mass_rollup(motor: AxialFluxMotor) -> dict[str, float]:
    """Component masses in kg; 'total' includes the structure factor."""
    magnets = motor.magnet_volume * motor.magnet.density_kg_m3
    back_iron = motor.back_iron_volume * motor.steel.density_kg_m3
    stator_core = stator_core_mass(motor)
    copper = motor.copper_volume * motor.conductor.density_kg_m3
    active = magnets + back_iron + stator_core + copper
    structure = motor.structure_mass_factor * active
    return {
        "magnets": magnets,
        "back_iron": back_iron,
        "stator_core": stator_core,
        "copper": copper,
        "structure": structure,
        "total": active + structure,
    }

axfluxmdo.models.thermal_rc

Steady-state lumped thermal network (single winding-to-ambient resistance).

Copper loss rises linearly with winding temperature through rho(T), so the steady-state fixed point

T_w = T_amb + R_theta * (P_cu(T_w) + P_other)
P_cu(T) = P_cu_ref * (1 + alpha * (T - T_ref))

has the closed-form solution

T_w = [T_amb + R_theta * (P_cu_ref * (1 - alpha * T_ref) + P_other)]
      / [1 - R_theta * P_cu_ref * alpha]

Thermal runaway occurs when the denominator is non-positive: each kelvin of temperature rise adds more than a kelvin's worth of extra copper loss.

solve_winding_temperature

solve_winding_temperature(p_cu_ref_w: float, ref_temp_c: float, alpha_per_c: float, p_other_w: float, r_theta_k_per_w: float, ambient_c: float) -> ThermalSolution

Closed-form steady-state winding temperature with R(T) coupling.

p_cu_ref_w: copper loss evaluated at ref_temp_c. p_other_w: other losses deposited in the winding node (e.g. a fraction of core loss), assumed temperature-independent.

Source code in src/axfluxmdo/models/thermal_rc.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def solve_winding_temperature(
    p_cu_ref_w: float,
    ref_temp_c: float,
    alpha_per_c: float,
    p_other_w: float,
    r_theta_k_per_w: float,
    ambient_c: float,
) -> ThermalSolution:
    """Closed-form steady-state winding temperature with R(T) coupling.

    p_cu_ref_w: copper loss evaluated at ref_temp_c.
    p_other_w: other losses deposited in the winding node (e.g. a fraction of
        core loss), assumed temperature-independent.
    """
    denominator = 1.0 - r_theta_k_per_w * p_cu_ref_w * alpha_per_c
    if denominator <= 0.0:
        return ThermalSolution(winding_temp_c=math.inf, copper_loss_w=math.inf, runaway=True)
    numerator = ambient_c + r_theta_k_per_w * (
        p_cu_ref_w * (1.0 - alpha_per_c * ref_temp_c) + p_other_w
    )
    t_w = numerator / denominator
    p_cu = p_cu_ref_w * (1.0 + alpha_per_c * (t_w - ref_temp_c))
    return ThermalSolution(winding_temp_c=t_w, copper_loss_w=p_cu, runaway=False)

solve_winding_temperature_iterative

solve_winding_temperature_iterative(p_cu_ref_w: float, ref_temp_c: float, alpha_per_c: float, p_other_w: float, r_theta_k_per_w: float, ambient_c: float, iterations: int = 50) -> float

Fixed-point iteration reference implementation (used to verify the closed form).

Source code in src/axfluxmdo/models/thermal_rc.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def solve_winding_temperature_iterative(
    p_cu_ref_w: float,
    ref_temp_c: float,
    alpha_per_c: float,
    p_other_w: float,
    r_theta_k_per_w: float,
    ambient_c: float,
    iterations: int = 50,
) -> float:
    """Fixed-point iteration reference implementation (used to verify the closed form)."""
    t_w = ambient_c
    for _ in range(iterations):
        p_cu = p_cu_ref_w * (1.0 + alpha_per_c * (t_w - ref_temp_c))
        t_w = ambient_c + r_theta_k_per_w * (p_cu + p_other_w)
    return t_w

axfluxmdo.models.constraints

Constraint evaluation records.

Constraint names deliberately match the flat keys of AnalyticalResult.to_dict() (e.g. winding_temp_c) so that Phase 3's optimize_pareto can parse SPEC-style constraint strings like "winding_temp_c < 140" against result dictionaries.

make_upper_bound

make_upper_bound(name: str, value: float, limit: float) -> ConstraintRecord

Build a '<=' constraint record with normalized margin.

Source code in src/axfluxmdo/models/constraints.py
32
33
34
35
36
37
38
39
40
41
def make_upper_bound(name: str, value: float, limit: float) -> ConstraintRecord:
    """Build a '<=' constraint record with normalized margin."""
    if math.isinf(value):
        return ConstraintRecord(
            name=name, value=value, limit=limit, satisfied=False, margin=-math.inf
        )
    margin = (limit - value) / abs(limit) if limit != 0.0 else -value
    return ConstraintRecord(
        name=name, value=value, limit=limit, satisfied=value <= limit, margin=margin
    )

axfluxmdo.models.base

Shared model interface.

Model

Bases: Protocol

Anything with an evaluate(motor, op) -> AnalyticalResult-compatible method.