Source code for process_improve.experiments.factor

# (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) + ")"