Source code for process_improve.experiments.strategy.budget

# (c) Kevin Dunn, 2010-2026. MIT License.

"""Budget allocation logic for multi-stage DOE strategies.

Implements the **25-40-55-15 framework**:
    - Screening:     25-40 % of total budget
    - Optimisation:  40-55 %
    - Confirmation:  5-15 % (minimum 3 runs)

Sources:
    - Montgomery, *Design and Analysis of Experiments*, 10th ed. (25% rule)
    - Stat-Ease SCOR framework
    - NIST Engineering Statistics Handbook section 5.3.3
"""

from __future__ import annotations

import math
from collections.abc import Callable
from typing import Any

from process_improve.experiments.designs_response_surface import dsd_run_count
from process_improve.experiments.designs_screening import plackett_burman_runs

# ---------------------------------------------------------------------------
# Run estimation look-up tables
# ---------------------------------------------------------------------------

# Box-Behnken run counts (excluding center points)
_BBD_RUNS: dict[int, int] = {
    3: 12,
    4: 24,
    5: 40,
    6: 48,
    7: 56,
}

#: Factors from which a CCD's cube is the resolution V half fraction, ``generate_design(...,
#: cube="fractional")``, which is what ``recommend_strategy`` asks for; fewer take the full 2^k cube.
_CCD_HALF_FRACTION_FROM = 6


def _ccd_factorial_runs(n_factors: int) -> int:
    """Return the runs in a CCD's cube: 2^k up to five factors, the 2^(k-1) half fraction from six."""
    return 2 ** (n_factors - 1) if n_factors >= _CCD_HALF_FRACTION_FROM else 2**n_factors


# ---------------------------------------------------------------------------
# Run estimation functions
# ---------------------------------------------------------------------------


def _fractional_factorial_runs(n_factors: int) -> int:
    """Return the runs in the smallest two-level design of resolution IV or more, as ``generate_design`` builds.

    A resolution IV fraction needs at least ``2k`` runs, and a regular fraction reaches
    that bound: 8 runs for four factors, 16 for five to eight, 32 for nine to sixteen, and
    so on. Three or fewer factors have no resolution IV fraction, so they take the full
    factorial.
    """
    if n_factors <= 3:
        return 2**n_factors
    return 2 ** math.ceil(math.log2(2 * n_factors))


def _plackett_burman_runs(n_factors: int) -> int:
    """Smallest multiple of 4 above ``k`` with a Hadamard matrix (what ``generate_design`` builds)."""
    return plackett_burman_runs(n_factors)


def _dsd_runs(n_factors: int) -> int:
    """Return the runs in the DSD ``generate_design`` builds (it needs at least 3 factors)."""
    return dsd_run_count(max(n_factors, 3))


#: Run-count estimate per screening design, keyed by generate_design type (and the old DSD name).
_SCREENING_RUNS: dict[str, Callable[[int], int]] = {
    "plackett_burman": _plackett_burman_runs,
    "dsd": _dsd_runs,
    "definitive_screening": _dsd_runs,
    "fractional_factorial": _fractional_factorial_runs,
    "full_factorial": lambda k: 2**k,
    # Lin's half-fraction of the smallest Hadamard order N with N - 2 >= k: N / 2 runs
    "supersaturated": lambda k: int(math.ceil((k + 2) / 4) * 2),
}


[docs] def estimate_screening_runs(n_factors: int, design_type: str) -> int: """Estimate the number of runs for a screening design. Parameters ---------- n_factors : int Number of factors to screen. design_type : str One of ``"plackett_burman"``, ``"dsd"`` (or ``"definitive_screening"``), ``"fractional_factorial"``, ``"full_factorial"``, ``"supersaturated"``. Any other value gets the Plackett-Burman estimate. Returns ------- int Estimated run count including center points. """ return _SCREENING_RUNS.get(design_type, _plackett_burman_runs)(n_factors)
[docs] def estimate_rsm_runs(n_factors: int, design_type: str, n_center_points: int = 3) -> int: """Estimate the number of runs for an RSM design. Parameters ---------- n_factors : int Number of factors (typically 2-5 after screening). design_type : str One of ``"ccd"``, ``"box_behnken"``, ``"ccd_face_centered"``, ``"d_optimal"``. n_center_points : int Number of center point replicates (default 3). Returns ------- int Estimated run count. For a CCD it is the size ``generate_design`` builds with ``cube="full"`` up to five factors and ``cube="fractional"`` (a half-fraction cube) from six, which is what ``recommend_strategy`` passes. """ if design_type in ("ccd", "ccd_face_centered"): return _ccd_factorial_runs(n_factors) + 2 * n_factors + n_center_points if design_type == "box_behnken": base = _BBD_RUNS.get(n_factors, 0) if base == 0: # BBD not defined for this factor count; fall back to CCD estimate return _ccd_factorial_runs(n_factors) + 2 * n_factors + n_center_points return base + n_center_points if design_type == "d_optimal": # Rough estimate: 1.5x the number of model terms for a quadratic model # Quadratic model terms: 1 + k + k(k-1)/2 + k = 1 + 2k + k(k-1)/2 n_terms = 1 + 2 * n_factors + n_factors * (n_factors - 1) // 2 return math.ceil(1.5 * n_terms) # Fallback: CCD estimate return _ccd_factorial_runs(n_factors) + 2 * n_factors + n_center_points
[docs] def estimate_confirmation_runs(min_runs: int = 3) -> int: """Return the number of confirmation runs. Parameters ---------- min_runs : int Minimum confirmation runs (default 3). Returns ------- int Confirmation run count (always at least 3). """ return max(3, min_runs)
# --------------------------------------------------------------------------- # Budget allocation # ---------------------------------------------------------------------------
[docs] def allocate_budget( # noqa: C901, PLR0913, PLR0915 total_budget: int | None, n_factors: int, needs_screening: bool, needs_rsm: bool, screening_design: str = "plackett_burman", rsm_design: str = "box_behnken", domain_weights: dict[str, float] | None = None, min_confirmation: int = 3, n_center_points: int = 3, ) -> dict[str, Any]: """Allocate a total run budget across experimental stages. Parameters ---------- total_budget : int or None Total runs across all stages. If ``None``, computes an ideal budget. n_factors : int Total number of candidate factors. needs_screening : bool Whether a screening stage is needed. needs_rsm : bool Whether an RSM optimisation stage is needed. screening_design : str Preferred screening design type. rsm_design : str Preferred RSM design type. domain_weights : dict or None Stage-to-fraction mapping from the domain template. min_confirmation : int Minimum confirmation runs (domain-dependent). n_center_points : int Center points for RSM design. Returns ------- dict Keys: ``"screening"``, ``"optimization"``, ``"confirmation"``, ``"total"``, ``"ideal_total"``, ``"is_tight"``, ``"warnings"``. """ weights = domain_weights or {"screening": 0.30, "optimization": 0.50, "confirmation": 0.10} warnings: list[str] = [] # Estimate ideal runs for each stage ideal_screening = estimate_screening_runs(n_factors, screening_design) if needs_screening else 0 # Assume screening reduces to ~3 significant factors for RSM n_rsm_factors = min(n_factors, 3) if needs_screening else n_factors ideal_rsm = estimate_rsm_runs(n_rsm_factors, rsm_design, n_center_points) if needs_rsm else 0 ideal_confirmation = estimate_confirmation_runs(min_confirmation) ideal_total = ideal_screening + ideal_rsm + ideal_confirmation if total_budget is None: # No budget constraint: use ideal allocation return { "screening": ideal_screening, "optimization": ideal_rsm, "confirmation": ideal_confirmation, "total": ideal_total, "ideal_total": ideal_total, "is_tight": False, "warnings": [], } # Check if budget is feasible minimum_feasible = 0 if needs_screening: minimum_feasible += max(n_factors + 1, 4) # Absolute minimum: k+1 runs if needs_rsm: minimum_feasible += n_rsm_factors * 2 + 3 # Bare minimum RSM minimum_feasible += 3 # Minimum confirmation is_tight = total_budget < ideal_total is_very_tight = total_budget < minimum_feasible if is_very_tight: warnings.append( f"Budget of {total_budget} runs is very tight for {n_factors} factors. " f"Minimum feasible is ~{minimum_feasible} runs. " "Consider a single Definitive Screening Design to combine screening and curvature detection." ) # Allocate proportionally, respecting minimums if needs_screening and needs_rsm: # Full multi-stage allocation screening_share = weights.get("screening", 0.30) rsm_share = weights.get("optimization", 0.50) conf_share = weights.get("confirmation", 0.10) alloc_confirmation = max(min_confirmation, int(total_budget * conf_share)) remaining = total_budget - alloc_confirmation if remaining <= 0: alloc_confirmation = min_confirmation remaining = total_budget - alloc_confirmation # Split remaining between screening and RSM total_share = screening_share + rsm_share alloc_screening = max(n_factors + 1, int(remaining * screening_share / total_share)) alloc_rsm = remaining - alloc_screening # Ensure RSM has minimum runs if alloc_rsm < n_rsm_factors * 2 + 3: alloc_rsm = min(n_rsm_factors * 2 + 3, remaining) alloc_screening = remaining - alloc_rsm if alloc_screening < n_factors + 1: alloc_screening = n_factors + 1 alloc_rsm = remaining - alloc_screening elif needs_screening: alloc_screening = total_budget - min_confirmation alloc_rsm = 0 alloc_confirmation = min_confirmation elif needs_rsm: alloc_screening = 0 alloc_rsm = total_budget - min_confirmation alloc_confirmation = min_confirmation else: alloc_screening = 0 alloc_rsm = 0 alloc_confirmation = total_budget # Clamp all values to be non-negative alloc_screening = max(0, alloc_screening) alloc_rsm = max(0, alloc_rsm) alloc_confirmation = max(0, alloc_confirmation) if is_tight and not is_very_tight: warnings.append( f"Budget of {total_budget} runs is tight (ideal is {ideal_total}). " "Designs may have reduced power or fewer center points." ) return { "screening": alloc_screening, "optimization": alloc_rsm, "confirmation": alloc_confirmation, "total": alloc_screening + alloc_rsm + alloc_confirmation, "ideal_total": ideal_total, "is_tight": is_tight, "warnings": warnings, }