Source code for process_improve.batch.control

# (c) Kevin Dunn, 2010-2026. MIT License. Based on own private work over the years.
"""Mid-course correction of a running batch with a latent-variable model.

At a decision point during a batch, the initial conditions and the
trajectories observed so far are known, the future responses are not, and the
future manipulated-variable (MV) columns are ours to choose. This module
solves that choice as a quadratic program in the scaled space of a fitted
:class:`process_improve.batch.BatchPLS` model:

- the score vector of the candidate row is an affine function of the future
  MV columns, ``t = b + A_F u``, built from the fixed-missingness-pattern
  projection operator (:meth:`~process_improve.batch.BatchPLS.projection_matrix`,
  trimmed score regression by default), with the future *response* columns
  treated as missing;
- the objective trades off quality tracking (or maximisation) against
  movement from the nominal remaining schedule, plus soft SPE and Hotelling's
  T2 penalties that keep the correction where the model has data;
- box bounds, rate-of-change limits between consecutive samples (including
  the seam to the last implemented sample) and optional hard SPE / T2 caps
  complete the program.

With the quadratic caps the problem is a convex quadratically-constrained QP.
The workhorse is the penalty-form pure QP solved with `osqp
<https://osqp.org>`_ (the ``control`` extra); the hard-cap mode wraps the
same QP in an outer iteration on the two penalty multipliers. It raises the
multiplier of each broken cap until both caps hold, then bisects each
multiplier down in turn, taking a step only when both caps still hold: a
schedule returned with status ``"ok"`` meets both caps to within 1%, and a
cap that binds alone ends with its statistic just inside it. When both caps
bind, a step down in one multiplier can break the other cap, so a statistic
can stay well inside its cap and the schedule can fall short of the capped
optimum.

The formulation follows the latent-variable batch control literature.
Flores-Cerrillo and MacGregor (2004) give the quality-tracking objective, the
movement-suppression term and the soft T2 term, and they check the SPE of the
measurements so far before a correction is computed, suggesting that the
schedule from the last decision point be kept when that check fails, which is
what the gate here does. The dead band plays the role of the "no-control
region" of Yabuki and MacGregor (1997). Garcia-Munoz, Kourti and MacGregor
(2004) compute Hotelling's T2 of on-line score estimates against the
covariance of the reference batches' estimates at each time, as Nomikos and
MacGregor (1995) suggested; the per-decision-point limits here, and the
prediction interval at a decision point, are built the same way, from the
training batches re-projected under that point's missingness pattern.
Arteaga and Ferrer (2002) give the trimmed score regression used to estimate
the scores of a partially observed row.

Two departures from Flores-Cerrillo and MacGregor are deliberate. Their
optimisation is over an adjustment to the scores, with the remaining
trajectories recovered by inverting the PLS model, which is what keeps those
trajectories consistent with past operation. Here the decision variables are
the future MV columns themselves, so actuator bounds and rate limits apply
exactly in engineering units, and the terms that hold the answer inside the
model's region are stated explicitly instead. Golshan et al. (2010) give both
forms for latent-variable MPC: over a correction to the scores, and directly
over the future manipulated variables. Second, the SPE of the *candidate* row
is penalised and capped, not only checked on the measurements so far: a
trajectory reconstructed from adjusted scores lies on the model plane by
construction, and one written directly in the MV columns need not.

A practical caveat: models of this kind are identified on *recorded*
trajectories (the realised values plus measurement noise), while the
corrector outputs *setpoints*. The substitution is sound where the control
loops track their setpoints; the executed-policy evaluation in
:func:`evaluate_control_policies` (:mod:`process_improve.simulation`)
measures the realised effect rather than trusting the model's own
prediction.

References
----------
Flores-Cerrillo, J. and MacGregor, J.F., "Control of batch product quality
by trajectory manipulation using latent variable models", Journal of Process
Control, 14, 539-553, 2004.

Arteaga, F. and Ferrer, A., "Dealing with missing data in MSPC: several
methods, different interpretations, some examples", Journal of Chemometrics,
16, 408-418, 2002.

Garcia-Munoz, S., Kourti, T. and MacGregor, J.F., "Model Predictive
Monitoring for Batch Processes", Industrial & Engineering Chemistry
Research, 43, 5929-5941, 2004.

Nomikos, P. and MacGregor, J.F., "Multivariate SPC Charts for Monitoring
Batch Processes", Technometrics, 37, 41-59, 1995.

Yabuki, Y. and MacGregor, J.F., "Product quality control in semibatch
reactors using midcourse correction policies", Industrial & Engineering
Chemistry Research, 36, 1268-1275, 1997.

Golshan, M., MacGregor, J.F., Bruwer, M.-J. and Mhaskar, P., "Latent
Variable Model Predictive Control (LV-MPC) for trajectory tracking in batch
processes", Journal of Process Control, 20, 538-550, 2010.
"""

from __future__ import annotations

import itertools
import typing

import numpy as np
import pandas as pd
from scipy.stats import t as t_dist
from sklearn.utils import Bunch

from ..multivariate._diagnostics import _t2_score_sd, t2_precision
from ..multivariate._limits import hotellings_t2_limit, spe_calculation
from ..multivariate._projection import project_rows
from ._online import observed_series, scaled_row, unfolded_layout

if typing.TYPE_CHECKING:
    from ._batch_pls import BatchPLS

_MODES = ("target", "maximize")
_DEFAULT_WEIGHTS = {"target": 1.0, "movement": 0.1, "spe": 0.0, "t2": 0.0}
_CAP_REL_TOL = 0.01


def _solve_qp(
    H: np.ndarray,
    f: np.ndarray,
    A_con: np.ndarray | None,
    lower: np.ndarray | None,
    upper: np.ndarray | None,
) -> np.ndarray:
    """Solve ``min 0.5 x'Hx + f'x  s.t.  lower <= A_con x <= upper`` with osqp.

    The unconstrained case has the closed-form stationary solution
    ``H x = -f`` and skips the solver entirely.
    """
    if A_con is None:
        from .._linalg import safe_inverse  # noqa: PLC0415

        return safe_inverse(H, what="the QP Hessian") @ (-f)
    try:
        import osqp  # noqa: PLC0415 - deferred so the module imports without the extra
        from scipy import sparse  # noqa: PLC0415
    except ImportError as exc:  # pragma: no cover - exercised via env-without-osqp
        from .._extras import require_extra  # noqa: PLC0415

        raise require_extra("osqp", "control") from exc

    problem = osqp.OSQP()
    problem.setup(
        sparse.csc_matrix(H),
        f,
        sparse.csc_matrix(A_con),
        lower,
        upper,
        verbose=False,
        eps_abs=1e-10,
        eps_rel=1e-10,
        max_iter=100_000,
        polishing=True,
    )
    # Explicit, as osqp warns that its default will become True; the status check below raises instead.
    result = problem.solve(raise_error=False)
    status = str(result.info.status)
    if "solved" not in status.lower():
        raise RuntimeError(
            f"The mid-course QP did not solve: osqp status {status!r}. "
            "Check that the bounds and rate limits admit any feasible schedule."
        )
    return np.asarray(result.x, dtype=float)


def _knot_matrix(n_free: int, n_knots: int) -> np.ndarray:
    """Linear-interpolation matrix mapping ``n_knots`` values to ``n_free`` samples."""
    if n_knots < 2 or n_knots > n_free:
        raise ValueError(f"n_knots must lie in [2, {n_free}] for {n_free} free samples; got {n_knots}.")
    positions = np.linspace(0, n_free - 1, n_knots)
    B = np.zeros((n_free, n_knots))
    for i in range(n_free):
        j = int(np.searchsorted(positions, i, side="right") - 1)
        j = min(j, n_knots - 2)
        left, right = positions[j], positions[j + 1]
        w = (i - left) / (right - left)
        B[i, j] = 1.0 - w
        B[i, j + 1] = w
    return B


def _check_columns(model: BatchPLS, labels: list, argument: str) -> None:
    known = set(model.feature_columns_)
    unknown = [label for label in labels if label not in known]
    if unknown:
        raise ValueError(f"{argument} contains labels that are not model features: {unknown[:5]}.")


[docs] def midcourse_correction( # noqa: PLR0913, PLR0912, PLR0915, C901 model: BatchPLS, *, observed: pd.Series, free_columns: list, mode: str = "target", y_target: pd.Series | dict | float | None = None, weights: dict | None = None, bounds: dict | None = None, rate_limits: dict | None = None, seam: dict | None = None, nominal_remaining: pd.Series | None = None, spe_cap: float | None = None, t2_cap: float | None = None, score_covariance: np.ndarray | pd.DataFrame | None = None, method: str = "tsr", ridge: float = 0.0, n_knots: int | None = None, ) -> Bunch: """Optimise the remaining manipulated-variable columns of one batch. This is the pure optimisation: everything is explicit and nothing is gated (no dead band, no validity check; use :class:`MidCourseCorrector` for the full decision-point workflow). The unfolded row of the model splits three ways: ``observed`` columns carry known values; ``free_columns`` are the decision variables (the future MV columns); every other column is a missing future response, imputed by the projection operator. Parameters ---------- model : BatchPLS A fitted :class:`process_improve.batch.BatchPLS` model. observed : pd.Series Known values in engineering units, indexed by unfolded column labels: the initial conditions ``(name, "")`` and the past trajectory columns ``(tag, sample)``. free_columns : list Unfolded column labels of the decision variables, e.g. ``[("temperature", 12), ("temperature", 13), ...]``. Must be disjoint from ``observed``. mode : {"target", "maximize"}, default="target" ``"target"`` tracks ``y_target`` with a quadratic penalty (the Yabuki-MacGregor use case). ``"maximize"`` pushes the predicted quality up with a linear term; the quadratic movement penalty keeps the program bounded, which is the correct form for quality maximisation (an unreachable setpoint inside a quadratic is deliberately not used). y_target : Series, dict, or float, optional The quality target in original units; required for ``mode="target"`` (a bare float is accepted for a single-target model). weights : dict, optional Keys (all optional): ``"target"`` (scalar or per-target array; tracking weight, or the linear reward in ``maximize`` mode), ``"movement"`` (scalar or per-free-column array; penalty on the scaled deviation from ``nominal_remaining``; must be positive in ``maximize`` mode), ``"spe"`` and ``"t2"`` (soft penalties on the candidate row's SPE and Hotelling's T2; the manufacturing-vs-development exploration dial). Defaults: ``{"target": 1.0, "movement": 0.1, "spe": 0.0, "t2": 0.0}``. bounds : dict, optional Per-tag box bounds in engineering units, ``{tag: (low, high)}``, applied to every free column of that tag. Tighten the box inward by roughly two control-error standard deviations, so optimised setpoints do not sit on the actuator rails where clipping biases the realised mean. rate_limits : dict, optional Per-tag limit on the change between consecutive samples, in engineering units, ``{tag: max_step}``. Applied between consecutive free samples of the tag and, when ``seam`` provides the last implemented value, across the seam as well. seam : dict, optional ``{tag: last_implemented_value}`` in engineering units, for the seam rate constraint. nominal_remaining : pd.Series, optional The nominal remaining schedule in engineering units, indexed by ``free_columns``; the movement penalty is measured from it. Default: the training average (the model's centring) of those columns. spe_cap : float, optional Hard cap on the candidate row's SPE (on the square-root scale used throughout the package, so the quadratic constraint bounds ``SPE**2``). Enforced by the outer multiplier iteration. t2_cap : float, optional Hard cap on the candidate row's Hotelling's T2. score_covariance : array-like of shape (A, A), optional Covariance used in the T2 quadratic. Default: the diagonal of the training score variances, leaving out a component with no spread as the model's own T2 does. Pass the per-decision-point covariance of the score *estimates* (Garcia-Munoz et al., 2004) for a reference that matches the pattern; :meth:`MidCourseCorrector.limits_at` builds it. A component with no spread is left out of a covariance passed here too: it adds 0 to the T2, and the inverse is taken over the other components. method : {"tsr", "scp", "pmp"}, default="tsr" Score-estimation method for the projection operator. ridge : float, default=0.0 Regularisation for the operator; see :meth:`~process_improve.batch.BatchPLS.projection_matrix`. n_knots : int, optional Parameterise each tag's free samples by ``n_knots`` linearly interpolated knot values. Shrinks the decision space (useful early in the batch) and smooths the schedule; the problem stays a QP. Returns ------- result : sklearn.utils.Bunch With keys ``mv`` (Series, the optimised free columns in engineering units, in model-feature order), ``y_hat`` and ``y_hat_no_change`` (Series, original quality units), ``scores`` (Series), ``spe`` and ``t2`` (floats for the candidate row over the observed-plus-free pattern), ``active_constraints`` (dict with keys ``bounds``, ``rate``, ``spe_cap``, ``t2_cap``), ``solver`` (Bunch with ``status``, ``n_solves``, ``spe_multiplier``, ``t2_multiplier``, ``spe_unconstrained`` and ``t2_unconstrained``), ``operator_condition_number`` and ``spe_offset`` (the constant term in the SPE quadratic, ``c_obs @ c_obs + c_free @ c_free``). """ from sklearn.utils.validation import check_is_fitted # noqa: PLC0415 check_is_fitted(model, "x_weights_") if mode not in _MODES: raise ValueError(f"mode must be one of {list(_MODES)}; got {mode!r}.") if not free_columns: raise ValueError("free_columns is empty: there is nothing to optimise.") if not isinstance(observed, pd.Series): raise TypeError( f"observed must be a pandas Series indexed by unfolded column labels; got {type(observed).__name__}." ) overlap = set(observed.index) & set(free_columns) if overlap: raise ValueError(f"observed and free_columns overlap: {sorted(overlap, key=str)[:5]}.") _check_columns(model, list(observed.index), "observed") _check_columns(model, list(free_columns), "free_columns") weights = {**_DEFAULT_WEIGHTS, **(weights or {})} features = pd.Index(model.feature_columns_) observed_mask = features.isin(observed.index) free_mask = features.isin(set(free_columns)) pattern_mask = observed_mask | free_mask A = int(model.n_components) n_targets = len(model.target_names_) center = model.center_.to_numpy(dtype=float) scale = model.scale_.to_numpy(dtype=float) # The operator over the observed-plus-free pattern; split by position. op = model.projection_matrix(pattern_mask, method=method, ridge=ridge) matrix = op.matrix.to_numpy(dtype=float) pattern_positions = np.flatnonzero(pattern_mask) in_free = free_mask[pattern_positions] M_free = matrix[:, in_free] M_obs = matrix[:, ~in_free] observed_positions = np.flatnonzero(observed_mask) free_positions = np.flatnonzero(free_mask) z_obs = ( observed.reindex(pd.Index(features[observed_positions])).to_numpy(dtype=float) - center[observed_positions] ) / scale[observed_positions] if np.isnan(z_obs).any(): raise ValueError("observed contains NaN values; every observed column needs a value.") b = M_obs @ z_obs # scores of the batch-so-far with u = 0 (the training average) A_F = M_free # Quality map in scaled-Y space: y_s = C (b + A_F u). C = np.asarray(model.y_loadings_, dtype=float) # (n_targets, A) G_y = C @ A_F y0_scaled = C @ b # SPE of the candidate row: residuals over observed and free positions. P = model.x_loadings_.to_numpy(dtype=float) P_obs = P[observed_positions, :] P_free = P[free_positions, :] D_obs = -P_obs @ A_F c_obs = z_obs - P_obs @ b n_free = len(free_positions) D_free = np.eye(n_free) - P_free @ A_F c_free = -P_free @ b # T2 quadratic: t' S^{-1} t with t = b + A_F u. A component with no spread # gets weight 0, not about 1/0, by the rule every multivariate T2 uses (#1086, #1124). score_sd = np.sqrt(np.asarray(model.explained_variance_, dtype=float)) if score_covariance is None: s_inv = np.diag(1.0 / _t2_score_sd(score_sd) ** 2) else: s_arr = np.asarray(score_covariance, dtype=float) if s_arr.shape != (A, A): raise ValueError(f"score_covariance must have shape ({A}, {A}); got {s_arr.shape}.") s_inv = t2_precision(s_arr, score_sd, what="score_covariance") # Nominal remaining schedule, scaled. if nominal_remaining is None: u_nom = np.zeros(n_free) else: nominal_values = nominal_remaining.reindex(pd.Index(features[free_positions])).to_numpy(dtype=float) if np.isnan(nominal_values).any(): raise ValueError("nominal_remaining must cover every free column with a finite value.") u_nom = (nominal_values - center[free_positions]) / scale[free_positions] # Movement weights per free column. movement = np.asarray(weights["movement"], dtype=float) movement = np.full(n_free, float(movement)) if movement.ndim == 0 else movement if movement.shape != (n_free,): raise ValueError(f"weights['movement'] must be a scalar or length-{n_free} array; got shape {movement.shape}.") if mode == "maximize" and not np.all(movement > 0): raise ValueError("weights['movement'] must be strictly positive in maximize mode (it bounds the program).") # Target handling. w_target = np.asarray(weights["target"], dtype=float) w_target = np.full(n_targets, float(w_target)) if w_target.ndim == 0 else w_target if w_target.shape != (n_targets,): raise ValueError(f"weights['target'] must be a scalar or length-{n_targets} array; got shape {w_target.shape}.") y_scale = model.y_scale_.to_numpy(dtype=float) y_center = model.y_center_.to_numpy(dtype=float) if mode == "target": if y_target is None: raise ValueError("y_target is required when mode='target'.") if isinstance(y_target, (int, float)) and n_targets == 1: target_values = np.array([float(y_target)]) else: target_series = pd.Series(y_target) target_values = target_series.reindex(model.target_names_).to_numpy(dtype=float) if np.isnan(target_values).any(): raise ValueError(f"y_target must supply a value for every target in {model.target_names_}.") y_target_scaled = (target_values - y_center) / y_scale elif y_target is not None: raise ValueError("y_target only applies to mode='target'; in maximize mode use weights['target'].") # --- Quadratic assembly on u ------------------------------------------ H_move = 2.0 * np.diag(movement) f_move = -2.0 * movement * u_nom H_spe = 2.0 * (D_obs.T @ D_obs + D_free.T @ D_free) f_spe = 2.0 * (D_obs.T @ c_obs + D_free.T @ c_free) spe_const = float(c_obs @ c_obs + c_free @ c_free) H_t2 = 2.0 * (A_F.T @ s_inv @ A_F) f_t2 = 2.0 * (A_F.T @ s_inv @ b) if mode == "target": W1 = np.diag(w_target) H_track = 2.0 * (G_y.T @ W1 @ G_y) f_track = 2.0 * (G_y.T @ W1 @ (y0_scaled - y_target_scaled)) else: H_track = np.zeros((n_free, n_free)) f_track = -(G_y.T @ w_target) # --- Constraints on u -------------------------------------------------- free_labels = list(features[free_positions]) free_tags = [label[0] for label in free_labels] rows: list[np.ndarray] = [] lows: list[float] = [] highs: list[float] = [] row_names: list[str] = [] if bounds: for tag, (low, high) in bounds.items(): if low >= high: raise ValueError(f"bounds for {tag!r} must satisfy low < high; got ({low}, {high}).") for j, (label, tag) in enumerate(zip(free_labels, free_tags, strict=True)): if tag in bounds: low, high = bounds[tag] row = np.zeros(n_free) row[j] = 1.0 rows.append(row) lows.append((low - center[free_positions[j]]) / scale[free_positions[j]]) highs.append((high - center[free_positions[j]]) / scale[free_positions[j]]) row_names.append(f"bound:{label}") if rate_limits: by_tag: dict[object, list[int]] = {} for j, tag in enumerate(free_tags): by_tag.setdefault(tag, []).append(j) for tag, positions_in_free in by_tag.items(): if tag not in rate_limits: continue max_step = float(rate_limits[tag]) if max_step <= 0: raise ValueError(f"rate_limits for {tag!r} must be positive; got {max_step}.") ordered = sorted(positions_in_free, key=lambda j: free_labels[j][1]) for j_prev, j_next in itertools.pairwise(ordered): row = np.zeros(n_free) row[j_next] = scale[free_positions[j_next]] row[j_prev] = -scale[free_positions[j_prev]] offset = center[free_positions[j_next]] - center[free_positions[j_prev]] rows.append(row) lows.append(-max_step - offset) highs.append(max_step - offset) row_names.append(f"rate:{free_labels[j_next]}") if seam and tag in seam: j0 = ordered[0] row = np.zeros(n_free) row[j0] = scale[free_positions[j0]] offset = center[free_positions[j0]] - float(seam[tag]) rows.append(row) lows.append(-max_step - offset) highs.append(max_step - offset) row_names.append(f"seam:{free_labels[j0]}") A_con = np.vstack(rows) if rows else None lower = np.asarray(lows, dtype=float) if rows else None upper = np.asarray(highs, dtype=float) if rows else None # --- Optional knot parameterisation ------------------------------------ # The knots live in ENGINEERING units (the whole point is a smooth # schedule), so the substitution into the scaled decision space is # affine: u = W v + d with W = S^{-1} B_eng and d = -S^{-1} c, where S # and c are the per-column scale and centre of the free columns. if n_knots is not None: by_tag = {} for j, tag in enumerate(free_tags): by_tag.setdefault(tag, []).append(j) blocks = [] order: list[int] = [] for positions_in_free in by_tag.values(): ordered = sorted(positions_in_free, key=lambda j: free_labels[j][1]) order.extend(ordered) # A tag with a single remaining sample has nothing to interpolate # (the last decision point before the batch ends); it keeps its # one decision variable. blocks.append(_knot_matrix(len(ordered), min(n_knots, len(ordered))) if len(ordered) >= 2 else np.eye(1)) permute = np.zeros((n_free, n_free)) for row_pos, j in enumerate(order): permute[j, row_pos] = 1.0 from scipy.linalg import block_diag # noqa: PLC0415 scale_free = scale[free_positions] center_free = center[free_positions] W_sub = (permute @ block_diag(*blocks)) / scale_free[:, None] d_sub = -center_free / scale_free else: W_sub = None d_sub = None def _solve(mu_spe: float, mu_t2: float) -> np.ndarray: H = H_track + H_move + (weights["spe"] + mu_spe) * H_spe + (weights["t2"] + mu_t2) * H_t2 f = f_track + f_move + (weights["spe"] + mu_spe) * f_spe + (weights["t2"] + mu_t2) * f_t2 if W_sub is None: return _solve_qp(H, f, A_con, lower, upper) H_v = W_sub.T @ H @ W_sub f_v = W_sub.T @ (f + H @ d_sub) if A_con is None or lower is None or upper is None: A_v, low_v, up_v = None, None, None else: shift = A_con @ d_sub A_v, low_v, up_v = A_con @ W_sub, lower - shift, upper - shift v = _solve_qp(H_v, f_v, A_v, low_v, up_v) return W_sub @ v + d_sub def _statistics(u: np.ndarray) -> tuple[float, float]: t = b + A_F @ u ssr = float(np.sum((c_obs + D_obs @ u) ** 2) + np.sum((c_free + D_free @ u) ** 2)) t2_value = float(t @ s_inv @ t) return np.sqrt(ssr), t2_value # --- Solve, with the outer multiplier iteration for hard caps ---------- # Escalate whichever multiplier's cap is violated until both hold, then # bisect each active multiplier down so the achieved statistic lands just # inside its cap instead of far below it (the escalation overshoots). # Each statistic is non-increasing in its own multiplier for this convex # problem, so the per-coordinate bisection is well posed. Lowering one # multiplier can raise the other statistic, so a bisection step is taken # only when both caps still hold: every accepted schedule meets both, and # an "ok" status means they hold (#681). mu_spe = mu_t2 = 0.0 n_solves = 0 u_star = _solve(mu_spe, mu_t2) n_solves += 1 spe_value, t2_value = _statistics(u_star) spe_unconstrained, t2_unconstrained = spe_value, t2_value spe_cap_value = float("inf") if spe_cap is None else float(spe_cap) t2_cap_value = float("inf") if t2_cap is None else float(t2_cap) cap_status = "ok" def _within_caps(spe: float, t2: float) -> bool: return spe <= spe_cap_value * (1 + _CAP_REL_TOL) and t2 <= t2_cap_value * (1 + _CAP_REL_TOL) for _ in range(30): spe_bad = spe_value > spe_cap_value * (1 + _CAP_REL_TOL) t2_bad = t2_value > t2_cap_value * (1 + _CAP_REL_TOL) if not spe_bad and not t2_bad: break if spe_bad: mu_spe = max(mu_spe * 4.0, 1e-3) if t2_bad: mu_t2 = max(mu_t2 * 4.0, 1e-3) u_star = _solve(mu_spe, mu_t2) n_solves += 1 spe_value, t2_value = _statistics(u_star) else: cap_status = "cap_not_met" if cap_status == "ok" and (mu_spe > 0 or mu_t2 > 0): for _round in range(2): if mu_spe > 0 and spe_value < spe_cap_value * (1 - _CAP_REL_TOL): low_mu, high_mu = 0.0, mu_spe for _ in range(12): mid = 0.5 * (low_mu + high_mu) u_try = _solve(mid, mu_t2) n_solves += 1 spe_try, t2_try = _statistics(u_try) if not _within_caps(spe_try, t2_try): low_mu = mid else: high_mu = mid u_star, spe_value, t2_value = u_try, spe_try, t2_try if spe_try >= spe_cap_value * (1 - _CAP_REL_TOL): break mu_spe = high_mu if mu_t2 > 0 and t2_value < t2_cap_value * (1 - _CAP_REL_TOL): low_mu, high_mu = 0.0, mu_t2 for _ in range(12): mid = 0.5 * (low_mu + high_mu) u_try = _solve(mu_spe, mid) n_solves += 1 spe_try, t2_try = _statistics(u_try) if not _within_caps(spe_try, t2_try): low_mu = mid else: high_mu = mid u_star, spe_value, t2_value = u_try, spe_try, t2_try if t2_try >= t2_cap_value * (1 - _CAP_REL_TOL): break mu_t2 = high_mu if _within_caps(spe_value, t2_value): break scores = b + A_F @ u_star y_hat_scaled = C @ scores y_hat = pd.Series(y_center + y_scale * y_hat_scaled, index=model.target_names_, name="y_hat") t_nominal = b + A_F @ u_nom y_no_change = pd.Series(y_center + y_scale * (C @ t_nominal), index=model.target_names_, name="y_hat_no_change") mv = pd.Series( center[free_positions] + scale[free_positions] * u_star, index=pd.Index(free_labels), name="mv", ) active: dict[str, object] = {"bounds": [], "rate": [], "spe_cap": False, "t2_cap": False} if rows and A_con is not None and lower is not None and upper is not None: values = A_con @ u_star tol = 1e-6 for name, value, low, high in zip(row_names, values, lower, upper, strict=True): if value <= low + tol or value >= high - tol: kind = name.split(":", 1)[0] key = "bounds" if kind == "bound" else "rate" typing.cast("list", active[key]).append(name.split(":", 1)[1]) def _binding(statistic: str) -> bool: """Return whether a cap holds its statistic down: with its multiplier at zero, the statistic breaks it.""" nonlocal n_solves n_solves += 1 if statistic == "spe": return _statistics(_solve(0.0, mu_t2))[0] > spe_cap_value * (1 + _CAP_REL_TOL) return _statistics(_solve(mu_spe, 0.0))[1] > t2_cap_value * (1 + _CAP_REL_TOL) # A cap is active where its statistic sits at it (within the tolerance), or above it when it # could not be met. Below it, a multiplier left over from the bisection is binding only if # dropping it would break the cap: one bisected down because its cap is slack is not (#681). if spe_cap is not None: active["spe_cap"] = spe_value >= spe_cap_value * (1 - _CAP_REL_TOL) or (mu_spe > 0 and _binding("spe")) if t2_cap is not None: active["t2_cap"] = t2_value >= t2_cap_value * (1 - _CAP_REL_TOL) or (mu_t2 > 0 and _binding("t2")) return Bunch( mv=mv, y_hat=y_hat, y_hat_no_change=y_no_change, scores=pd.Series(scores, index=model.scores_.columns, name="scores"), spe=spe_value, t2=t2_value, active_constraints=active, solver=Bunch( status=cap_status, n_solves=n_solves, spe_multiplier=mu_spe, t2_multiplier=mu_t2, spe_unconstrained=spe_unconstrained, t2_unconstrained=t2_unconstrained, ), operator_condition_number=float(op.condition_number), spe_offset=spe_const, )
[docs] class MidCourseCorrector: """Decision-point workflow around :func:`midcourse_correction`. Holds the model, the nominal schedule and the tuning. :meth:`predict` answers the monitoring question at a decision point (the predicted final quality with its interval, and the validity statistics of the batch so far). :meth:`correct` adds the decision: it checks the batch-so-far against the model (the SPE check of Flores-Cerrillo and MacGregor, 2004, keeping the current schedule when it fails), applies the no-correction dead band (in the role of the no-control region of Yabuki and MacGregor, 1997) in target mode, builds the per-decision-point reference limits (Garcia-Munoz et al., 2004), solves the QP, and returns the full corrected schedule ready to implement (or to hand to :meth:`process_improve.simulation.BioreactorSimulator.simulate_batch`). Parameters ---------- model : BatchPLS Fitted model whose X block unfolds recorded tag trajectories (and optionally initial conditions). Must be fitted with the default column layout (``group_by_batch=False``). nominal_schedule : pd.DataFrame The nominal setpoint schedule: ``n_timesteps_`` rows (positionally aligned with the tag samples), one column per manipulated tag. mv_tags : list The manipulated tags (a subset of the model's tag names); every other tag is a response, treated as missing after the decision point. mode : {"target", "maximize"}, default="target" y_target : Series, dict, or float, optional Required by :meth:`correct` in ``mode="target"``. A corrector built with only ``model``, ``nominal_schedule`` and ``mv_tags`` can :meth:`predict` (the monitoring question needs no target) but not correct. weights, bounds, rate_limits, method, ridge, n_knots Passed through to :func:`midcourse_correction`. spe_cap, t2_cap : float, "limit", or None, default="limit" Hard caps for the QP. ``"limit"`` resolves, per decision point, to the training-based limit for the same missingness pattern at ``conf_level`` (see :meth:`limits_at`); a float is used as given; None disables the cap. conf_level : float, default=0.95 Confidence level for the per-decision-point limits and the dead-band prediction interval. dead_band : float, default=1.0 Multiplier on the prediction-interval half-width: in target mode the correction is skipped while the no-change prediction lies within ``dead_band`` half-widths of the target for every quality variable. The half-width is the interval *at the decision point* (see :meth:`predict`): it carries the model's prediction error under that point's missingness pattern, so early decision points, where the estimate rests on few observed columns, are held to a wider band. Set to 0.0 to correct at every decision point. Ignored in maximize mode. target_side : {"both", "below", "above"}, default="both" Which deviations from the target warrant a correction. ``"below"`` treats the target as a floor (a more-is-better quality): batches predicted at or above it are left alone, whatever the dead band says. ``"above"`` is the mirror (a ceiling); ``"both"`` corrects deviations in either direction (an on-target specification). """ def __init__( # noqa: PLR0913 self, model: BatchPLS, nominal_schedule: pd.DataFrame, *, mv_tags: list, mode: str = "target", y_target: pd.Series | dict | float | None = None, weights: dict | None = None, bounds: dict | None = None, rate_limits: dict | None = None, spe_cap: float | str | None = "limit", t2_cap: float | str | None = "limit", conf_level: float = 0.95, dead_band: float = 1.0, target_side: str = "both", method: str = "tsr", ridge: float = 0.0, n_knots: int | None = None, ) -> None: from sklearn.utils.validation import check_is_fitted # noqa: PLC0415 check_is_fitted(model, "x_weights_") if model.group_by_batch: raise ValueError("MidCourseCorrector requires a model fitted with group_by_batch=False.") if mode not in _MODES: raise ValueError(f"mode must be one of {list(_MODES)}; got {mode!r}.") unknown_tags = [t for t in mv_tags if t not in model.tag_names_] if unknown_tags: raise ValueError(f"mv_tags contains tags the model does not carry: {unknown_tags}.") if not isinstance(nominal_schedule, pd.DataFrame): raise TypeError(f"nominal_schedule must be a DataFrame; got {type(nominal_schedule).__name__}.") if nominal_schedule.shape[0] != model.n_timesteps_: raise ValueError( f"nominal_schedule must have {model.n_timesteps_} rows (one per aligned sample); " f"got {nominal_schedule.shape[0]}." ) missing_columns = [t for t in mv_tags if t not in nominal_schedule.columns] if missing_columns: raise ValueError(f"nominal_schedule is missing columns for mv_tags: {missing_columns}.") self.model = model self.nominal_schedule = nominal_schedule self.mv_tags = list(mv_tags) self.mode = mode self.y_target = y_target self.weights = weights self.bounds = bounds self.rate_limits = rate_limits self.spe_cap = spe_cap self.t2_cap = t2_cap if target_side not in ("both", "below", "above"): raise ValueError(f"target_side must be 'both', 'below' or 'above'; got {target_side!r}.") self.conf_level = conf_level self.dead_band = dead_band self.target_side = target_side self.method = method self.ridge = ridge self.n_knots = n_knots self._limit_cache: dict[int, Bunch] = {} # ------------------------------------------------------------------ # def _masks_at(self, k: int) -> Bunch: """Boolean masks over the unfolded features for decision point ``k``.""" layout = unfolded_layout(self.model.feature_columns_) is_z, seq_num = layout.is_z, layout.sequence past = ~is_z & (seq_num < k) future = ~is_z & (seq_num >= k) is_mv = np.array([t in set(self.mv_tags) for t in layout.tags]) observed = is_z | past free = future & is_mv return Bunch(observed=observed, free=free, missing=future & ~is_mv)
[docs] def limits_at(self, k: int) -> Bunch: """Per-decision-point reference limits from the training batches. The training rows are re-projected under decision point ``k``'s two patterns: the *monitoring* pattern (initial conditions plus every tag up to ``k``; the future entirely missing) for the SPE validity gate, and the *candidate* pattern (monitoring plus the future MV columns, which the optimiser treats as observed) for the QP's SPE cap and the score covariance behind its T2 term. Limits: the g-chi-squared SPE limit (Box's approximation, as Nomikos and MacGregor, 1995, use it) on each pattern's training SPE values, and the F-distribution T2 limit on the candidate-pattern score estimates with their own covariance (Garcia-Munoz et al., 2004). Also returned: ``rmse_k`` (Series over the targets, original units), the prediction error of the model at this decision point, from the candidate-pattern estimates of the training batches (an in-sample error, since the model was fitted on those batches); and the condition numbers of the two pattern operators. Results are cached per ``k``. """ if k in self._limit_cache: return self._limit_cache[k] if not 1 <= k <= self.model.n_timesteps_: raise ValueError(f"k must lie in [1, {self.model.n_timesteps_}]; got {k}.") masks = self._masks_at(k) training = self.model._x_scaled_training.to_numpy(dtype=float) loadings = self.model.x_loadings_.to_numpy(dtype=float) guide = self.model.direct_weights_.to_numpy(dtype=float) weights = self.model.x_weights_.to_numpy(dtype=float) variances = np.asarray(self.model.explained_variance_, dtype=float) monitor_rows = training.copy() monitor_rows[:, ~masks.observed] = np.nan residuals = self.model._x_residuals monitor = project_rows( loadings, guide, variances, monitor_rows, method=self.method, ridge=self.ridge, x_weights=weights, x_residuals=residuals, ) candidate_mask = masks.observed | masks.free candidate_rows = training.copy() candidate_rows[:, ~candidate_mask] = np.nan candidate = project_rows( loadings, guide, variances, candidate_rows, method=self.method, ridge=self.ridge, x_weights=weights, x_residuals=residuals, ) n = training.shape[0] A = int(self.model.n_components) score_cov = np.cov(candidate.scores, rowvar=False, ddof=1) score_cov = np.atleast_2d(score_cov) # The candidate row is new to the batches the model was fitted to (#978). t2_limit = hotellings_t2_limit(self.conf_level, n_components=A, n_rows=n, judged_rows="new") # The prediction error of the model *at this decision point*: the # quality predicted from the candidate-pattern score estimates of the # training batches, against their measured quality, on the same # N - A - 1 degrees of freedom as the full-row training RMSE that # PLS.prediction_interval uses. Early in the batch the estimate rests # on few observed columns and this error can be much larger than the # full-row RMSE (orders of magnitude when the restricted estimator is # ill-conditioned); it is what the prediction interval, and hence the # dead band, must carry at that decision point. y_loadings = np.asarray(self.model.y_loadings_, dtype=float) y_scaled = self.model._y_scaled_training.to_numpy(dtype=float) residual = candidate.scores @ y_loadings.T - y_scaled df = max(n - A - 1, 1) rmse_k = np.sqrt(np.sum(residual**2, axis=0) / df) * self.model.y_scale_.to_numpy(dtype=float) result = Bunch( spe_limit_monitor=float(spe_calculation(monitor.spe, conf_level=self.conf_level)), spe_limit_candidate=float(spe_calculation(candidate.spe, conf_level=self.conf_level)), t2_limit=t2_limit, score_covariance=score_cov, rmse_k=pd.Series(rmse_k, index=self.model.target_names_, name="rmse_k"), condition_number_monitor=float(monitor.condition_number[0]), condition_number_candidate=float(candidate.condition_number[0]), ) self._limit_cache[k] = result return result
def _observed_series( self, batch_so_far: pd.DataFrame, initial_conditions: pd.Series | pd.DataFrame | None, k: int, ) -> pd.Series: """Build the engineering-unit observed Series for decision point ``k`` (see :mod:`._online`).""" return observed_series(self.model, batch_so_far, initial_conditions, k)
[docs] def predict( self, batch_so_far: pd.DataFrame, *, initial_conditions: pd.Series | pd.DataFrame | None = None, schedule: pd.DataFrame | None = None, k: int | None = None, ) -> Bunch: """Predict the final quality of a running batch at decision point ``k``. The monitoring question "where is this batch heading if nothing more is changed": the initial conditions and the samples recorded so far are known, the remaining manipulated-variable samples are taken from ``schedule`` (the nominal schedule by default), and the remaining response samples are missing and estimated. The prediction interval uses the model's prediction error *at this decision point* (``limits_at(k).rmse_k``) with the leverage of the estimated scores, so it is wide early in the batch, when the estimate rests on few observed columns, and narrows as the batch runs. It is the prediction interval of a least-squares regression of the quality on the scores, with the scores treated as known; check its coverage on held-out batches before relying on it. Parameters ---------- batch_so_far : pd.DataFrame The recorded tag trajectories up to the decision point: the first ``k`` samples, columns = the model's tags. initial_conditions : pd.Series or pd.DataFrame, optional The batch's Z values; required if the model was fitted with a Z block. schedule : pd.DataFrame, optional The setpoint schedule whose remaining rows are assumed (same layout as ``nominal_schedule``). Defaults to the nominal schedule. k : int, optional The decision point (number of completed samples). Defaults to ``len(batch_so_far)``. At ``k == n_timesteps_`` the row is complete and the prediction equals the model's ordinary one. Returns ------- result : sklearn.utils.Bunch With keys ``y_hat``, ``half_width``, ``lower`` and ``upper`` (Series over the targets, original units; the interval is at ``conf_level``), ``spe_so_far`` and ``spe_limit_monitor`` (the validity check on the measurements so far), ``in_control`` (bool, ``spe_so_far <= spe_limit_monitor``), ``t2`` and ``t2_limit`` (the candidate row against the per-decision-point score covariance), ``condition_number`` (of the candidate-pattern operator; a very large value means the estimate at this decision point is not trustworthy, whatever the batch looks like), and ``k``. """ model = self.model if k is None: k = len(batch_so_far) if not 1 <= k <= model.n_timesteps_: raise ValueError(f"k must lie in [1, {model.n_timesteps_}]; got {k}.") if len(batch_so_far) < k: raise ValueError(f"batch_so_far has {len(batch_so_far)} samples but k={k} were requested.") schedule = schedule if schedule is not None else self.nominal_schedule if schedule.shape[0] != model.n_timesteps_: raise ValueError(f"schedule must have {model.n_timesteps_} rows; got {schedule.shape[0]}.") limits = self.limits_at(k) masks = self._masks_at(k) observed = self._observed_series(batch_so_far, initial_conditions, k) features = pd.Index(model.feature_columns_) center = model.center_.to_numpy(dtype=float) scale = model.scale_.to_numpy(dtype=float) loadings = model.x_loadings_.to_numpy(dtype=float) guide = model.direct_weights_.to_numpy(dtype=float) weights = model.x_weights_.to_numpy(dtype=float) variances = np.asarray(model.explained_variance_, dtype=float) residuals = model._x_residuals row = scaled_row(model, observed) so_far = project_rows( loadings, guide, variances, row[None, :], method=self.method, ridge=self.ridge, x_weights=weights, x_residuals=residuals, ) spe_so_far = float(so_far.spe[0]) candidate = row.copy() free_positions = np.flatnonzero(masks.free) if free_positions.size: free_labels = list(features[masks.free]) planned = np.array([float(schedule.iloc[s][tag]) for (tag, s) in free_labels]) candidate[free_positions] = (planned - center[free_positions]) / scale[free_positions] projected = project_rows( loadings, guide, variances, candidate[None, :], method=self.method, ridge=self.ridge, x_weights=weights, x_residuals=residuals, ) scores = projected.scores[0] s_inv = t2_precision(limits.score_covariance, np.sqrt(variances), what="score_covariance") t2 = float(scores @ s_inv @ scores) y_loadings = np.asarray(model.y_loadings_, dtype=float) y_hat = model.y_center_.to_numpy(dtype=float) + model.y_scale_.to_numpy(dtype=float) * (y_loadings @ scores) n = model.n_samples_ df = max(n - int(model.n_components) - 1, 1) t_crit = t_dist.ppf(1 - (1 - self.conf_level) / 2, df) leverage = 1.0 / n + t2 / (n - 1) half_width = t_crit * np.sqrt(1.0 + leverage) * limits.rmse_k.to_numpy(dtype=float) targets = model.target_names_ return Bunch( y_hat=pd.Series(y_hat, index=targets, name="y_hat"), half_width=pd.Series(half_width, index=targets, name="half_width"), lower=pd.Series(y_hat - half_width, index=targets, name="lower"), upper=pd.Series(y_hat + half_width, index=targets, name="upper"), conf_level=self.conf_level, spe_so_far=spe_so_far, spe_limit_monitor=limits.spe_limit_monitor, in_control=bool(spe_so_far <= limits.spe_limit_monitor), t2=t2, t2_limit=limits.t2_limit, condition_number=float(projected.condition_number[0]), k=k, )
[docs] def correct( # noqa: C901, PLR0912 - the decision-point workflow is one narrative self, batch_so_far: pd.DataFrame, *, initial_conditions: pd.Series | pd.DataFrame | None = None, implemented_schedule: pd.DataFrame | None = None, k: int | None = None, ) -> Bunch: """Decide and (when warranted) compute the correction at one decision point. Parameters ---------- batch_so_far : pd.DataFrame The recorded tag trajectories up to the decision point: the first ``k`` samples, columns = the model's tags. initial_conditions : pd.Series or pd.DataFrame, optional The batch's Z values; required if the model was fitted with a Z block. implemented_schedule : pd.DataFrame, optional The setpoint schedule actually implemented so far (same layout as ``nominal_schedule``); its first ``k`` rows are carried into the returned schedule verbatim and its row ``k - 1`` anchors the seam rate constraint. Defaults to the nominal schedule. k : int, optional The decision point (number of completed samples). Defaults to ``len(batch_so_far)``. Returns ------- result : sklearn.utils.Bunch With keys ``schedule`` (the full setpoint DataFrame: implemented past plus the decided remainder), ``corrected`` (bool), ``reason`` (``"corrected"``, ``"spe_gate"``, ``"dead_band"`` or ``"batch_complete"``), ``k``, ``spe_so_far`` and ``spe_limit_monitor`` (the validity gate), ``y_hat_no_change`` and ``half_width`` (the prediction and its interval half-width at this decision point, from :meth:`predict`), ``condition_number`` (of the candidate-pattern operator), in target mode ``dead_band_margin`` (Series; deviation of the no-change prediction from the target in units of the half-width), plus ``y_hat`` and ``correction`` (the full :func:`midcourse_correction` Bunch) when a correction was computed. """ model = self.model if self.mode == "target" and self.y_target is None: raise ValueError( "y_target is required to correct in mode='target' (a corrector without one can only predict)." ) if k is None: k = len(batch_so_far) if not 1 <= k <= model.n_timesteps_: raise ValueError(f"k must lie in [1, {model.n_timesteps_}]; got {k}.") if len(batch_so_far) < k: raise ValueError(f"batch_so_far has {len(batch_so_far)} samples but k={k} were requested.") schedule = (implemented_schedule if implemented_schedule is not None else self.nominal_schedule).copy() if schedule.shape[0] != model.n_timesteps_: raise ValueError(f"implemented_schedule must have {model.n_timesteps_} rows; got {schedule.shape[0]}.") if k == model.n_timesteps_: return Bunch(schedule=schedule, corrected=False, reason="batch_complete", k=k) limits = self.limits_at(k) masks = self._masks_at(k) observed = self._observed_series(batch_so_far, initial_conditions, k) features = pd.Index(model.feature_columns_) # --- The no-change prediction, and the SPE validity gate on the # batch so far. The prediction assumes the currently planned # remainder (the implemented schedule's future rows), which is the # nominal schedule unless an earlier decision point changed it. ------ prediction = self.predict(batch_so_far, initial_conditions=initial_conditions, schedule=schedule, k=k) spe_so_far = prediction.spe_so_far common = { "k": k, "spe_so_far": spe_so_far, "spe_limit_monitor": limits.spe_limit_monitor, "y_hat_no_change": prediction.y_hat, "half_width": prediction.half_width, "condition_number": prediction.condition_number, } if spe_so_far > limits.spe_limit_monitor: return Bunch(schedule=schedule, corrected=False, reason="spe_gate", **common) # --- Assemble the QP inputs. The movement penalty is measured from # the currently planned remainder, for the same reason. --------------- free_labels = list(features[masks.free]) nominal_remaining = pd.Series({(tag, s): float(schedule.iloc[s][tag]) for (tag, s) in free_labels}) seam = {tag: float(schedule.iloc[k - 1][tag]) for tag in self.mv_tags} if k > 0 else None caps: dict[str, float | None] = {} for name, setting, resolved in ( ("spe_cap", self.spe_cap, limits.spe_limit_candidate), ("t2_cap", self.t2_cap, limits.t2_limit), ): if setting == "limit": caps[name] = float(resolved) elif setting is None: caps[name] = None else: caps[name] = float(typing.cast("float", setting)) # --- Dead band (target mode): correct only when the projected # deviation is significant against the prediction interval at this # decision point. ---------------------------------------------------- dead_band_margin = None if self.mode == "target" and (self.dead_band > 0 or self.target_side != "both"): y0 = prediction.y_hat half_width = prediction.half_width.to_numpy(dtype=float) target = pd.Series(self.y_target) if not isinstance(self.y_target, (int, float)) else None target_values = ( target.reindex(model.target_names_).to_numpy(dtype=float) if target is not None else np.array([float(typing.cast("float", self.y_target))]) ) signed = y0.to_numpy(dtype=float) - target_values if self.target_side == "below": deviation = np.maximum(-signed, 0.0) elif self.target_side == "above": deviation = np.maximum(signed, 0.0) else: deviation = np.abs(signed) dead_band_margin = pd.Series(deviation / half_width, index=model.target_names_, name="dead_band_margin") if bool((deviation <= self.dead_band * half_width).all()): return Bunch( schedule=schedule, corrected=False, reason="dead_band", dead_band_margin=dead_band_margin, **common ) result = midcourse_correction( model, observed=observed, free_columns=free_labels, mode=self.mode, y_target=self.y_target if self.mode == "target" else None, weights=self.weights, bounds=self.bounds, rate_limits=self.rate_limits, seam=seam, nominal_remaining=nominal_remaining, spe_cap=caps["spe_cap"], t2_cap=caps["t2_cap"], score_covariance=limits.score_covariance, method=self.method, ridge=self.ridge, n_knots=self.n_knots, ) for (tag, s), value in result.mv.items(): schedule.iloc[s, typing.cast("int", schedule.columns.get_loc(tag))] = float(value) return Bunch( schedule=schedule, corrected=True, reason="corrected", spe_limit_candidate=limits.spe_limit_candidate, t2_limit=limits.t2_limit, y_hat=result.y_hat, dead_band_margin=dead_band_margin, correction=result, **common, )
def _oracle_remaining( simulator: object, z_row: pd.Series, seed: int, k: int, *, n_knots: int = 4, max_evaluations: int = 400, ) -> float: """Best achievable titer when the remaining schedule is optimised against the simulator. Direct search (Nelder-Mead, then Powell from the same start) over ``n_knots`` linearly interpolated knot values per manipulated variable from sample ``k`` onward, with the identical seed, so the disturbances match the batch being corrected. It estimates the ceiling for any mid-course scheme at that decision point: the objective is the true process, not a model of it, and the disturbances still to come are known to it, as they are to no scheme running on a plant. The search is local, within the full operating bounds and without rate limits, so the value is a lower bound on that ceiling. """ from scipy.optimize import minimize # noqa: PLC0415 nominal = simulator.nominal_trajectory() # type: ignore[attr-defined] config = simulator.config # type: ignore[attr-defined] n_samples = nominal.shape[0] m = n_samples - k knot_positions = np.linspace(0, m - 1, min(n_knots, m)) limits = {"temperature": config.temp_bounds, "pH": config.ph_bounds} tags = list(nominal.columns) n_per = len(knot_positions) def build(v: np.ndarray) -> pd.DataFrame: trajectory = nominal.copy() for i, tag in enumerate(tags): values = np.interp(np.arange(m), knot_positions, v[i * n_per : (i + 1) * n_per]) low, high = limits[tag] trajectory.iloc[k:, trajectory.columns.get_loc(tag)] = np.clip(values, low, high) return trajectory def negative_titer(v: np.ndarray) -> float: return -float(simulator.simulate_batch(z_row, build(v), random_state=seed).titer) # type: ignore[attr-defined] start = np.concatenate([nominal[tag].iloc[k:].to_numpy()[knot_positions.astype(int)] for tag in tags]) first = minimize(negative_titer, start, method="Nelder-Mead", options={"maxfev": max_evaluations}) second = minimize(negative_titer, start, method="Powell", options={"maxfev": max_evaluations}) return float(-min(first.fun, second.fun)) def _run_decision_points( simulator: object, corrector: MidCourseCorrector, z_row: pd.Series, decision_points: tuple[int, ...], seed: int, ) -> dict: """Run one test batch under replay and under mid-course correction, with the same seed. The batch is first run on the nominal schedule (the replay titer), then walked through the decision points: each correction is executed by re-simulating the batch under the corrected schedule with the identical disturbance history, and the next decision point sees that executed batch. Returns the record fields of the comparison: the replay and mid-course titers, whether and where the batch was first corrected, the corrector's reason, the predicted titer of the first corrected schedule, and the no-change prediction and interval half-width at the first decision point. """ base = simulator.simulate_batch(z_row, random_state=seed) # type: ignore[attr-defined] nominal_index = simulator.nominal_trajectory().index # type: ignore[attr-defined] schedule = None corrected = False reason = None first_k = None y_hat_predicted = np.nan y_hat_no_change = np.nan half_width = np.nan current = base for k in decision_points: outcome = corrector.correct( current.tags.iloc[:k].reset_index(drop=True), initial_conditions=z_row, implemented_schedule=schedule, k=int(k), ) if np.isnan(y_hat_no_change) and "y_hat_no_change" in outcome: y_hat_no_change = float(outcome.y_hat_no_change.iloc[0]) half_width = float(outcome.half_width.iloc[0]) reason = outcome.reason if reason is None or not corrected else reason if outcome.corrected: schedule = outcome.schedule if not corrected: first_k = int(k) y_hat_predicted = float(outcome.y_hat.iloc[0]) corrected = True trajectory = schedule.copy() trajectory.index = nominal_index current = simulator.simulate_batch(z_row, trajectory, random_state=seed) # type: ignore[attr-defined] return { "replay": float(base.titer), "midcourse": float(current.titer), "corrected": corrected, "reason": reason, "decision_point": first_k, "y_hat_predicted": y_hat_predicted, "y_hat_no_change": y_hat_no_change, "half_width": half_width, }
[docs] def evaluate_control_policies( # noqa: PLR0913, PLR0915, C901 - one executed comparison, kept linear simulator: object, *, y_target: float, n_train: int = 200, n_test: int = 40, mv_variation: float = 2.5, n_components: int = 4, decision_points: tuple[int, ...] = (8,), target_side: str = "below", dead_band: float = 1.0, weights: dict | None = None, bounds: dict | None = None, rate_limits: dict | None = None, spe_cap: float | str | None = "limit", t2_cap: float | str | None = "limit", n_knots: int | None = 4, per_class: bool = True, method: str = "tsr", ridge: float = 0.0, include_adapted: bool = True, adapted_n_knots: int = 4, adapted_n_starts: int = 5, oracle: str = "corrected", random_state: int | np.random.Generator | None = None, ) -> Bunch: """Compare batch operating policies on the bioreactor simulator, executed. Runs the full mid-course-correction workflow end to end and reports *realised* (executed) quality, not model predictions: every corrected schedule is fed back into the simulator with the identical seed, so the with- and without-correction titers are true same-batch counterfactuals. Four policies are compared: - **replay**: every batch runs the nominal schedule (the floor; what "replicate the golden batch" automation does). - **mid-course**: batches are corrected at the ``decision_points`` by :class:`MidCourseCorrector` models fitted on a deliberately varied historical campaign; batches the dead band or the validity gate skips run the nominal schedule. - **oracle-from-k**: for every batch the mid-course policy corrected, the remaining schedule is instead optimised against the simulator itself at the same decision point, with the batch's own seed (:func:`_oracle_remaining`). This estimates the ceiling for any mid-course scheme at that decision point. The gap to the mid-course row mixes the error of an empirical model with limited historical excitation, the oracle's knowledge of the disturbances still to come, and the corrector's own limits (movement penalty, validity caps, tighter bounds and rate limits). - **adapted**: every batch runs the schedule that maximises the disturbance-free titer for its own initial conditions, found before the batch starts from the simulator's own equations (``simulator.optimal_trajectory``): feedforward adaptation with a perfect process model, an estimate of the feedforward ceiling. Parameters ---------- simulator : BioreactorSimulator The simulator (duck-typed: needs ``simulate_campaign``, ``simulate_batch``, ``nominal_trajectory``, ``optimal_trajectory`` and ``config``). y_target : float The quality target handed to the corrector (original units). n_train, n_test : int Sizes of the historical (training) campaign and the fresh test campaign. mv_variation : float, default=2.5 Deliberate setpoint variation of the historical campaign; the identification requirement is discussed in :meth:`~process_improve.simulation.BioreactorSimulator.simulate_campaign`. n_components : int, default=4 Components for each :class:`~process_improve.batch.BatchPLS` model. decision_points : tuple of int, default=(8,) Sample indices at which the corrector is consulted, in order; later points see the schedule implemented by earlier ones. per_class : bool, default=True Fit one model per feed class (labels from the training campaign; test batches are assigned to the nearest class centroid in standardised Z). With ``False``, or when class labels are unavailable, a single global model is used. The effect of a schedule change depends on the feed class and one linear model averages it: on the default configuration the global model corrects five batches for a mean executed gain of about 0.5 g/L, against eight batches and about 1.4 g/L per class, so per-class models are the default. target_side, dead_band, weights, bounds, rate_limits, spe_cap, t2_cap, n_knots, method, ridge Corrector settings, passed to :class:`MidCourseCorrector`. ``bounds`` defaults to the simulator's operating bounds tightened inward by about two control-error standard deviations (0.3 degC, 0.04 pH); ``rate_limits`` defaults to 3.0 degC and 0.5 pH per sample; ``weights`` defaults to ``{"target": 1.0, "movement": 0.1}``. include_adapted : bool, default=True Compute the adapted (perfect-feedforward) row; it costs one ``optimal_trajectory`` call per test batch (roughly 10 s each on the default configuration). adapted_n_knots, adapted_n_starts : int Passed to ``optimal_trajectory`` for the adapted policy. oracle : {"corrected", "none"}, default="corrected" Compute the oracle-from-k row for the batches the mid-course policy corrected (a few direct-search optimisations against the simulator), or skip it. random_state : int, np.random.Generator, or None Seed for the campaigns and the per-batch execution seeds; the whole comparison is reproducible end to end. Returns ------- result : sklearn.utils.Bunch With keys ``summary`` (DataFrame: one row per policy with mean, sd, min and max titer), ``batches`` (DataFrame: per-batch replay / mid-course / adapted / oracle titers, the assigned and true feed class, whether and why each batch was or was not corrected, the decision point used, the corrector's predicted quality, and the no-change prediction with its interval half-width at the first decision point), ``n_corrected``, ``n_harmed`` (corrected batches whose executed titer fell more than 0.01 below replay), and ``models`` (per-class fit R2). """ from .._random import check_random_state # noqa: PLC0415 from ._batch_pls import BatchPLS # noqa: PLC0415 if oracle not in ("corrected", "none"): raise ValueError(f"oracle must be 'corrected' or 'none'; got {oracle!r}.") rng = check_random_state(random_state) train_seed = int(rng.integers(2**31)) test_seed = int(rng.integers(2**31)) batch_seeds = rng.integers(2**31, size=int(n_test)) config = simulator.config # type: ignore[attr-defined] nominal = simulator.nominal_trajectory() # type: ignore[attr-defined] nominal_positional = nominal.reset_index(drop=True) mv_tags = list(nominal.columns) if bounds is None: bounds = { "temperature": (config.temp_bounds[0] + 0.3, config.temp_bounds[1] - 0.3), "pH": (config.ph_bounds[0] + 0.04, config.ph_bounds[1] - 0.04), } if rate_limits is None: rate_limits = {"temperature": 3.0, "pH": 0.5} weights = weights or {"target": 1.0, "movement": 0.1} # --- Train the model(s) on a deliberately varied historical campaign --- train = simulator.simulate_campaign( # type: ignore[attr-defined] n_train, policy="historical", mv_variation=mv_variation, random_state=train_seed ) z_train = train.initial_conditions labels = np.asarray(list(train.classes)) usable_classes = per_class and len(set(labels)) > 1 and "?" not in set(labels) z_mean, z_sd = z_train.mean(), z_train.std(ddof=1) z_standardised = (z_train - z_mean) / z_sd def _fit(ids: list) -> BatchPLS: return BatchPLS(n_components=n_components).fit( {i: train.batches[i] for i in ids}, train.quality.loc[ids], initial_conditions=z_train.loc[ids], ) correctors: dict = {} centroids: dict = {} fit_r2: dict = {} groups = sorted(set(labels)) if usable_classes else ["all"] for group in groups: ids = ( list(train.batches) if group == "all" else [i for i, c in zip(train.batches, labels, strict=True) if c == group] ) model = _fit(ids) fit_r2[group] = float(model.r2_cumulative_.iloc[-1]) centroids[group] = z_standardised.loc[ids].mean() correctors[group] = MidCourseCorrector( model, nominal_positional, mv_tags=mv_tags, mode="target", y_target=y_target, target_side=target_side, dead_band=dead_band, weights=weights, bounds=bounds, rate_limits=rate_limits, spe_cap=spe_cap, t2_cap=t2_cap, method=method, ridge=ridge, n_knots=n_knots, ) def _assign(z_row: pd.Series) -> str: z_std = (z_row - z_mean) / z_sd return min(centroids, key=lambda g: float(((z_std - centroids[g]) ** 2).sum())) # --- Fresh test batches, every policy executed with the same seed ------ test = simulator.simulate_campaign(n_test, policy="replay", random_state=test_seed) # type: ignore[attr-defined] z_test = test.initial_conditions records = [] for position, batch_id in enumerate(z_test.index): seed = int(batch_seeds[position]) z_row = z_test.loc[batch_id] group = _assign(z_row) record = {"batch_id": batch_id, "class_true": list(test.classes)[position], "class_assigned": group} record.update(_run_decision_points(simulator, correctors[group], z_row, decision_points, seed)) corrected, first_k = record["corrected"], record["decision_point"] if include_adapted: best = simulator.optimal_trajectory( # type: ignore[attr-defined] z_row, n_knots=adapted_n_knots, n_starts=adapted_n_starts, random_state=0 ) record["adapted"] = float( simulator.simulate_batch(z_row, best.trajectory, random_state=seed).titer # type: ignore[attr-defined] ) if oracle == "corrected" and corrected: record["oracle_from_k"] = _oracle_remaining(simulator, z_row, seed, typing.cast("int", first_k)) records.append(record) batches = pd.DataFrame(records).set_index("batch_id") def _row(values: pd.Series) -> dict: return { "mean": float(values.mean()), "sd": float(values.std(ddof=1)), "min": float(values.min()), "max": float(values.max()), } summary_rows = {"replay": _row(batches["replay"]), "midcourse": _row(batches["midcourse"])} if oracle == "corrected" and batches.get("oracle_from_k") is not None and batches["oracle_from_k"].notna().any(): oracle_full = batches["oracle_from_k"].fillna(batches["midcourse"]) summary_rows["oracle_from_k"] = _row(oracle_full) if include_adapted: summary_rows["adapted"] = _row(batches["adapted"]) summary = pd.DataFrame(summary_rows).T corrected_mask = batches["corrected"] harmed = int(((batches["midcourse"] - batches["replay"]) < -0.01)[corrected_mask].sum()) return Bunch( summary=summary, batches=batches, n_corrected=int(corrected_mask.sum()), n_harmed=harmed, models=Bunch(fit_r2=fit_r2, per_class=usable_classes, groups=groups), )