SamdGuizani
Trim Space to app-required files only
52af849
Raw
History Blame Contribute Delete
7.11 kB
"""
Coating dissolution models.
Predicts the fraction of drug released over time when coated particles are
placed in a dissolution medium (e.g. USP paddle apparatus).
Available models
----------------
- Zero-order : constant release rate
- First-order : release proportional to remaining undissolved fraction
- Higuchi : square-root-of-time (matrix diffusion)
- Korsmeyer-Peppas: power-law (generalised, n determines mechanism)
Each model returns F(t) in % (0–100).
Fitting
-------
`fit_dissolution` uses scipy.optimize.curve_fit to estimate model parameters
from experimental (t, F%) data.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass
import numpy as np
from scipy.optimize import curve_fit
from ..config import DISSOLUTION
# ── Model functions ────────────────────────────────────────────────────────────
def model_zero_order(t: np.ndarray, k: float) -> np.ndarray:
"""F(t) = kΒ·t, clipped to [0, 100] %."""
return np.clip(k * t, 0.0, 100.0)
def model_first_order(t: np.ndarray, k: float) -> np.ndarray:
"""F(t) = 100Β·(1 βˆ’ exp(βˆ’kΒ·t)) %."""
return 100.0 * (1.0 - np.exp(-k * t))
def model_higuchi(t: np.ndarray, k: float) -> np.ndarray:
"""F(t) = k·√t, clipped to [0, 100] %."""
return np.clip(k * np.sqrt(t), 0.0, 100.0)
def model_korsmeyer_peppas(t: np.ndarray, k: float, n: float) -> np.ndarray:
"""
F(t) = 100Β·kΒ·t^n, clipped to [0, 100] %.
n < 0.45: Fickian diffusion
0.45 < n < 0.89: anomalous transport
n β‰ˆ 0.89: Case II transport (erosion)
"""
return np.clip(100.0 * k * np.power(np.maximum(t, 0.0), n), 0.0, 100.0)
# ── Coating-mass ↔ rate-constant link (diffusion through the EC film) ─────────
def dissolution_k(wg_fraction: float, ssa_cm2g: float) -> float:
"""
First-order dissolution rate constant k [1/s] implied by a coating level.
Diffusion-through-film relation (MODELLING_BACKGROUND_README.md Β§5):
k = m_sample · SSA² · P · ρ_EC / (V_disso · x_EC)
Parameters
----------
wg_fraction : EC mass fraction x_EC = M_coating / M_batch [g/g]
ssa_cm2g : particle specific surface area [cmΒ²/g]
Returns
-------
k [1/s]; ``inf`` when wg_fraction <= 0 (no coating β†’ instant release).
"""
if wg_fraction <= 0:
return float("inf")
S = DISSOLUTION["Mass_sample"] * ssa_cm2g
return (S * DISSOLUTION["Permeability"] * DISSOLUTION["rho_EC"] * ssa_cm2g
/ (DISSOLUTION["Volume_disso"] * wg_fraction))
def dissolution_curve(wg_fraction: float, ssa_cm2g: float):
"""
Predicted first-order dissolution profile for a given coating level.
Returns
-------
(t_min, F_pct, k) : time grid [min] over the standard test duration,
released fraction [%] via :func:`model_first_order`, and the rate
constant k [1/s] from :func:`dissolution_k`.
"""
k = dissolution_k(wg_fraction, ssa_cm2g)
t_s = np.arange(1, DISSOLUTION["Total_min"] + 1) * 60.0
if np.isinf(k):
F = np.full_like(t_s, 100.0)
else:
F = model_first_order(t_s, k)
return t_s / 60.0, F, k
# Registry of available models for programmatic access
DISSOLUTION_MODELS = {
"zero_order": (model_zero_order, ["k"]),
"first_order": (model_first_order, ["k"]),
"higuchi": (model_higuchi, ["k"]),
"korsmeyer_peppas": (model_korsmeyer_peppas, ["k", "n"]),
}
# ── Fitting ────────────────────────────────────────────────────────────────────
@dataclass
class DissolutionFitResult:
model_name: str
params: dict[str, float] # fitted parameter values
params_std: dict[str, float] # standard errors
F_model: np.ndarray # fitted curve evaluated on t_fit
t_fit: np.ndarray
rmse: float # root-mean-square error [%]
r_squared: float
converged: bool
def fit_dissolution(
t_exp: np.ndarray, # time points [min]
F_exp: np.ndarray, # dissolved fraction [%]
model_name: str = "first_order",
p0: list[float] | None = None,
bounds: tuple | None = None,
t_fit: np.ndarray | None = None,
) -> DissolutionFitResult:
"""
Fit a dissolution model to experimental data.
Parameters
----------
t_exp : array
Experimental time points [min].
F_exp : array
Experimental dissolved fraction [%].
model_name : str
One of 'zero_order', 'first_order', 'higuchi', 'korsmeyer_peppas'.
p0 : list, optional
Initial parameter guess. Defaults depend on model.
bounds : tuple, optional
Parameter bounds passed to curve_fit. Defaults to (0, np.inf).
t_fit : array, optional
Dense time array for plotting the fitted curve.
Defaults to linspace(0, max(t_exp), 300).
Returns
-------
DissolutionFitResult
"""
if model_name not in DISSOLUTION_MODELS:
raise ValueError(f"Unknown model '{model_name}'. Choose from {list(DISSOLUTION_MODELS)}")
fn, param_names = DISSOLUTION_MODELS[model_name]
if p0 is None:
p0 = [0.1] * len(param_names)
if bounds is None:
bounds = (0, np.inf)
if t_fit is None:
t_fit = np.linspace(0, t_exp.max(), 300)
converged = True
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
popt, pcov = curve_fit(fn, t_exp, F_exp, p0=p0, bounds=bounds, maxfev=5000)
except RuntimeError:
popt = np.array(p0)
pcov = np.full((len(p0), len(p0)), np.nan)
converged = False
perr = np.sqrt(np.diag(np.abs(pcov)))
F_pred_exp = fn(t_exp, *popt)
residuals = F_exp - F_pred_exp
rmse = float(np.sqrt(np.mean(residuals ** 2)))
ss_res = float(np.sum(residuals ** 2))
ss_tot = float(np.sum((F_exp - F_exp.mean()) ** 2))
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
F_model = fn(t_fit, *popt)
return DissolutionFitResult(
model_name=model_name,
params=dict(zip(param_names, popt.tolist())),
params_std=dict(zip(param_names, perr.tolist())),
F_model=F_model,
t_fit=t_fit,
rmse=rmse,
r_squared=r2,
converged=converged,
)
def fit_all_models(
t_exp: np.ndarray,
F_exp: np.ndarray,
t_fit: np.ndarray | None = None,
) -> dict[str, DissolutionFitResult]:
"""Fit all four dissolution models and return a dict of results."""
return {
name: fit_dissolution(t_exp, F_exp, model_name=name, t_fit=t_fit)
for name in DISSOLUTION_MODELS
}
def best_model(results: dict[str, DissolutionFitResult]) -> DissolutionFitResult:
"""Return the model with the highest RΒ² among fitted results."""
return max(results.values(), key=lambda r: r.r_squared if np.isfinite(r.r_squared) else -np.inf)