diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index a8800fc..620ad9b 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -53,10 +53,10 @@ jobs: run: | if [ "${{ steps.mode.outputs.mode }}" == "fast" ]; then echo "Draft PR: Skipping notebook and pyomo tests for fast feedback" - pytest tests/ -n auto -v -m "not notebook and not pyomo" + pytest tests/ -n auto -v -m "not notebook and not pyomo" --ignore=tests/test_pyomo_models else echo "Ready PR: Running full scipy test suite (excluding notebook and pyomo)" - pytest tests/ -n auto -v -m "not notebook and not pyomo" --cov=lyopronto --cov-report=xml:coverage.xml + pytest tests/ -n auto -v -m "not notebook and not pyomo" --ignore=tests/test_pyomo_models --cov=lyopronto --cov-report=xml:coverage.xml fi - name: Upload coverage diff --git a/.github/workflows/rundocs.yml b/.github/workflows/rundocs.yml index e56fa60..c8fc41f 100644 --- a/.github/workflows/rundocs.yml +++ b/.github/workflows/rundocs.yml @@ -37,7 +37,7 @@ jobs: pip install -e . --no-build-isolation - name: Run tests (draft = fast, ready = coverage) - run: pytest tests/ -n auto -v -m "notebook" --cov=lyopronto --cov-report=xml --cov-report=term-missing + run: pytest tests/ -n auto -v -m "notebook" --ignore=tests/test_pyomo_models --cov=lyopronto --cov-report=xml --cov-report=term-missing # jobs: # doctests: # # env: diff --git a/.github/workflows/slow-tests.yml b/.github/workflows/slow-tests.yml index 20df2e0..11c4832 100644 --- a/.github/workflows/slow-tests.yml +++ b/.github/workflows/slow-tests.yml @@ -74,7 +74,7 @@ jobs: if [ "${{ inputs.include_pyomo }}" == "true" ]; then run_pytest pytest tests/ -n auto -v --cov=lyopronto --cov-report=xml --cov-report=term-missing else - run_pytest pytest tests/ -n auto -v -m "not pyomo" --cov=lyopronto --cov-report=xml --cov-report=term-missing + run_pytest pytest tests/ -n auto -v -m "not pyomo" --ignore=tests/test_pyomo_models --cov=lyopronto --cov-report=xml --cov-report=term-missing fi else echo "Running ONLY slow tests (marked with @pytest.mark.slow)" @@ -82,7 +82,7 @@ jobs: if [ "${{ inputs.include_pyomo }}" == "true" ]; then run_pytest pytest tests/ -n auto -v -m "slow" --cov=lyopronto --cov-report=xml --cov-report=term-missing else - run_pytest pytest tests/ -n auto -v -m "slow and not pyomo" --cov=lyopronto --cov-report=xml --cov-report=term-missing + run_pytest pytest tests/ -n auto -v -m "slow and not pyomo" --ignore=tests/test_pyomo_models --cov=lyopronto --cov-report=xml --cov-report=term-missing fi fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e3be451..04b8359 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,7 @@ jobs: - name: Run SciPy tests (excluding Pyomo tests) run: | echo "Running SciPy test suite (excluding Pyomo tests)" - pytest tests/ -n auto -v -m "not pyomo" --cov=lyopronto --cov-report=xml --cov-report=term-missing + pytest tests/ -n auto -v -m "not pyomo" --ignore=tests/test_pyomo_models --cov=lyopronto --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/lyopronto/pyomo_models/single_step.py b/lyopronto/pyomo_models/single_step.py new file mode 100644 index 0000000..d52d677 --- /dev/null +++ b/lyopronto/pyomo_models/single_step.py @@ -0,0 +1,427 @@ +"""Single time-step Pyomo optimization for lyophilization primary drying. + +This module provides a Pyomo-based NLP formulation that replicates one time step +of the scipy sequential optimization approach. It solves for optimal chamber +pressure (Pch) and shelf temperature (Tsh) at a given dried cake length (Lck). + +Physics Corrections (Jan 2025): + - Energy balance: Corrected to match multi-period model (Q_shelf = Q_sublimation) + - Vial bottom temp: Added proper constraint for frozen layer temperature gradient + - Matches corrected multi-period formulation + +The model includes: + - 7 decision variables: Pch, Tsh, Tsub, Tbot, Psub, dmdt, Kv + - 5 equality constraints: vapor pressure, sublimation rate, energy balance, + vial bottom temperature, vial heat transfer + - 2 inequality constraints: equipment capability, product temperature limit + - Objective: maximize sublimation driving force (minimize Pch - Psub) +""" + +# LyoPRONTO, a vial-scale lyophilization process simulator +# Nonlinear optimization +# Copyright (C) 2025, David E. Bernal Neira + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pyomo.environ as pyo + +from .. import constant + + +def create_single_step_model( + vial, + product, + ht, + Lpr0, + Lck, + Pch_bounds=(0.05, 0.5), + Tsh_bounds=(-50, 50), + eq_cap=None, + nVial=None, + apply_scaling=True, +): + """Create Pyomo model for single time-step lyophilization optimization. + + This function constructs a ConcreteModel that represents the physics and + constraints of lyophilization at a single time step. The model finds optimal + chamber pressure and shelf temperature to maximize sublimation rate while + respecting equipment and product temperature constraints. + + Args: + vial (dict): Vial geometry with keys: + - 'Av' (float): Vial area [cm²] + - 'Ap' (float): Product area [cm²] + product (dict): Product properties with keys: + - 'R0' (float): Base product resistance [cm²·hr·Torr/g] + - 'A1' (float): Resistance parameter [cm·hr·Torr/g] + - 'A2' (float): Resistance parameter [1/cm] + - 'T_pr_crit' (float): Critical product temperature [°C] + ht (dict): Heat transfer parameters with keys: + - 'KC' (float): Vial heat transfer parameter [cal/s/K/cm²] + - 'KP' (float): Vial heat transfer parameter [cal/s/K/cm²/Torr] + - 'KD' (float): Vial heat transfer parameter [1/Torr] + Lpr0 (float): Initial product length [cm] + Lck (float): Current dried cake length [cm] + Pch_bounds (tuple, optional): (min, max) for chamber pressure [Torr]. + Default: (0.05, 0.5) + Tsh_bounds (tuple, optional): (min, max) for shelf temperature [°C]. + Default: (-50, 50) + eq_cap (dict, optional): Equipment capability parameters with keys: + - 'a' (float): Equipment capability parameter [kg/hr] + - 'b' (float): Equipment capability parameter [kg/hr/Torr] + If None, equipment constraint is not enforced. + nVial (int, optional): Number of vials in batch. Required if eq_cap provided. + + Returns: + pyo.ConcreteModel: Pyomo model ready to solve with variables, constraints, + and objective defined. + + Notes: + The model uses the following physics equations (corrected Jan 2025): + - Vapor pressure: Psub = 2.698e10 * exp(-6144.96/(Tsub + 273.15)) + - Product resistance: Rp = R0 + A1*Lck/(1 + A2*Lck) + - Vial heat transfer: Kv = KC + KP*Pch/(1 + KD*Pch) + - Sublimation rate: dmdt = Ap/Rp * (Psub - Pch) / kg_To_g + - Energy balance: Kv*Av*(Tsh - Tbot) = dHs*dmdt*kg_To_g/hr_To_s + - Vial bottom temp: Tbot = Tsub + (Lpr0-Lck)*dHs*dmdt*kg_To_g/hr_To_s/(Ap*k_ice) + + Examples: + >>> from lyopronto import functions + >>> vial = {'Av': 3.80, 'Ap': 3.14} + >>> product = {'R0': 1.4, 'A1': 16.0, 'A2': 0.0, 'T_pr_crit': -5.0} + >>> ht = {'KC': 2.75e-4, 'KP': 8.93e-4, 'KD': 0.46} + >>> Lpr0 = functions.Lpr0_FUN(2.0, 3.14, 0.05) + >>> Lck = 0.5 # Half dried + >>> model = create_single_step_model(vial, product, ht, Lpr0, Lck) + >>> # Now solve with solve_single_step(model) + """ + model = pyo.ConcreteModel() + + # ==================== Parameters (Fixed for this step) ==================== + model.Lpr0 = pyo.Param(initialize=Lpr0) + model.Lck = pyo.Param(initialize=Lck) + model.Av = pyo.Param(initialize=vial["Av"]) + model.Ap = pyo.Param(initialize=vial["Ap"]) + model.R0 = pyo.Param(initialize=product["R0"]) + model.A1 = pyo.Param(initialize=product["A1"]) + model.A2 = pyo.Param(initialize=product["A2"]) + model.T_crit = pyo.Param(initialize=product["T_pr_crit"]) + model.KC = pyo.Param(initialize=ht["KC"]) + model.KP = pyo.Param(initialize=ht["KP"]) + model.KD = pyo.Param(initialize=ht["KD"]) + + # Physical constants + model.kg_To_g = pyo.Param(initialize=constant.kg_To_g) + model.hr_To_s = pyo.Param(initialize=constant.hr_To_s) + model.k_ice = pyo.Param(initialize=constant.k_ice) + model.dHs = pyo.Param(initialize=constant.dHs) + + # ==================== Decision Variables ==================== + # Chamber pressure [Torr] + model.Pch = pyo.Var(domain=pyo.NonNegativeReals, bounds=Pch_bounds) + + # Shelf temperature [°C] + model.Tsh = pyo.Var(domain=pyo.Reals, bounds=Tsh_bounds) + + # Sublimation front temperature [°C] - always below freezing + model.Tsub = pyo.Var(domain=pyo.Reals, bounds=(-60, 0)) + + # Vial bottom temperature [°C] + model.Tbot = pyo.Var(domain=pyo.Reals, bounds=(-60, 50)) + + # Vapor pressure at sublimation front [Torr] + model.Psub = pyo.Var(domain=pyo.NonNegativeReals, bounds=(1e-6, 10)) + + # Log of vapor pressure (for numerical stability in exponential constraint) + model.log_Psub = pyo.Var( + domain=pyo.Reals, bounds=(-14, 2.5) + ) # log(1e-6) to log(10) + + # Sublimation rate [kg/hr] - must be non-negative + model.dmdt = pyo.Var(domain=pyo.NonNegativeReals, bounds=(0, 10)) + + # Vial heat transfer coefficient [cal/s/K/cm²] + model.Kv = pyo.Var(domain=pyo.PositiveReals, bounds=(1e-6, 1e-2)) + + # ==================== Derived Quantities (Expressions) ==================== + # Product resistance [cm²·hr·Torr/g] + model.Rp = pyo.Expression( + expr=model.R0 + model.A1 * model.Lck / (1 + model.A2 * model.Lck) + ) + + # ==================== Equality Constraints ==================== + # C1: Vapor pressure at sublimation front (Antoine equation) + # Using log transformation for numerical stability: + # log(Psub) = log(2.698e10) - 6144.96/(Tsub + 273.15) + model.vapor_pressure_log = pyo.Constraint( + expr=model.log_Psub == pyo.log(2.698e10) - 6144.96 / (model.Tsub + 273.15) + ) + + # C1b: Link log_Psub to Psub + model.vapor_pressure_exp = pyo.Constraint( + expr=model.Psub == pyo.exp(model.log_Psub) + ) + + # C2: Sublimation rate from mass transfer + # dmdt = Ap/Rp * (Psub - Pch) / kg_To_g + model.sublimation_rate = pyo.Constraint( + expr=model.dmdt + == model.Ap / model.Rp / model.kg_To_g * (model.Psub - model.Pch) + ) + + # C3: Energy balance - heat from shelf equals heat for sublimation + # Q_shelf = Kv * Av * (Tsh - Tbot) + # Q_sub = dHs * dmdt * kg_To_g / hr_To_s + # This matches the corrected multi-period energy_balance constraint + model.energy_balance = pyo.Constraint( + expr=model.Kv * model.Av * (model.Tsh - model.Tbot) + == model.dHs * model.dmdt * model.kg_To_g / model.hr_To_s + ) + + # C4: Vial bottom temperature from conduction through frozen layer + # Tbot = Tsub + ΔT_frozen + # where ΔT_frozen = (Lpr0 - Lck) * Q_sub / (Ap * k_ice) + # This matches the corrected multi-period vial_bottom_temp constraint + model.vial_bottom_temp = pyo.Constraint( + expr=model.Tbot + == model.Tsub + + (model.Lpr0 - model.Lck) + * model.dHs + * model.dmdt + * model.kg_To_g + / model.hr_To_s + / (model.Ap * model.k_ice) + ) + + # C5: Vial heat transfer coefficient + # Kv = KC + KP * Pch / (1 + KD * Pch) + model.kv_calc = pyo.Constraint( + expr=model.Kv == model.KC + model.KP * model.Pch / (1 + model.KD * model.Pch) + ) + + # ==================== Inequality Constraints ==================== + # Product temperature limit: Tbot ≤ T_crit + model.temp_limit = pyo.Constraint(expr=model.Tbot <= model.T_crit) + + # Equipment capability limit (optional) + if eq_cap is not None and nVial is not None: + model.a_eq = pyo.Param(initialize=eq_cap["a"]) + model.b_eq = pyo.Param(initialize=eq_cap["b"]) + model.nVial = pyo.Param(initialize=nVial) + + # a + b*Pch - nVial*dmdt ≥ 0 + model.equipment_capability = pyo.Constraint( + expr=model.a_eq + model.b_eq * model.Pch - model.nVial * model.dmdt >= 0 + ) + + # ==================== Objective Function ==================== + # Minimize (Pch - Psub) to maximize sublimation driving force (Psub - Pch) + model.obj = pyo.Objective(expr=model.Pch - model.Psub, sense=pyo.minimize) + + # ==================== Scaling (Optional) ==================== + if apply_scaling: + from . import utils + + scaling_factors = { + "Tsub": 0.01, + "Tbot": 0.01, + "Tsh": 0.01, + "Pch": 10, + "Psub": 10, + "log_Psub": 1.0, # log values are already scaled + "Kv": 1e4, + "dmdt": 1.0, + } + utils.add_scaling_suffix(model, scaling_factors) + + return model + + +def solve_single_step(model, solver="ipopt", tee=False, warmstart_data=None): + """Solve Pyomo single-step model and extract results. + + Args: + model (pyo.ConcreteModel): Pyomo model created by create_single_step_model() + solver (str, optional): Solver name. Default: 'ipopt' + tee (bool, optional): If True, print solver output. Default: False + warmstart_data (dict, optional): Initial values for variables with keys: + 'Pch', 'Tsh', 'Tsub', 'Tbot', 'Psub', 'dmdt', 'Kv'. + If provided, these values are used to initialize the model. + + Returns: + dict: Solution dictionary with keys: + - 'status' (str): Solver termination condition + - 'Pch' (float): Chamber pressure [Torr] + - 'Tsh' (float): Shelf temperature [°C] + - 'Tsub' (float): Sublimation front temperature [°C] + - 'Tbot' (float): Vial bottom temperature [°C] + - 'Psub' (float): Vapor pressure [Torr] + - 'dmdt' (float): Sublimation rate [kg/hr] + - 'Kv' (float): Vial heat transfer coefficient [cal/s/K/cm²] + - 'Rp' (float): Product resistance [cm²·hr·Torr/g] + - 'obj' (float): Objective value + + Raises: + RuntimeError: If solver is not available or fails to solve. + + Notes: + For IPOPT solver, the following options are used: + - max_iter: 3000 + - tol: 1e-6 + - mu_strategy: 'adaptive' + - print_level: 5 (if tee=True), 0 (if tee=False) + + If IPOPT is not in PATH, will attempt to use IDAES-provided IPOPT. + + Examples: + >>> model = create_single_step_model(vial, product, ht, Lpr0, Lck) + >>> solution = solve_single_step(model, tee=True) + >>> print(f"Optimal Pch: {solution['Pch']:.4f} Torr") + >>> print(f"Optimal Tsh: {solution['Tsh']:.2f} °C") + """ + # Apply warmstart values if provided + if warmstart_data is not None: + if "Pch" in warmstart_data: + model.Pch.set_value(warmstart_data["Pch"]) + if "Tsh" in warmstart_data: + model.Tsh.set_value(warmstart_data["Tsh"]) + if "Tsub" in warmstart_data: + model.Tsub.set_value(warmstart_data["Tsub"]) + if "Tbot" in warmstart_data: + model.Tbot.set_value(warmstart_data["Tbot"]) + if "Psub" in warmstart_data: + model.Psub.set_value(warmstart_data["Psub"]) + if "log_Psub" in warmstart_data: + model.log_Psub.set_value(warmstart_data["log_Psub"]) + if "dmdt" in warmstart_data: + model.dmdt.set_value(warmstart_data["dmdt"]) + if "Kv" in warmstart_data: + model.Kv.set_value(warmstart_data["Kv"]) + + # Create solver - try IDAES first if available, then standard Pyomo + if solver.lower() == "ipopt": + try: + from idaes.core.solvers import get_solver + + opt = get_solver("ipopt") + except (ImportError, Exception): + # Fall back to standard Pyomo solver + opt = pyo.SolverFactory(solver) + else: + opt = pyo.SolverFactory(solver) + + # Configure IPOPT options + if solver.lower() == "ipopt": + opt.options["max_iter"] = 3000 + opt.options["tol"] = 1e-6 + opt.options["mu_strategy"] = "adaptive" + opt.options["print_level"] = 5 if tee else 0 + + # Solve + results = opt.solve(model, tee=tee) + + # Check convergence + termination = results.solver.termination_condition + status_str = str(termination) + + if termination not in [ + pyo.TerminationCondition.optimal, + pyo.TerminationCondition.locallyOptimal, + ]: + print(f"WARNING: Solver status: {termination}") + + # Extract solution + solution = { + "status": status_str, + "Pch": pyo.value(model.Pch), + "Tsh": pyo.value(model.Tsh), + "Tsub": pyo.value(model.Tsub), + "Tbot": pyo.value(model.Tbot), + "Psub": pyo.value(model.Psub), + "log_Psub": pyo.value(model.log_Psub), + "dmdt": pyo.value(model.dmdt), + "Kv": pyo.value(model.Kv), + "Rp": pyo.value(model.Rp), + "obj": pyo.value(model.obj), + } + + return solution + + +def optimize_single_step( + vial, + product, + ht, + Lpr0, + Lck, + Pch_bounds=(0.05, 0.5), + Tsh_bounds=(-50, 50), + eq_cap=None, + nVial=None, + warmstart_data=None, + solver="ipopt", + tee=False, + apply_scaling=True, +): + """Convenience function to create and solve single-step model in one call. + + This function combines create_single_step_model() and solve_single_step() + for ease of use when you don't need to inspect the model structure. + + Args: + vial (dict): Vial geometry (see create_single_step_model) + product (dict): Product properties (see create_single_step_model) + ht (dict): Heat transfer parameters (see create_single_step_model) + Lpr0 (float): Initial product length [cm] + Lck (float): Current dried cake length [cm] + Pch_bounds (tuple): Chamber pressure bounds [Torr] + Tsh_bounds (tuple): Shelf temperature bounds [°C] + eq_cap (dict): Equipment capability parameters + nVial (int): Number of vials + warmstart_data (dict): Initial variable values + solver (str): Solver name (default: 'ipopt') + tee (bool): Print solver output (default: False) + apply_scaling (bool): Apply variable scaling (default: True) + + Returns: + dict: Solution dictionary (see solve_single_step) + + Examples: + >>> solution = optimize_single_step( + ... vial={'Av': 3.8, 'Ap': 3.14}, + ... product={'R0': 1.4, 'A1': 16.0, 'A2': 0.0, 'T_pr_crit': -5.0}, + ... ht={'KC': 2.75e-4, 'KP': 8.93e-4, 'KD': 0.46}, + ... Lpr0=1.5, + ... Lck=0.5, + ... tee=True + ... ) + """ + # Create and solve + model = create_single_step_model( + vial, + product, + ht, + Lpr0, + Lck, + Pch_bounds=Pch_bounds, + Tsh_bounds=Tsh_bounds, + eq_cap=eq_cap, + nVial=nVial, + apply_scaling=apply_scaling, + ) + solution = solve_single_step( + model, solver=solver, tee=tee, warmstart_data=warmstart_data + ) + + return solution diff --git a/lyopronto/pyomo_models/utils.py b/lyopronto/pyomo_models/utils.py new file mode 100644 index 0000000..7f4e94f --- /dev/null +++ b/lyopronto/pyomo_models/utils.py @@ -0,0 +1,254 @@ +"""Utility functions for Pyomo model initialization, scaling, and result extraction. + +This module provides helper functions to bridge scipy and Pyomo implementations, +including warmstarting Pyomo models from scipy solutions and converting results +to standard output formats. +""" + +# LyoPRONTO, a vial-scale lyophilization process simulator +# Nonlinear optimization +# Copyright (C) 2025, David E. Bernal Neira + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import numpy as np + +from .. import constant, functions + + +def initialize_from_scipy(scipy_output, time_index, vial, product, Lpr0, ht=None): + """Create warmstart data dictionary from scipy optimization output. + + This function extracts values from a scipy optimization result array + and formats them as a dictionary suitable for initializing Pyomo variables. + + Args: + scipy_output (np.ndarray): Output from opt_Pch_Tsh.dry() or similar, + with shape (n_steps, 7) and columns: + [time, Tsub, Tbot, Tsh, Pch_mTorr, flux, percent_dried (0-100)] + time_index (int): Index of the time step to extract (0-based) + vial (dict): Vial geometry with 'Av', 'Ap' keys + product (dict): Product properties with 'R0', 'A1', 'A2' keys + Lpr0 (float): Initial product length [cm] + ht (dict, optional): Heat transfer parameters with 'KC', 'KP', 'KD' keys. + If provided, Kv will be computed accurately. + + Returns: + dict: Warmstart data with keys: 'Pch', 'Tsh', 'Tsub', 'Tbot', 'Psub', + 'log_Psub', 'dmdt', 'Kv' + + Notes: + - Pch is converted from mTorr to Torr (divides by 1000) + - Lck is calculated from percent_dried (column 6 is 0-100%, converted to fraction) + - Derived quantities (Psub, dmdt, Kv) are computed from physics functions + + Examples: + >>> from lyopronto import opt_Pch_Tsh + >>> scipy_out = opt_Pch_Tsh.dry(vial, product, ht, Pch, Tsh, dt, eq_cap, nVial) + >>> warmstart = initialize_from_scipy(scipy_out, 10, vial, product, Lpr0) + >>> # Use warmstart dict to initialize Pyomo model + """ + # Extract values from scipy output + # Columns: [time, Tsub, Tbot, Tsh, Pch_mTorr, flux, percent_dried] + Tsub = scipy_output[time_index, 1] + Tbot = scipy_output[time_index, 2] + Tsh = scipy_output[time_index, 3] + Pch = scipy_output[time_index, 4] / constant.Torr_to_mTorr # mTorr → Torr + percent_dried = scipy_output[time_index, 6] # 0-100 percentage + + # Calculate derived quantities + frac_dried = percent_dried / 100.0 # Convert percentage to fraction (0-1) + Lck = frac_dried * Lpr0 # Current dried cake length [cm] + Rp = functions.Rp_FUN(Lck, product["R0"], product["A1"], product["A2"]) + Psub = functions.Vapor_pressure(Tsub) + dmdt = functions.sub_rate(vial["Ap"], Rp, Tsub, Pch) + + # Calculate Kv from heat transfer parameters if available + if ht is not None: + Kv = functions.Kv_FUN(ht["KC"], ht["KP"], ht["KD"], Pch) + else: + # Use typical value as fallback + Kv = 5e-4 # Typical value [cal/s/K/cm²] + + warmstart_data = { + "Pch": Pch, + "Tsh": Tsh, + "Tsub": Tsub, + "Tbot": Tbot, + "Psub": Psub, + "log_Psub": np.log(max(Psub, 1e-10)), # Add log for stability + "dmdt": max(0.0, dmdt), # Ensure non-negative + "Kv": Kv, + } + + return warmstart_data + + +def extract_solution_to_array(solution, time): + """Convert Pyomo solution dict to standard output array format. + + This function formats a Pyomo solution to match the scipy output format + for consistency and comparison. + + Args: + solution (dict): Solution from solve_single_step() with keys: + 'Pch', 'Tsh', 'Tsub', 'Tbot', 'dmdt', etc. + time (float): Time value for this step [hr] + + Returns: + np.ndarray: Array of shape (7,) with columns: + [time, Tsub, Tbot, Tsh, Pch_mTorr, flux, percent_dried] + + Notes: + - Pch is converted from Torr to mTorr + - flux is dmdt normalized by product area + - percent_dried must be computed externally (requires Lck and Lpr0) + + Examples: + >>> solution = solve_single_step(model) + >>> output_row = extract_solution_to_array(solution, time=0.5) + """ + # Note: This is a simplified version + # In full implementation, would need vial['Ap'] and Lck/Lpr0 for complete conversion + output = np.array( + [ + time, + solution["Tsub"], + solution["Tbot"], + solution["Tsh"], + solution["Pch"] * constant.Torr_to_mTorr, # Torr → mTorr + solution["dmdt"], # Note: needs conversion to flux [kg/hr/m²] + 0.0, # percent_dried - needs to be computed externally + ] + ) + + return output + + +def add_scaling_suffix(model, variable_scales=None): + """Add scaling factors to Pyomo model for improved numerical conditioning. + + Scaling can significantly improve solver convergence by ensuring all + variables and constraints have similar magnitudes. + + Args: + model (pyo.ConcreteModel): Pyomo model to add scaling to + variable_scales (dict, optional): Custom scaling factors. If None, + uses default scales based on typical variable magnitudes. + Keys are variable names ('Tsub', 'Pch', etc.), values are + scaling factors. + + Returns: + None: Modifies model in place by adding scaling_factor Suffix + + Notes: + Default scaling factors: + - Temperature variables (Tsub, Tbot, Tsh): 0.01 (typical ~-20°C) + - Pressure variables (Pch, Psub): 10 (typical ~0.1 Torr) + - Kv: 1e4 (typical ~1e-4) + - dmdt: 1.0 + + Examples: + >>> model = create_single_step_model(...) + >>> add_scaling_suffix(model) # Use defaults + >>> # Or with custom scales: + >>> add_scaling_suffix(model, {'Pch': 5, 'Tsh': 0.02}) + """ + import pyomo.environ as pyo + + # Default scales + default_scales = { + "Tsub": 0.01, # Typical value ~-20°C + "Tbot": 0.01, + "Tsh": 0.01, + "Pch": 10, # Typical value ~0.1 Torr + "Psub": 10, + "Kv": 1e4, # Typical value ~1e-4 + "dmdt": 1.0, + } + + # Use custom scales if provided, otherwise defaults + scales = variable_scales if variable_scales is not None else default_scales + + # Create scaling suffix + model.scaling_factor = pyo.Suffix(direction=pyo.Suffix.EXPORT) + + # Apply scaling factors + for var_name, scale in scales.items(): + if hasattr(model, var_name): + var = getattr(model, var_name) + model.scaling_factor[var] = scale + + +def check_solution_validity(solution, tol=1e-3): + """Validate that a Pyomo solution satisfies physical constraints. + + Args: + solution (dict): Solution dictionary from solve_single_step() + tol (float, optional): Tolerance for constraint violations. Default: 1e-3 + + Returns: + tuple: (is_valid, violations) where: + - is_valid (bool): True if all checks pass + - violations (list): List of violation messages + + Notes: + Checks performed: + - Temperature ordering: Tsub ≤ Tbot ≤ Tsh + - Sublimation temperature below freezing: Tsub < 0 + - Positive pressures and rates + - Vapor pressure consistency + + Examples: + >>> solution = solve_single_step(model) + >>> is_valid, violations = check_solution_validity(solution) + >>> if not is_valid: + ... print("Violations:", violations) + """ + violations = [] + + # Temperature ordering + if solution["Tsub"] > solution["Tbot"] + tol: + violations.append( + f"Tsub ({solution['Tsub']:.2f}) > Tbot ({solution['Tbot']:.2f})" + ) + + if solution["Tbot"] > solution["Tsh"] + tol: + violations.append( + f"Tbot ({solution['Tbot']:.2f}) > Tsh ({solution['Tsh']:.2f})" + ) + + # Sublimation temperature below freezing + if solution["Tsub"] > 0 + tol: + violations.append(f"Tsub ({solution['Tsub']:.2f}) above freezing") + + # Positive values + if solution["Pch"] < -tol: + violations.append(f"Negative Pch: {solution['Pch']:.4f}") + + if solution["Psub"] < -tol: + violations.append(f"Negative Psub: {solution['Psub']:.4f}") + + if solution["dmdt"] < -tol: + violations.append(f"Negative dmdt: {solution['dmdt']:.6f}") + + # Driving force check (Psub should be > Pch for sublimation) + if solution["Psub"] < solution["Pch"] - tol: + violations.append( + f"Psub ({solution['Psub']:.4f}) < Pch ({solution['Pch']:.4f})" + ) + + is_valid = len(violations) == 0 + + return is_valid, violations diff --git a/tests/test_pyomo_models/README.md b/tests/test_pyomo_models/README.md new file mode 100644 index 0000000..7bb5618 --- /dev/null +++ b/tests/test_pyomo_models/README.md @@ -0,0 +1,29 @@ +# Pyomo Models Test Suite + +Tests for the Pyomo-based optimization models in `lyopronto/pyomo_models/`. + +## Test Organization + +| Category | Files | Purpose | +|----------|-------|---------| +| **Model Tests** | `test_model_*.py` | Model creation, structure, warmstart | +| **Optimizer Tests** | `test_optimizer_*.py` | Optimizer functions (Tsh, Pch, Pch_Tsh) | +| **Infrastructure** | `test_parameter_validation.py`, `test_warmstart.py`, `test_staged_solve.py` | Validation, warmstart, staged solve | + +## Running Tests + +```bash +# Run all Pyomo tests +pytest tests/test_pyomo_models/ -v + +# Run with coverage +pytest tests/test_pyomo_models/ --cov=lyopronto.pyomo_models + +# Skip if Pyomo not installed +pytest tests/test_pyomo_models/ -v # Tests auto-skip if Pyomo unavailable +``` + +## Known Limitations + +- `test_block_triangularization` in `test_model_multi_period.py` is xfailed due to DOF from initial conditions (structural analysis limitation, not a functional bug) +- Some tests require optional dependencies (PyNumero, incidence_analysis) diff --git a/tests/test_pyomo_models/test_model_single_step.py b/tests/test_pyomo_models/test_model_single_step.py new file mode 100644 index 0000000..6ac8fe0 --- /dev/null +++ b/tests/test_pyomo_models/test_model_single_step.py @@ -0,0 +1,307 @@ +"""Tests for Pyomo single time-step model. + +This module tests the single time-step optimization model (from model.single_step) +against the scipy baseline to ensure correctness and consistency. +""" + +# LyoPRONTO, a vial-scale lyophilization process simulator +# Nonlinear optimization +# Copyright (C) 2025, David E. Bernal Neira + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import numpy as np +import pytest +from lyopronto import functions +from lyopronto.pyomo_models import single_step, utils + +# Try to import pyomo - skip tests if not available +try: + import pyomo.environ as pyo + + PYOMO_AVAILABLE = True +except ImportError: + PYOMO_AVAILABLE = False + +pytestmark = [ + pytest.mark.pyomo, + pytest.mark.skipif(not PYOMO_AVAILABLE, reason="Pyomo not installed"), +] + + +class TestSingleStepModel: + """Tests for single time-step model creation and structure.""" + + def test_model_creation_basic(self, standard_vial, standard_product, standard_ht): + """Test that model can be created with standard inputs.""" + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + Lck = 0.5 # Half dried + + model = single_step.create_single_step_model( + standard_vial, standard_product, standard_ht, Lpr0, Lck + ) + + # Check model type + assert isinstance(model, pyo.ConcreteModel) + + # Check key variables exist + assert hasattr(model, "Pch") + assert hasattr(model, "Tsh") + assert hasattr(model, "Tsub") + assert hasattr(model, "Tbot") + assert hasattr(model, "Psub") + assert hasattr(model, "dmdt") + assert hasattr(model, "Kv") + + # Check constraints exist (updated Jan 2025 for corrected physics) + assert hasattr(model, "vapor_pressure_log") + assert hasattr(model, "vapor_pressure_exp") + assert hasattr(model, "sublimation_rate") + assert hasattr(model, "energy_balance") # Renamed from heat_balance + assert hasattr(model, "vial_bottom_temp") # Renamed from shelf_temp + assert hasattr(model, "kv_calc") + assert hasattr(model, "temp_limit") + + # Check log_Psub variable exists + assert hasattr(model, "log_Psub") + + # Check objective exists + assert hasattr(model, "obj") + + def test_model_with_equipment_constraint( + self, standard_vial, standard_product, standard_ht + ): + """Test model creation with equipment capability constraint.""" + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + Lck = 0.5 + eq_cap = {"a": -0.182, "b": 11.7} + nVial = 398 + + model = single_step.create_single_step_model( + standard_vial, + standard_product, + standard_ht, + Lpr0, + Lck, + eq_cap=eq_cap, + nVial=nVial, + ) + + # Check equipment constraint exists + assert hasattr(model, "equipment_capability") + assert hasattr(model, "a_eq") + assert hasattr(model, "b_eq") + assert hasattr(model, "nVial") + + def test_variable_bounds(self, standard_vial, standard_product, standard_ht): + """Test that variable bounds are correctly set.""" + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + Lck = 0.5 + Pch_bounds = (0.1, 0.3) + Tsh_bounds = (-40, 30) + + model = single_step.create_single_step_model( + standard_vial, + standard_product, + standard_ht, + Lpr0, + Lck, + Pch_bounds=Pch_bounds, + Tsh_bounds=Tsh_bounds, + ) + + # Check bounds + assert model.Pch.bounds == Pch_bounds + assert model.Tsh.bounds == Tsh_bounds + assert model.Tsub.bounds == (-60, 0) + assert model.dmdt.bounds[0] == 0 # Lower bound must be non-negative + + +class TestSingleStepSolver: + """Tests for solving Pyomo single-step model.""" + + @pytest.mark.slow + def test_solve_basic(self, standard_vial, standard_product, standard_ht): + """Test that model can be solved successfully.""" + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + Lck = 0.5 + + model = single_step.create_single_step_model( + standard_vial, standard_product, standard_ht, Lpr0, Lck + ) + + solution = single_step.solve_single_step(model, tee=False) + + # Check solution structure + assert "status" in solution + assert "Pch" in solution + assert "Tsh" in solution + assert "Tsub" in solution + assert "Tbot" in solution + assert "Psub" in solution + assert "dmdt" in solution + assert "Kv" in solution + + # Check physical validity + assert solution["Tsub"] < 0, "Sublimation temp should be below freezing" + assert solution["Tsub"] <= solution["Tbot"], "Tsub should be <= Tbot" + assert solution["Tbot"] <= solution["Tsh"], "Tbot should be <= Tsh" + assert solution["Pch"] > 0, "Chamber pressure should be positive" + assert solution["Psub"] > 0, "Vapor pressure should be positive" + assert solution["dmdt"] >= 0, "Sublimation rate should be non-negative" + + @pytest.mark.slow + def test_solve_with_warmstart(self, standard_vial, standard_product, standard_ht): + """Test solving with warmstart initialization.""" + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + Lck = 0.5 + + # Create warmstart data with reasonable initial guess + warmstart = { + "Pch": 0.15, + "Tsh": -10.0, + "Tsub": -25.0, + "Tbot": -20.0, + "Psub": 0.5, + "dmdt": 0.5, + "Kv": 5e-4, + } + + model = single_step.create_single_step_model( + standard_vial, standard_product, standard_ht, Lpr0, Lck + ) + + solution = single_step.solve_single_step( + model, warmstart_data=warmstart, tee=False + ) + + # Should solve successfully + assert "Pch" in solution + assert solution["dmdt"] >= 0 + + @pytest.mark.slow + def test_optimize_convenience_function( + self, standard_vial, standard_product, standard_ht + ): + """Test the optimize_single_step convenience function.""" + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + Lck = 0.5 + + solution = single_step.optimize_single_step( + standard_vial, standard_product, standard_ht, Lpr0, Lck, tee=False + ) + + # Check solution is valid + assert "Pch" in solution + assert "Tsh" in solution + assert solution["dmdt"] >= 0 + + +class TestSolutionValidity: + """Tests for solution validation utilities.""" + + def test_check_valid_solution(self): + """Test validation of a physically reasonable solution.""" + solution = { + "Pch": 0.15, + "Tsh": -5.0, + "Tsub": -25.0, + "Tbot": -20.0, + "Psub": 0.5, + "dmdt": 0.5, + "Kv": 5e-4, + } + + is_valid, violations = utils.check_solution_validity(solution) + + assert is_valid, f"Valid solution flagged as invalid: {violations}" + assert len(violations) == 0 + + def test_check_invalid_temperature_ordering(self): + """Test detection of invalid temperature ordering.""" + solution = { + "Pch": 0.15, + "Tsh": -5.0, + "Tsub": -10.0, # Invalid: Tsub > Tbot + "Tbot": -20.0, + "Psub": 0.5, + "dmdt": 0.5, + "Kv": 5e-4, + } + + is_valid, violations = utils.check_solution_validity(solution) + + assert not is_valid + assert any("Tsub" in v and "Tbot" in v for v in violations) + + def test_check_invalid_driving_force(self): + """Test detection of invalid driving force (Psub < Pch).""" + solution = { + "Pch": 0.5, # Invalid: Pch > Psub + "Tsh": -5.0, + "Tsub": -25.0, + "Tbot": -20.0, + "Psub": 0.3, + "dmdt": 0.0, + "Kv": 5e-4, + } + + is_valid, violations = utils.check_solution_validity(solution) + + assert not is_valid + assert any("Psub" in v and "Pch" in v for v in violations) + + +class TestWarmstartUtilities: + """Tests for warmstart initialization from scipy.""" + + def test_initialize_from_scipy_format(self, standard_vial, standard_product): + """Test that warmstart data can be created from scipy output format.""" + # Create mock scipy output (7 columns) + scipy_output = np.array( + [ + [0.0, -25.0, -20.0, -5.0, 150.0, 0.5, 0.3], # time=0 + [0.5, -23.0, -18.0, -3.0, 140.0, 0.55, 0.5], # time=0.5 + ] + ) + + Lpr0 = functions.Lpr0_FUN(2.0, standard_vial["Ap"], standard_product["cSolid"]) + + warmstart = utils.initialize_from_scipy( + scipy_output, + time_index=1, + vial=standard_vial, + product=standard_product, + Lpr0=Lpr0, + ) + + # Check structure + assert "Pch" in warmstart + assert "Tsh" in warmstart + assert "Tsub" in warmstart + assert "Tbot" in warmstart + assert "Psub" in warmstart + assert "dmdt" in warmstart + assert "Kv" in warmstart + + # Check values match scipy output (accounting for unit conversions) + assert np.isclose(warmstart["Tsub"], -23.0, atol=0.1) + assert np.isclose(warmstart["Tbot"], -18.0, atol=0.1) + assert np.isclose(warmstart["Tsh"], -3.0, atol=0.1) + assert np.isclose(warmstart["Pch"], 0.14, rtol=0.1) # 140 mTorr → 0.14 Torr + + # Check derived quantities are reasonable + assert warmstart["Psub"] > 0 + assert warmstart["dmdt"] >= 0 + assert warmstart["Kv"] > 0