Skip to content

axfluxmdo.optimize

Requires the [opt] extra for the pymoo / OpenMDAO / scikit-learn backends; DesignProblem and DesignDataset work with the core install.

axfluxmdo.optimize.problem

Backend-agnostic optimization problem specification.

This module is the single source of truth for parsing the SPEC-style optimization grammar against the stable AnalyticalResult.to_dict() keys:

  • variables: {"outer_radius": (0.05, 0.12), "pole_pairs": [8, 10, 12]} — 2-tuples are continuous bounds (or integer bounds when the motor field is an int), lists are discrete choices; dotted paths reach nested fields ("tolerances.runout_m").
  • objectives: "maximize_torque_density" / "minimize_mass" — the suffix resolves through the alias map to a to_dict() key.
  • constraints: "winding_temp_c < 140" — parsed to the pymoo/OpenMDAO g <= 0 convention. The model's built-in ConstraintRecords are enforced in addition by default, so optimizer output is always result.feasible.

Design vectors that violate geometric validation (frozen-dataclass __post_init__ ValueError, e.g. inner_radius >= outer_radius) are penalized with large finite objective/constraint values rather than raising — GA constraint-domination buries them and gradient drivers stay numeric.

Only numpy is required here; pymoo/OpenMDAO backends import this module, not the other way around.

UnknownKeyError

Bases: ValueError

A user-facing name did not resolve to any result key or alias.

UserConstraint dataclass

UserConstraint(key: str, op: str, bound: float, label: str)

violation

violation(value: float) -> float

g <= 0 convention: positive means violated.

Source code in src/axfluxmdo/optimize/problem.py
91
92
93
94
95
96
97
def violation(self, value: float) -> float:
    """g <= 0 convention: positive means violated."""
    if not math.isfinite(value):
        return MARGIN_CLAMP
    if self.op in ("<", "<="):
        return value - self.bound
    return self.bound - value

EvalRecord dataclass

EvalRecord(f_min: ndarray, g: ndarray, result: AnalyticalResult | None, motor: AxialFluxMotor | None)

One design evaluation in optimizer space.

DesignProblem

DesignProblem(motor: AxialFluxMotor, op: OperatingPoint, *, variables: Mapping[str, object], objectives: Sequence[str], constraints: Sequence[str] = (), model: Model | None = None, enforce_model_constraints: bool = True)

Validated design variables / objectives / constraints around a seed motor.

Source code in src/axfluxmdo/optimize/problem.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def __init__(
    self,
    motor: AxialFluxMotor,
    op: OperatingPoint,
    *,
    variables: Mapping[str, object],
    objectives: Sequence[str],
    constraints: Sequence[str] = (),
    model: Model | None = None,
    enforce_model_constraints: bool = True,
):
    self.motor = motor
    self.op = op
    self.model = model or AnalyticalModel()
    self.enforce_model_constraints = enforce_model_constraints

    # Baseline evaluation defines the available result keys and sanity-checks
    # that the seed design is evaluable.
    self.baseline_result = self.model.evaluate(motor, op)
    available = self.baseline_result.to_dict().keys()

    self.continuous: dict[str, tuple[float, float]] = {}
    self.integer: dict[str, tuple[int, int]] = {}
    self.choices: dict[str, list] = {}
    for name, spec in variables.items():
        baseline_value = self._baseline_value(name)  # validates the field path
        if isinstance(spec, tuple) and len(spec) == 2:
            lo, hi = spec
            if lo >= hi:
                raise ValueError(f"variable {name!r}: lower bound must be < upper bound")
            if isinstance(baseline_value, int) and not isinstance(baseline_value, bool):
                self.integer[name] = (int(lo), int(hi))
            else:
                self.continuous[name] = (float(lo), float(hi))
        elif isinstance(spec, (list, range, np.ndarray)):
            options = list(spec)
            if not options:
                raise ValueError(f"variable {name!r}: empty choice list")
            self.choices[name] = options
        else:
            raise ValueError(
                f"variable {name!r}: spec must be a (low, high) tuple or a list of "
                f"choices, got {spec!r}"
            )

    self.objectives = [parse_objective(s, available) for s in objectives]
    if not self.objectives:
        raise ValueError("at least one objective is required")
    self.user_constraints = [parse_constraint(s, available) for s in constraints]

    self.model_constraint_names = (
        [c.name for c in self.baseline_result.constraints] if enforce_model_constraints else []
    )

apply

apply(x: Mapping[str, object]) -> AxialFluxMotor

Build the motor variant for a design vector; may raise ValueError.

Source code in src/axfluxmdo/optimize/problem.py
233
234
235
236
237
238
239
240
241
242
def apply(self, x: Mapping[str, object]) -> AxialFluxMotor:
    """Build the motor variant for a design vector; may raise ValueError."""
    motor = self.motor
    for name, value in x.items():
        if name in self.integer or name in self.choices:
            baseline = self._baseline_value(name)
            if isinstance(baseline, int) and not isinstance(value, int):
                value = int(round(float(value)))  # noqa: PLW2901
        motor = replace_field(motor, name, value)
    return motor

objective_values

objective_values(result: AnalyticalResult) -> np.ndarray

Human-readable (un-negated) objective values.

Source code in src/axfluxmdo/optimize/problem.py
244
245
246
247
def objective_values(self, result: AnalyticalResult) -> np.ndarray:
    """Human-readable (un-negated) objective values."""
    d = result.to_dict()
    return np.array([d[obj.name] for obj in self.objectives])

resolve_key

resolve_key(name: str, available: Collection[str]) -> str

Resolve a user-facing name (alias or canonical) to a to_dict() key.

Source code in src/axfluxmdo/optimize/problem.py
68
69
70
71
72
73
74
def resolve_key(name: str, available: Collection[str]) -> str:
    """Resolve a user-facing name (alias or canonical) to a to_dict() key."""
    candidate = ALIASES.get(name, name)
    if candidate in available:
        return candidate
    options = sorted(set(available) | set(ALIASES))
    raise UnknownKeyError(f"unknown result key {name!r}; available keys and aliases: {options}")

parse_objective

parse_objective(spec: str, available: Collection[str]) -> Objective

Parse 'maximize_' / 'minimize_' (key may be an alias).

Source code in src/axfluxmdo/optimize/problem.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def parse_objective(spec: str, available: Collection[str]) -> Objective:
    """Parse 'maximize_<key>' / 'minimize_<key>' (key may be an alias)."""
    if spec.startswith("maximize_"):
        sense, raw = +1, spec[len("maximize_") :]
    elif spec.startswith("minimize_"):
        sense, raw = -1, spec[len("minimize_") :]
    else:
        raise ValueError(
            f"objective {spec!r} must start with 'maximize_' or 'minimize_' "
            f"(e.g. 'maximize_torque_density')"
        )
    key = resolve_key(raw, available)
    if key == "feasible":
        raise ValueError("'feasible' is not a valid objective; constraints handle feasibility")
    return Objective(name=key, sense=sense, label=spec)

parse_constraint

parse_constraint(spec: str, available: Collection[str]) -> UserConstraint

Parse ' < bound' style constraint strings (<=, >=, <, > supported).

Source code in src/axfluxmdo/optimize/problem.py
117
118
119
120
121
122
123
124
125
126
127
def parse_constraint(spec: str, available: Collection[str]) -> UserConstraint:
    """Parse '<key> < bound' style constraint strings (<=, >=, <, > supported)."""
    match = _CONSTRAINT_RE.match(spec)
    if match is None:
        raise ValueError(
            f"cannot parse constraint {spec!r}; expected '<key> < bound' with one of <, <=, >, >="
        )
    raw_key, op, bound = match.groups()
    return UserConstraint(
        key=resolve_key(raw_key, available), op=op, bound=float(bound), label=spec
    )

axfluxmdo.optimize.pymoo_runner

Pareto-front optimization via pymoo's mixed-variable GA.

pymoo is imported lazily so the base package works without the [opt] extra. Mixed continuous/integer/choice design variables use MixedVariableGA with rank-and-crowding survival (the multi-objective configuration recommended by pymoo 0.6.x for mixed search spaces) — never NSGA2-with-rounding, which breaks Choice semantics and duplicate elimination.

ParetoStudy dataclass

ParetoStudy(variables: list[str], objectives: list[Objective], X: list[dict], F: ndarray, results: list[AnalyticalResult], motors: list[AxialFluxMotor], seed: int, pop_size: int, n_gen: int, problem: DesignProblem)

Feasible non-dominated designs from one optimize_pareto run.

F holds human-readable objective values (maximize objectives are NOT negated here; the sign convention lives only inside the optimizer). Every point satisfies the user constraints and result.feasible.

to_records

to_records() -> list[dict]

One flat dict per point: design variables merged with result.to_dict().

Source code in src/axfluxmdo/optimize/pymoo_runner.py
57
58
59
def to_records(self) -> list[dict]:
    """One flat dict per point: design variables merged with result.to_dict()."""
    return [x | r.to_dict() for x, r in zip(self.X, self.results, strict=True)]

to_arrays

to_arrays(*fields_: str) -> dict[str, np.ndarray]

Extract named fields (aliases, to_dict keys, or design variables) as arrays.

Source code in src/axfluxmdo/optimize/pymoo_runner.py
61
62
63
64
65
66
67
68
69
def to_arrays(self, *fields_: str) -> dict[str, np.ndarray]:
    """Extract named fields (aliases, to_dict keys, or design variables) as arrays."""
    records = self.to_records()
    available = records[0].keys()
    out = {}
    for name in fields_:
        key = name if name in available else resolve_key(name, available)
        out[name] = np.array([rec[key] for rec in records])
    return out

best

best(objective: str) -> int

Index of the best point for one objective (alias-aware).

'Best' respects the study's sense for that key if it is an objective (max for maximize_*), otherwise defaults to maximum.

Source code in src/axfluxmdo/optimize/pymoo_runner.py
71
72
73
74
75
76
77
78
79
80
81
def best(self, objective: str) -> int:
    """Index of the best point for one objective (alias-aware).

    'Best' respects the study's sense for that key if it is an objective
    (max for maximize_*), otherwise defaults to maximum.
    """
    records = self.to_records()
    key = objective if objective in records[0] else resolve_key(objective, records[0].keys())
    values = np.array([rec[key] for rec in records])
    sense = next((obj.sense for obj in self.objectives if obj.name == key), +1)
    return int(np.argmax(sense * values))

optimize_pareto

optimize_pareto(motor: AxialFluxMotor, op: OperatingPoint, *, variables: Mapping[str, object], objectives: Sequence[str], constraints: Sequence[str] = (), model: Model | None = None, algorithm=None, pop_size: int = 60, n_gen: int = 40, seed: int = 1, verbose: bool = False, enforce_model_constraints: bool = True) -> ParetoStudy

Multi-objective Pareto optimization of the motor design (SPEC flagship API).

Note: unlike the SPEC sketch, the operating point is an explicit argument — every objective (torque, efficiency, winding temperature) depends on it.

Source code in src/axfluxmdo/optimize/pymoo_runner.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def optimize_pareto(
    motor: AxialFluxMotor,
    op: OperatingPoint,
    *,
    variables: Mapping[str, object],
    objectives: Sequence[str],
    constraints: Sequence[str] = (),
    model: Model | None = None,
    algorithm=None,
    pop_size: int = 60,
    n_gen: int = 40,
    seed: int = 1,
    verbose: bool = False,
    enforce_model_constraints: bool = True,
) -> ParetoStudy:
    """Multi-objective Pareto optimization of the motor design (SPEC flagship API).

    Note: unlike the SPEC sketch, the operating point is an explicit argument —
    every objective (torque, efficiency, winding temperature) depends on it.
    """
    _require_pymoo()
    from pymoo.core.mixed import MixedVariableGA
    from pymoo.core.problem import ElementwiseProblem
    from pymoo.core.variable import Choice, Integer, Real
    from pymoo.optimize import minimize

    try:
        from pymoo.operators.survival.rank_and_crowding import RankAndCrowding
    except ImportError:  # pragma: no cover - older pymoo layout
        from pymoo.algorithms.moo.nsga2 import RankAndCrowdingSurvival as RankAndCrowding

    design = DesignProblem(
        motor,
        op,
        variables=variables,
        objectives=objectives,
        constraints=constraints,
        model=model,
        enforce_model_constraints=enforce_model_constraints,
    )

    pymoo_vars: dict[str, object] = {}
    for name, (lo, hi) in design.continuous.items():
        pymoo_vars[name] = Real(bounds=(lo, hi))
    for name, (lo, hi) in design.integer.items():
        pymoo_vars[name] = Integer(bounds=(lo, hi))
    for name, options in design.choices.items():
        pymoo_vars[name] = Choice(options=options)

    class _MotorProblem(ElementwiseProblem):
        def __init__(self):
            super().__init__(vars=pymoo_vars, n_obj=design.n_obj, n_ieq_constr=design.n_constr)

        def _evaluate(self, x, out, *args, **kwargs):
            record = design.evaluate(x)
            out["F"] = record.f_min
            out["G"] = record.g

    if algorithm is None:
        algorithm = MixedVariableGA(pop_size=pop_size, survival=RankAndCrowding())

    res = minimize(_MotorProblem(), algorithm, ("n_gen", n_gen), seed=seed, verbose=verbose)

    if res.X is None:
        raise RuntimeError(
            "optimize_pareto found no feasible designs; relax the constraints, widen "
            "the variable bounds, or increase pop_size/n_gen"
        )
    xs = [dict(x) for x in np.atleast_1d(res.X)]

    # Re-evaluate the front for full results; keep strictly feasible points only.
    points = []
    for x in xs:
        record = design.evaluate(x)
        if record.result is not None and record.result.feasible and np.all(record.g <= 0):
            points.append((x, record))
    if not points:
        raise RuntimeError(
            "optimize_pareto found no feasible designs after re-evaluation; relax the "
            "constraints or increase the budget"
        )

    return ParetoStudy(
        variables=design.variable_names,
        objectives=design.objectives,
        X=[x for x, _ in points],
        F=np.array([design.objective_values(rec.result) for _, rec in points]),
        results=[rec.result for _, rec in points],
        motors=[rec.motor for _, rec in points],
        seed=seed,
        pop_size=pop_size,
        n_gen=n_gen,
        problem=design,
    )

axfluxmdo.optimize.sensitivity

One-at-a-time design sensitivities for tornado charts.

compute_sensitivities

compute_sensitivities(motor: AxialFluxMotor, op: OperatingPoint, variables: Sequence[str] | Mapping[str, object], *, output: str = 'torque_density_nm_kg', rel_step: float = 0.05, model: Model | None = None) -> SensitivityResult

One-at-a-time low/high perturbations of each variable (tornado-chart input).

variables may be a sequence of field names (perturbed by ±rel_step of the baseline value) or an optimize-style dict: tuple bounds clamp the perturbations, choice lists step one option below/above the baseline's nearest choice. Integer fields are rounded and de-duplicated. A perturbation that produces invalid geometry is skipped with a warning (the baseline output stands in for that side).

Source code in src/axfluxmdo/optimize/sensitivity.py
37
38
39
40
41
42
43
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
def compute_sensitivities(
    motor: AxialFluxMotor,
    op: OperatingPoint,
    variables: Sequence[str] | Mapping[str, object],
    *,
    output: str = "torque_density_nm_kg",
    rel_step: float = 0.05,
    model: Model | None = None,
) -> SensitivityResult:
    """One-at-a-time low/high perturbations of each variable (tornado-chart input).

    ``variables`` may be a sequence of field names (perturbed by ±rel_step of
    the baseline value) or an optimize-style dict: tuple bounds clamp the
    perturbations, choice lists step one option below/above the baseline's
    nearest choice. Integer fields are rounded and de-duplicated. A
    perturbation that produces invalid geometry is skipped with a warning
    (the baseline output stands in for that side).
    """
    model = model or AnalyticalModel()
    baseline_result = model.evaluate(motor, op)
    key = resolve_key(output, baseline_result.to_dict().keys())
    baseline = baseline_result.to_dict()[key]

    specs: dict[str, object | None]
    if isinstance(variables, Mapping):
        specs = dict(variables)
    else:
        specs = {name: None for name in variables}

    entries = []
    for name, spec in specs.items():
        base_value = _get_field(motor, name)
        low_in, high_in = _perturbations(base_value, spec, rel_step)
        if low_in == high_in:
            warnings.warn(f"variable {name!r}: no usable perturbation; skipped", stacklevel=2)
            continue
        low_out = _evaluate_side(model, motor, op, name, low_in, key, baseline)
        high_out = _evaluate_side(model, motor, op, name, high_in, key, baseline)
        entries.append(
            SensitivityEntry(
                variable=name,
                low_input=float(low_in),
                high_input=float(high_in),
                low_output=low_out,
                high_output=high_out,
            )
        )

    entries.sort(key=lambda e: abs(e.swing), reverse=True)
    return SensitivityResult(output=key, baseline=baseline, entries=entries)

axfluxmdo.optimize.dataset

Dataset of evaluated designs (numpy-only; usable without the [opt] extra).

Each record pairs a design vector x (dict over the declared variables) with the flat outputs dict of its evaluation (result.to_dict(), plus any extra keys such as an expensive-FEA objective).

Feature encoding for surrogates is ordinal-as-float: continuous and integer variables are cast to float; Choice variables use the option VALUE when numeric (e.g. pole_pairs — an ordered physical quantity) and the option INDEX otherwise. One-hot encoding for genuinely unordered categoricals is documented future work; the GP's per-dimension ARD length scales absorb the differing column scales.

Persistence is JSON Lines with a versioned header (axfluxmdo-dataset-v1) — dependency-free, append-friendly, git-diffable; these datasets are O(10^2..10^3) rows, so columnar formats buy nothing.

DesignDataset

DesignDataset(variable_names: Sequence[str], *, choices: Mapping[str, list] | None = None)

Ordered collection of (design vector, evaluation outputs) records.

Source code in src/axfluxmdo/optimize/dataset.py
40
41
42
43
44
45
46
47
48
def __init__(
    self,
    variable_names: Sequence[str],
    *,
    choices: Mapping[str, list] | None = None,
):
    self.variable_names = list(variable_names)
    self.choices = {k: list(v) for k, v in (choices or {}).items()}
    self.records: list[dict] = []

from_study classmethod

from_study(study: ParetoStudy) -> DesignDataset

Wrap a ParetoStudy's points without re-evaluating anything.

Source code in src/axfluxmdo/optimize/dataset.py
52
53
54
55
56
57
58
@classmethod
def from_study(cls, study: ParetoStudy) -> DesignDataset:
    """Wrap a ParetoStudy's points without re-evaluating anything."""
    ds = cls(study.variables, choices=study.problem.choices)
    for x, result in zip(study.X, study.results, strict=True):
        ds.append(x, result.to_dict())
    return ds

from_evaluations classmethod

from_evaluations(problem: DesignProblem, xs: Iterable[Mapping[str, object]]) -> DesignDataset

Evaluate each design with the problem's model and record it.

Source code in src/axfluxmdo/optimize/dataset.py
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def from_evaluations(
    cls, problem: DesignProblem, xs: Iterable[Mapping[str, object]]
) -> DesignDataset:
    """Evaluate each design with the problem's model and record it."""
    ds = cls(problem.variable_names, choices=problem.choices)
    for x in xs:
        record = problem.evaluate(x)
        if record.result is not None:
            ds.append(x, record.result.to_dict())
    return ds

encode

encode(x: Mapping[str, object]) -> np.ndarray

One design dict -> feature row (fixed variable order).

Source code in src/axfluxmdo/optimize/dataset.py
 92
 93
 94
 95
 96
 97
 98
 99
100
def encode(self, x: Mapping[str, object]) -> np.ndarray:
    """One design dict -> feature row (fixed variable order)."""
    row = []
    for name in self.variable_names:
        value = x[name]
        if name in self.choices and not isinstance(value, (int, float)):
            value = self.choices[name].index(value)
        row.append(float(value))
    return np.array(row)

feature_matrix

feature_matrix() -> np.ndarray

(n, d) float matrix, columns in variable_names order.

Source code in src/axfluxmdo/optimize/dataset.py
102
103
104
105
106
def feature_matrix(self) -> np.ndarray:
    """(n, d) float matrix, columns in ``variable_names`` order."""
    if not self.records:
        return np.empty((0, len(self.variable_names)))
    return np.vstack([self.encode(rec["x"]) for rec in self.records])

to_arrays

to_arrays(y: str) -> tuple[np.ndarray, np.ndarray]

(X, y) for surrogate fitting; y accepts aliases or output keys.

Source code in src/axfluxmdo/optimize/dataset.py
108
109
110
111
112
113
114
def to_arrays(self, y: str) -> tuple[np.ndarray, np.ndarray]:
    """(X, y) for surrogate fitting; ``y`` accepts aliases or output keys."""
    if not self.records:
        raise ValueError("dataset is empty")
    available = self.records[0]["outputs"].keys()
    key = y if y in available else resolve_key(y, available)
    return self.feature_matrix(), np.array([rec["outputs"][key] for rec in self.records])

dedupe

dedupe(*, tol: float = 1e-12) -> DesignDataset

Drop records whose feature rows duplicate an earlier one (keep first).

Source code in src/axfluxmdo/optimize/dataset.py
116
117
118
119
120
121
122
123
124
125
126
def dedupe(self, *, tol: float = 1e-12) -> DesignDataset:
    """Drop records whose feature rows duplicate an earlier one (keep first)."""
    out = DesignDataset(self.variable_names, choices=self.choices)
    seen: list[np.ndarray] = []
    for rec in self.records:
        row = self.encode(rec["x"])
        if any(np.all(np.abs(row - s) <= tol) for s in seen):
            continue
        seen.append(row)
        out.records.append(rec)
    return out

axfluxmdo.optimize.surrogate

Gaussian-process and ensemble surrogates over design datasets.

scikit-learn is imported at module level here, but this module is only reachable lazily from axfluxmdo.optimize (PEP 562), so the base package and import axfluxmdo.optimize stay sklearn-free (test-enforced).

The GP is the primary surrogate: Matern(nu=2.5) with PER-DIMENSION (ARD) length scales — mandatory for the mixed ordinal/continuous feature encoding — plus a WhiteKernel jitter. Trustworthiness is judged by cross-validation (cv_rmse/cv_r2), not by sklearn's ConvergenceWarning (suppressed: it fires routinely on small-n fits).

GPSurrogate

GPSurrogate(*, nu: float = 2.5, n_restarts: int = 5, random_state: int = 0, noise_level: float = 1e-06)

Gaussian-process surrogate with ARD Matern kernel and CV diagnostics.

Source code in src/axfluxmdo/optimize/surrogate.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def __init__(
    self,
    *,
    nu: float = 2.5,
    n_restarts: int = 5,
    random_state: int = 0,
    noise_level: float = 1e-6,
):
    self.nu = nu
    self.n_restarts = n_restarts
    self.random_state = random_state
    self.noise_level = noise_level
    self._gp: GaussianProcessRegressor | None = None
    self._x_mean: np.ndarray | None = None
    self._x_std: np.ndarray | None = None
    self._X: np.ndarray | None = None
    self._y: np.ndarray | None = None

predict_dict

predict_dict(x: Mapping[str, object], dataset: DesignDataset) -> tuple[float, float]

Predict at one design dict using the dataset's feature encoding.

Source code in src/axfluxmdo/optimize/surrogate.py
100
101
102
103
def predict_dict(self, x: Mapping[str, object], dataset: DesignDataset) -> tuple[float, float]:
    """Predict at one design dict using the dataset's feature encoding."""
    mean, std = self.predict(dataset.encode(x).reshape(1, -1))
    return float(mean[0]), float(std[0])

cv_rmse

cv_rmse(k: int = 5) -> float

Seeded k-fold RMSE on the training data — the honest trust signal.

Source code in src/axfluxmdo/optimize/surrogate.py
105
106
107
def cv_rmse(self, k: int = 5) -> float:
    """Seeded k-fold RMSE on the training data — the honest trust signal."""
    return self._cross_validate(k)[0]

RandomForestSurrogate

RandomForestSurrogate(*, n_estimators: int = 200, random_state: int = 0)

Ensemble surrogate: per-tree spread as the uncertainty estimate.

The documented fallback for non-smooth responses; satisfies the same Surrogate protocol as the GP.

Source code in src/axfluxmdo/optimize/surrogate.py
143
144
def __init__(self, *, n_estimators: int = 200, random_state: int = 0):
    self._rf = RandomForestRegressor(n_estimators=n_estimators, random_state=random_state)

axfluxmdo.optimize.bayesopt

Bayesian optimization for expensive design evaluations.

The loop reuses the Phase-3 :class:~axfluxmdo.optimize.problem.DesignProblem wholesale (search space, objective/constraint parsing, geometry penalization). The intended use case is an EXPENSIVE objective — e.g. a GetDP solve via expensive_fn — but the default path evaluates the cheap analytical model so tests and examples run anywhere.

Mechanics (v1, documented tradeoffs):

  • Initial design: scipy Latin hypercube over continuous/integer variables (integers rounded, deduped, topped up), seeded random draws for choices.
  • Acquisition: closed-form expected improvement in MINIMIZE space, maximized over a seeded candidate pool (half uniform, half perturbations of the incumbent) — derivative-free and correct for mixed spaces; gradient multistart is future work.
  • Constraints: evaluated by the cheap model's g-vector at every evaluated point. The incumbent is the best FEASIBLE point. The surrogate trains on all points, with infeasible ones assigned a SOFT penalty (worst feasible + 10% of the feasible range) — never the 1e9 geometry penalty, which would destroy GP smoothness. Probability-of-feasibility classifiers are future work.

Human-sign convention: BOStudy reports objective values with their natural sign (same invariant as ParetoStudy.F); minimize-space values live only inside this module.

BOStudy dataclass

BOStudy(objective: Objective, dataset: DesignDataset, X: list[dict], y: ndarray, feasible: ndarray, history: ndarray, best_x: dict, best_value: float, best_result: AnalyticalResult | None, best_motor: AxialFluxMotor | None, surrogate: Surrogate, n_initial: int, n_iterations: int, seed: int, problem: DesignProblem)

Result of one Bayesian-optimization run (human-sign objective values).

recommend

recommend(k: int = 5, *, risk_aversion: float = 1.0) -> list[dict]

Top-k feasible evaluated designs ranked by surrogate-pessimistic value.

Score = mean − risk_aversion·σ for maximize objectives (the reverse for minimize). Ranks only EVALUATED (verified-feasible) designs — uncertainty-aware without certifying unevaluated space.

Source code in src/axfluxmdo/optimize/bayesopt.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def recommend(self, k: int = 5, *, risk_aversion: float = 1.0) -> list[dict]:
    """Top-k feasible evaluated designs ranked by surrogate-pessimistic value.

    Score = mean − risk_aversion·σ for maximize objectives (the reverse for
    minimize). Ranks only EVALUATED (verified-feasible) designs —
    uncertainty-aware without certifying unevaluated space.
    """
    entries = []
    for i, x in enumerate(self.X):
        if not self.feasible[i]:
            continue
        mean, std = self.surrogate.predict(self.dataset.encode(x).reshape(1, -1))
        mean_h = self.objective.sense * -float(mean[0])  # minimize-space -> human
        if self.objective.sense > 0:
            score = mean_h - risk_aversion * float(std[0])
        else:
            score = mean_h + risk_aversion * float(std[0])
        entries.append(
            {
                "x": x,
                "observed": float(self.y[i]),
                "predicted_mean": mean_h,
                "predicted_std": float(std[0]),
                "score": score,
            }
        )
    reverse = self.objective.sense > 0
    entries.sort(key=lambda e: e["score"], reverse=reverse)
    return entries[:k]

bayesian_optimize

bayesian_optimize(motor: AxialFluxMotor, op: OperatingPoint, *, variables: Mapping[str, object], objective: str, constraints: Sequence[str] = (), model: Model | None = None, expensive_fn: Callable[[AxialFluxMotor, OperatingPoint], float | dict] | None = None, n_initial: int = 10, n_iterations: int = 25, seed: int = 1, xi: float = 0.01, n_candidates: int = 2048, surrogate: Surrogate | None = None, enforce_model_constraints: bool = True) -> BOStudy

Single-objective Bayesian optimization over the motor design space.

Source code in src/axfluxmdo/optimize/bayesopt.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def bayesian_optimize(
    motor: AxialFluxMotor,
    op: OperatingPoint,
    *,
    variables: Mapping[str, object],
    objective: str,
    constraints: Sequence[str] = (),
    model: Model | None = None,
    expensive_fn: Callable[[AxialFluxMotor, OperatingPoint], float | dict] | None = None,
    n_initial: int = 10,
    n_iterations: int = 25,
    seed: int = 1,
    xi: float = 0.01,
    n_candidates: int = 2048,
    surrogate: Surrogate | None = None,
    enforce_model_constraints: bool = True,
) -> BOStudy:
    """Single-objective Bayesian optimization over the motor design space."""
    rng = np.random.default_rng(seed)

    # Objective parsing: through the problem when the key is a model output;
    # manual prefix parsing when expensive_fn supplies a novel key.
    problem_objective, objective_obj = _resolve_objective(
        motor, op, variables, objective, constraints, model, enforce_model_constraints
    )
    problem = problem_objective
    obj = objective_obj

    dataset = DesignDataset(problem.variable_names, choices=problem.choices)

    xs: list[dict] = []
    y_min: list[float] = []  # minimize-space objective values
    g_ok: list[bool] = []
    results: list[AnalyticalResult | None] = []
    motors: list[AxialFluxMotor | None] = []

    def evaluate(x: dict) -> None:
        record = problem.evaluate(x)
        feasible = record.result is not None and bool(np.all(record.g <= 0))
        outputs = dict(record.result.to_dict()) if record.result is not None else {}
        if expensive_fn is not None and record.motor is not None:
            raw = expensive_fn(record.motor, op)
            extra = raw if isinstance(raw, Mapping) else {obj.name: float(raw)}
            outputs |= {k: float(v) for k, v in extra.items()}
        if obj.name in outputs:
            value_min = -outputs[obj.name] if obj.sense > 0 else outputs[obj.name]
        else:  # invalid geometry with expensive_fn never called
            value_min = math.inf
            feasible = False
        xs.append(x)
        y_min.append(value_min)
        g_ok.append(feasible)
        results.append(record.result)
        motors.append(record.motor)
        if outputs:
            dataset.append(x, outputs)

    for x in _initial_design(problem, n_initial, rng):
        evaluate(x)

    surr = surrogate or GPSurrogate(random_state=seed)

    for _ in range(n_iterations):
        X_train, y_train = _training_targets(dataset, xs, y_min, g_ok)
        surr.fit(X_train, y_train)
        incumbent = _incumbent_value(y_min, g_ok)
        candidates = _candidate_pool(problem, xs, y_min, g_ok, n_candidates, rng)
        if not candidates:
            break
        encoded = np.vstack([dataset.encode(c) for c in candidates])
        mean, std = surr.predict(encoded)
        evaluate(candidates[int(np.argmax(_expected_improvement(mean, std, incumbent, xi)))])

    # Final fit on everything for the returned surrogate / recommendations
    X_train, y_train = _training_targets(dataset, xs, y_min, g_ok)
    surr.fit(X_train, y_train)

    y_human = np.array([(-v if obj.sense > 0 else v) for v in y_min])
    feasible = np.array(g_ok)
    history = _history(y_min, g_ok, obj.sense)

    if not feasible.any():
        raise RuntimeError(
            "bayesian_optimize found no feasible designs; relax the constraints, "
            "widen the bounds, or increase n_initial/n_iterations"
        )
    best_idx = int(np.argmin(np.where(feasible, y_min, np.inf)))

    return BOStudy(
        objective=obj,
        dataset=dataset,
        X=xs,
        y=y_human,
        feasible=feasible,
        history=history,
        best_x=xs[best_idx],
        best_value=float(y_human[best_idx]),
        best_result=results[best_idx],
        best_motor=motors[best_idx],
        surrogate=surr,
        n_initial=n_initial,
        n_iterations=n_iterations,
        seed=seed,
        problem=problem,
    )

axfluxmdo.optimize.openmdao_components

OpenMDAO integration: the motor model as an ExplicitComponent.

pymoo handles multi-objective Pareto exploration; this layer exposes the motor model to OpenMDAO's gradient-based drivers and to larger coupled MDO groups (the SPEC's system-integration story). Discrete design variables (integer fields, choice lists) are frozen at their baseline values here — gradient drivers cannot move them; use optimize_pareto for those.

This module imports openmdao.api at import time (the component must subclass om.ExplicitComponent), so it is exposed from axfluxmdo.optimize only through lazy PEP 562 __getattr__.

MotorComponent

Bases: ExplicitComponent

Motor model wrapped for OpenMDAO with finite-difference partials.

Inputs: the DesignProblem's continuous variables (baseline values as defaults). Outputs: the requested result keys plus one g_<name> violation output per constraint (feasible when <= 0).

build_motor_group

build_motor_group(problem: DesignProblem, *, objective_index: int = 0) -> om.Problem

An om.Problem with the motor component, SLSQP driver, and constraints wired.

The objective is the DesignProblem objective at objective_index (negated internally when it is a maximize objective — OpenMDAO minimizes).

Source code in src/axfluxmdo/optimize/openmdao_components.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def build_motor_group(problem: DesignProblem, *, objective_index: int = 0) -> om.Problem:
    """An om.Problem with the motor component, SLSQP driver, and constraints wired.

    The objective is the DesignProblem objective at ``objective_index``
    (negated internally when it is a maximize objective — OpenMDAO minimizes).
    """
    obj = problem.objectives[objective_index]

    om_prob = om.Problem(reports=False)
    om_prob.model.add_subsystem("motor", MotorComponent(problem=problem), promotes=["*"])

    for name, (lo, hi) in problem.continuous.items():
        om_prob.model.add_design_var(_om_name(name), lower=lo, upper=hi)
    om_prob.model.add_objective(obj.name, scaler=-1.0 if obj.sense > 0 else 1.0)
    for i in range(problem.n_constr):
        om_prob.model.add_constraint(f"g_{i}", upper=0.0)

    om_prob.driver = om.ScipyOptimizeDriver(optimizer="SLSQP", tol=1e-6, maxiter=60)
    return om_prob

run_openmdao_demo

run_openmdao_demo(motor: AxialFluxMotor, op: OperatingPoint, *, variables: dict, objective: str, constraints: tuple = (), model: Model | None = None) -> dict

Single-objective gradient refinement over the continuous variables.

Returns {'x': optimal design dict, 'result': final to_dict(), 'success': bool}.

Source code in src/axfluxmdo/optimize/openmdao_components.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def run_openmdao_demo(
    motor: AxialFluxMotor,
    op: OperatingPoint,
    *,
    variables: dict,
    objective: str,
    constraints: tuple = (),
    model: Model | None = None,
) -> dict:
    """Single-objective gradient refinement over the continuous variables.

    Returns {'x': optimal design dict, 'result': final to_dict(), 'success': bool}.
    """
    problem = DesignProblem(
        motor, op, variables=variables, objectives=[objective], constraints=constraints, model=model
    )
    if not problem.continuous:
        raise ValueError("run_openmdao_demo needs at least one continuous variable")
    om_prob = build_motor_group(problem)
    om_prob.setup()
    driver_out = om_prob.run_driver()
    # OpenMDAO >= 3.31 returns a DriverResult; older versions return `failed`
    driver_ok = driver_out.success if hasattr(driver_out, "success") else not driver_out
    x = {name: float(om_prob.get_val(_om_name(name))[0]) for name in problem.continuous}
    record = problem.evaluate(x)
    return {
        "x": x,
        "result": record.result.to_dict() if record.result else None,
        "success": bool(driver_ok) and record.result is not None,
    }