# (c) Kevin Dunn, 2010-2026. MIT License.
"""Factor, Constraint, Response, and DesignResult definitions for design generation."""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Literal
import pandas as pd
from pydantic import BaseModel, model_validator
from process_improve.experiments.structures import Expt
[docs]
class FactorType(str, Enum):
"""Type of experimental factor."""
continuous = "continuous"
categorical = "categorical"
mixture = "mixture"
[docs]
class Factor(BaseModel):
"""Specification for a single experimental factor.
Parameters
----------
name : str
Name of the factor (e.g. "Temperature", "Pressure").
type : FactorType
One of "continuous", "categorical", or "mixture".
low : float or None
Low level for continuous/mixture factors: finite, and below *high*. A
mixture component's bounds are proportions, ``0 <= low < high <= 1``
(default 0 and 1).
high : float or None
High level for continuous/mixture factors.
levels : list or None
Explicit levels for categorical factors (distinct, none missing), or for
continuous factors with more than 2 levels.
units : str
Engineering units (e.g. "degC", "bar", "g/L").
hard_limits : bool
Whether ``low`` and ``high`` are limits of the process, not only the range studied: no run
may set the factor past them (a safety limit, an instrument's range, a rule of a lab).
Designs and augmentations then keep every run inside them, a strategy plans none that
steps outside, and the next round never moves or widens the range. Default False.
Examples
--------
>>> Factor(name="Temperature", low=150, high=200, units="degC")
>>> Factor(name="Pressure", low=1, high=3, units="bar", hard_limits=True)
>>> Factor(name="Catalyst", type="categorical", levels=["A", "B", "C"])
>>> Factor(name="x1", type="mixture", low=0.0, high=1.0)
"""
name: str
type: FactorType = FactorType.continuous
low: float | None = None
high: float | None = None
levels: list[Any] | None = None
units: str = ""
hard_limits: bool = False
@model_validator(mode="after")
def _validate_factor(self) -> Factor:
if self.type == FactorType.continuous:
if self.low is None or self.high is None:
raise ValueError(f"Factor '{self.name}': continuous factors require 'low' and 'high'.")
self._check_bounds()
elif self.type == FactorType.categorical:
self._check_levels()
elif self.type == FactorType.mixture:
if self.low is None:
self.low = 0.0
if self.high is None:
self.high = 1.0
self._check_bounds()
if self.low < 0 or self.high > 1: # type: ignore[operator]
raise ValueError(
f"Factor '{self.name}': a mixture component is a proportion, so its bounds must satisfy "
f"0 <= low < high <= 1; got low={self.low}, high={self.high}."
)
return self
def _check_bounds(self) -> None:
"""Raise unless ``low`` and ``high`` are finite with ``low < high``."""
low, high = float(self.low), float(self.high) # type: ignore[arg-type]
if not (math.isfinite(low) and math.isfinite(high)):
raise ValueError(f"Factor '{self.name}': 'low' ({low}) and 'high' ({high}) must be finite numbers.")
if low >= high:
raise ValueError(f"Factor '{self.name}': 'low' ({low}) must be less than 'high' ({high}).")
def _check_levels(self) -> None:
"""Raise unless a categorical factor has at least two distinct, non-missing levels."""
if not self.levels or len(self.levels) < 2:
raise ValueError(f"Factor '{self.name}': categorical factors require 'levels' with at least 2 entries.")
if any(level is None or (isinstance(level, float) and math.isnan(level)) for level in self.levels):
raise ValueError(f"Factor '{self.name}': a categorical level cannot be missing (None or NaN).")
if len(pd.unique(pd.Series(self.levels, dtype=object))) != len(self.levels):
raise ValueError(f"Factor '{self.name}': categorical levels must be distinct; got {self.levels}.")
@property
def center(self) -> float | None:
"""Return the center point value, or None for categorical factors."""
if self.low is not None and self.high is not None:
return (self.low + self.high) / 2.0
return None
@property
def range(self) -> tuple[float, float] | None:
"""Return the (low, high) range tuple, or None for categorical factors."""
if self.low is not None and self.high is not None:
return (self.low, self.high)
return None
[docs]
@classmethod
def from_data(
cls,
data: pd.Series | Sequence[Any],
*,
name: str | None = None,
type: FactorType | str = FactorType.continuous, # noqa: A002
units: str = "",
low: float | None = None,
high: float | None = None,
levels: list[Any] | None = None,
) -> Factor:
"""Create a Factor from a column of historical / real-world data.
Parameters
----------
data : array-like or pandas.Series
Observed values for the factor.
name : str or None
Factor name. If None, the ``name`` attribute of ``data`` (e.g. a
pandas Series column name) is used; an error is raised if neither
is available.
type : FactorType or str
One of "continuous", "categorical", or "mixture". Default
"continuous".
units : str
Engineering units.
low, high : float or None
Low/high levels for continuous or mixture factors. If not given,
they are inferred from the minimum and maximum of ``data``.
levels : list or None
Explicit levels for categorical factors. If not given, the unique
values of ``data`` (in order of first appearance) are used.
Returns
-------
Factor
A validated Factor instance.
Examples
--------
>>> Factor.from_data(df["Temperature"], type="continuous")
>>> Factor.from_data(df["Catalyst"], type="categorical")
>>> Factor.from_data(df["Pressure"], type="continuous", low=4, high=8)
"""
factor_type = FactorType(type)
values = pd.Series(data).dropna()
if name is None:
name = getattr(data, "name", None)
if name is None:
raise ValueError("`name` must be given when `data` has no `name` attribute.")
if factor_type == FactorType.categorical:
if levels is None:
levels = list(pd.unique(values))
return cls(name=str(name), type=factor_type, levels=levels, units=units)
if low is None:
low = float(values.min())
if high is None:
high = float(values.max())
return cls(name=str(name), type=factor_type, low=low, high=high, units=units)
[docs]
class Constraint(BaseModel):
"""A constraint on the experimental factor space.
Parameters
----------
expression : str
A string expression, e.g. ``"A + B <= 1.0"`` or ``"3*T + 5*D <= 600"``.
Factor names in the expression must match the ``name`` fields of the
corresponding ``Factor`` objects.
type : str
Either ``"linear"`` or ``"nonlinear"``. Informational only: the design
engines classify each expression themselves, so the label does not change
how the constraint is enforced.
Examples
--------
>>> Constraint(expression="A + B <= 1.0")
>>> Constraint(expression="3*T + 5*D <= 600", type="linear")
"""
expression: str
type: Literal["linear", "nonlinear"] = "linear"
[docs]
def as_constraints(constraints: Sequence[Constraint | dict | str] | None) -> list[Constraint] | None:
"""Accept constraints as ``Constraint`` objects, their dict form or bare expressions.
The dict form is what a JSON round trip of a ``Constraint`` gives, for example the
``design_params`` of a stage from ``recommend_strategy``.
Parameters
----------
constraints : Sequence[Constraint | dict | str] or None
The constraints in any of the three forms, mixed freely.
Returns
-------
list[Constraint] or None
One ``Constraint`` per item, in order; ``None`` when ``constraints`` is ``None``.
Raises
------
TypeError
For an item that is none of the three forms, or a single limit passed on its
own instead of in a list (a string is itself a sequence, of its characters).
"""
if constraints is None:
return None
if isinstance(constraints, (str, dict, Constraint)):
raise TypeError(
"constraints must be a list of limits; wrap a single limit in a list: "
f"[{constraints!r}], not {constraints!r}."
)
out: list[Constraint] = []
for c in constraints:
if isinstance(c, Constraint):
out.append(c)
elif isinstance(c, dict):
out.append(Constraint(**c))
elif isinstance(c, str):
out.append(Constraint(expression=c))
else:
raise TypeError(f"A constraint must be a Constraint, a dict or an expression string; got {c!r}.")
return out
[docs]
class ResponseGoal(str, Enum):
"""Goal direction for a response variable."""
maximize = "maximize"
minimize = "minimize"
target = "target"
[docs]
class Response(BaseModel):
"""Specification for a response variable in a DOE study.
Parameters
----------
name : str
Name of the response (e.g. "Yield", "Purity", "Cost").
goal : ResponseGoal
Optimisation direction: ``"maximize"``, ``"minimize"``, or ``"target"``.
target : float or None
Target value (required when ``goal="target"``).
low : float or None
Lower acceptable bound for desirability calculations.
high : float or None
Upper acceptable bound for desirability calculations.
units : str
Engineering units (e.g. "%", "mg/L", "min").
importance : float
Relative importance weight for multi-response optimisation (default 1.0).
Examples
--------
>>> Response(name="Yield", goal="maximize", low=60, high=95, units="%")
>>> Response(name="Cost", goal="minimize", low=10, high=50, units="USD")
>>> Response(name="pH", goal="target", target=7.0, low=6.5, high=7.5)
"""
name: str
goal: ResponseGoal = ResponseGoal.maximize
target: float | None = None
low: float | None = None
high: float | None = None
units: str = ""
importance: float = 1.0
@model_validator(mode="after")
def _validate_response(self) -> Response:
if self.goal == ResponseGoal.target and self.target is None:
raise ValueError(f"Response '{self.name}': goal='target' requires a 'target' value.")
return self
[docs]
@dataclass
class DesignResult:
"""Container for a generated experimental design and its metadata.
Parameters
----------
design : Expt
Design matrix in coded units (-1 / +1).
design_actual : Expt
Design matrix in actual (real-world) units.
run_order : list[int]
The standard-order position (1-based) of each run. ``design`` and
``design_actual`` are already in randomised run order, so ``run_order[i]``
is where their i-th row sits in the unrandomised design; sorting the rows on
it recovers the standard order.
design_type : str
Name of the design that was generated.
n_runs : int
Total number of experimental runs.
n_factors : int
Number of factors in the design.
factor_names : list[str]
Ordered list of factor names.
generators : list[str] or None
Generator strings for fractional factorials (e.g. ``["E=ABC"]``).
defining_relation : list[str] or None
Defining relation words (e.g. ``["I=ABCE"]``).
resolution : int or None
Resolution of the design (III, IV, V, ...).
alpha : float or None
Axial distance used for CCD designs.
blocks : list[int] or None
Block assignment for each run.
metadata : dict[str, Any]
Additional design-specific information.
"""
design: Expt
design_actual: Expt
run_order: list[int]
design_type: str
n_runs: int
n_factors: int
factor_names: list[str]
generators: list[str] | None = None
defining_relation: list[str] | None = None
resolution: int | None = None
alpha: float | None = None
blocks: list[int] | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def __repr__(self) -> str:
"""Return a concise string representation."""
parts = [
f"DesignResult(type={self.design_type!r}",
f"runs={self.n_runs}",
f"factors={self.n_factors}",
]
if self.resolution is not None:
parts.append(f"resolution={self.resolution}")
if self.generators:
parts.append(f"generators={self.generators}")
return ", ".join(parts) + ")"