From 4c837fd2c709581ab301a8c8198f83dd76085d8d Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:17:30 +0100 Subject: [PATCH 01/32] centralize model registries --- drux/params.py | 172 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/drux/params.py b/drux/params.py index d17d222..fd2e51c 100644 --- a/drux/params.py +++ b/drux/params.py @@ -1,4 +1,176 @@ # -*- coding: utf-8 -*- """Drux parameters and constants.""" +from math import exp, sqrt +from .messages import ( + ERROR_ZERO_ORDER_RELEASE_RATE, + ERROR_ZERO_ORDER_INITIAL_AMOUNT, + ERROR_FIRST_ORDER_RELEASE_RATE, + ERROR_FIRST_ORDER_INITIAL_AMOUNT, + ERROR_INVALID_DIFFUSION, + ERROR_INVALID_CONCENTRATION, + ERROR_INVALID_SOLUBILITY, + ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, + ERROR_RELEASABLE_AMOUNT, + ERROR_WEIBULL_SCALE_PARAMETER, + ERROR_WEIBULL_SHAPE_PARAMETER, + ERROR_INVALID_EROSION_CONSTANT, + ERROR_INVALID_INITIAL_RADIUS, + ERROR_INVALID_GEOMETRY_FACTOR, +) + DRUX_VERSION = "0.3" + +MODELS_REGISTRY = { + "zero_order": { + "params": { + "k0": { + "type": "float", + "description": "Zero-order release rate constant", + "unit": "mg/s", + "default": None, + }, + "M0": { + "type": "float", + "description": "Initial amount of drug in the solution", + "unit": "mg", + "default": 0, + }, + }, + "equation": lambda p, t: p.M0 + p.k0 * t, + "validation": { + "k0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_RELEASE_RATE}, + "M0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_INITIAL_AMOUNT}, + }, + }, + "first_order": { + "params": { + "k": { + "type": "float", + "description": "First-order release rate constant", + "unit": "1/s", + "default": None, + }, + "M0": { + "type": "float", + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, + }, + "equation": lambda p, t: p.M0 * (1 - exp(-p.k * t)), + "validation": { + "k": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_RELEASE_RATE}, + "M0": { + "check": lambda v: v >= 0, + "error": ERROR_FIRST_ORDER_INITIAL_AMOUNT, + }, + }, + }, + "higuchi": { + "params": { + "D": { + "type": "float", + "description": "Drug diffusivity in the polymer carrier", + "unit": "cm^2/s", + "default": None, + }, + "c0": { + "type": "float", + "description": "Initial drug concentration", + "unit": "mg/cm^3", + "default": None, + }, + "cs": { + "type": "float", + "description": "Drug solubility in the polymer", + "unit": "mg/cm^3", + "default": None, + }, + }, + "equation": lambda p, t: sqrt(p.D * (2 * p.c0 - p.cs) * p.cs * t), + "validation": { + "D": {"check": lambda v: v > 0, "error": ERROR_INVALID_DIFFUSION}, + "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, + "cs": {"check": lambda v: v > 0, "error": ERROR_INVALID_SOLUBILITY}, + "cs_vs_c0": { + "check": lambda p: p.cs <= p.c0, + "error": ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, + "cross_param": True, + }, + }, + }, + "weibull": { + "params": { + "a": { + "type": "float", + "description": "Scale factor", + "unit": "dimensionless", + "default": None, + }, + "b": { + "type": "float", + "description": "Shape factor", + "unit": "dimensionless", + "default": None, + }, + "M": { + "type": "float", + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, + }, + "equation": lambda p, t: p.M * (1 - exp(-p.a * t**p.b)), + "validation": { + "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, + "a": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SCALE_PARAMETER}, + "b": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SHAPE_PARAMETER}, + }, + }, + "hopfenberg": { + "params": { + "k0": { + "type": "float", + "description": "Erosion rate constant", + "unit": "mg/(mm^2·s)", + "default": None, + }, + "c0": { + "type": "float", + "description": "Initial drug concentration in the matrix", + "unit": "mg/mm^3", + "default": None, + }, + "a0": { + "type": "float", + "description": "Initial radius or half-thickness of the device", + "unit": "mm", + "default": None, + }, + "n": { + "type": "int", + "description": "Geometry factor (1=slab, 2=cylinder, 3=sphere)", + "unit": "dimensionless", + "default": None, + }, + "M": { + "type": "float", + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, + }, + "equation": lambda p, t: p.M * (1 - (1 - (p.k0 * t) / (p.c0 * p.a0)) ** p.n), + "validation": { + "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, + "k0": {"check": lambda v: v >= 0, "error": ERROR_INVALID_EROSION_CONSTANT}, + "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, + "a0": {"check": lambda v: v > 0, "error": ERROR_INVALID_INITIAL_RADIUS}, + "n": { + "check": lambda v: v in (1, 2, 3), + "error": ERROR_INVALID_GEOMETRY_FACTOR, + }, + }, + }, +} From 9b0916270c67092c395fdbd1436f7b761e2c12cd Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:18:35 +0100 Subject: [PATCH 02/32] use model registry instead of abstract methods --- drux/base_model.py | 57 +++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/drux/base_model.py b/drux/base_model.py index 2eee8fa..dc96169 100644 --- a/drux/base_model.py +++ b/drux/base_model.py @@ -2,7 +2,7 @@ import numpy as np import matplotlib.pyplot as plt -from abc import ABC, abstractmethod +from abc import ABC from typing import Any, Optional from .messages import ( @@ -14,6 +14,8 @@ ERROR_TARGET_RELEASE_EXCEEDS_MAX, ) +from .params import MODELS_REGISTRY + class DrugReleaseModel(ABC): """ @@ -35,25 +37,44 @@ def __init__(self): "xlabel": "Time (s)", "ylabel": "Cumulative Release", "title": "Drug Release Profile", - "label": "Release Profile"} + "label": "Release Profile", + } + self._parameters = None + self._model_name = None - @abstractmethod def _validate_parameters(self) -> None: """ - Validate model parameters. + Validate model parameters using MODELS_REGISTRY. - Should raise ValueError if parameters are invalid. + Raises ValueError if parameters are invalid. """ - pass + if self._model_name is None or self._parameters is None: + raise ValueError("Model name and parameters must be set") + + config = MODELS_REGISTRY[self._model_name] + + for param_name, rule in config["validation"].items(): + if rule.get("cross_param"): + # Cross-parameter validation (e.g., cs <= c0) + if not rule["check"](self._parameters): + raise ValueError(rule["error"]) + else: + # Single parameter validation + param_value = getattr(self._parameters, param_name) + if not rule["check"](param_value): + raise ValueError(rule["error"]) - @abstractmethod def _model_function(self, t: float) -> float: """ Model function that calculates drug release profile over time. :param t: time point at which to calculate drug release """ - pass + if self._model_name is None or self._parameters is None: + raise ValueError("Model name and parameters must be set") + + config = MODELS_REGISTRY[self._model_name] + return config["equation"](self._parameters, t) def _get_release_profile(self) -> np.ndarray: """Calculate the drug release profile over the specified time points.""" @@ -91,13 +112,14 @@ def simulate(self, duration: int, time_step: float = 1) -> np.ndarray: return self._release_profile def plot( - self, - show: bool = True, - label: Optional[str] = None, - xlabel: Optional[str] = None, - ylabel: Optional[str] = None, - title: Optional[str] = None, - **kwargs: Any) -> tuple: + self, + show: bool = True, + label: Optional[str] = None, + xlabel: Optional[str] = None, + ylabel: Optional[str] = None, + title: Optional[str] = None, + **kwargs: Any + ) -> tuple: """ Plot the drug release profile. @@ -112,7 +134,10 @@ def plot( # Plotting the release profile ax.plot( - self._time_points, self._release_profile, label=label or self._plot_parameters["label"], **kwargs + self._time_points, + self._release_profile, + label=label or self._plot_parameters["label"], + **kwargs ) ax.set_xlabel(xlabel or self._plot_parameters["xlabel"]) ax.set_ylabel(ylabel or self._plot_parameters["ylabel"]) From f36a09a9e43bad15d796ed20c6d5689a47856a70 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:19:45 +0100 Subject: [PATCH 03/32] method to create params dataclass from model registry --- drux/utils.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 drux/utils.py diff --git a/drux/utils.py b/drux/utils.py new file mode 100644 index 0000000..c700835 --- /dev/null +++ b/drux/utils.py @@ -0,0 +1,25 @@ +from dataclasses import field, make_dataclass +from .params import MODELS_REGISTRY + + +def create_parameters_dataclass(model_name: str): + """ + Create a dataclass from MODELS_REGISTRY configuration. + + :param model_name: Name of the model in MODELS_REGISTRY + :return: Dataclass type for model parameters + """ + config = MODELS_REGISTRY[model_name] + fields = [] + + for param_name, param_info in config["params"].items(): + param_type = eval(param_info["type"]) + default_value = param_info["default"] + + if default_value is not None: + fields.append((param_name, param_type, field(default=default_value))) + else: + fields.append((param_name, param_type)) + + class_name = f"{model_name.title().replace('_', '')}Parameters" + return make_dataclass(class_name, fields) From fdcf9a78593c141143c2518b05a263b4160e40d3 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:20:02 +0100 Subject: [PATCH 04/32] remove models dataclasses --- drux/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/drux/__init__.py b/drux/__init__.py index e6788d6..29fb78f 100644 --- a/drux/__init__.py +++ b/drux/__init__.py @@ -2,10 +2,10 @@ """Drux modules.""" from .params import DRUX_VERSION -from .higuchi import HiguchiModel, HiguchiParameters -from .zero_order import ZeroOrderModel, ZeroOrderParameters -from .first_order import FirstOrderModel, FirstOrderParameters -from .weibull import WeibullModel, WeibullParameters -from .hopfenberg import HopfenbergModel, HopfenbergParameters +from .higuchi import HiguchiModel +from .zero_order import ZeroOrderModel +from .first_order import FirstOrderModel +from .weibull import WeibullModel +from .hopfenberg import HopfenbergModel __version__ = DRUX_VERSION From f46bc28801cae444c054cf215076e830ac9193f6 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:20:20 +0100 Subject: [PATCH 05/32] pep8 refactor --- drux/messages.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/drux/messages.py b/drux/messages.py index 9d7b189..387aa4d 100644 --- a/drux/messages.py +++ b/drux/messages.py @@ -36,11 +36,11 @@ # Error messages for Weibull ERROR_WEIBULL_SCALE_PARAMETER = "Scale parameter (a) must be positive." ERROR_WEIBULL_SHAPE_PARAMETER = "Shape parameter (b) must be positive." -ERROR_RELEASABLE_AMOUNT = ( - "Entire releasable amount of drug (M) must be non-negative." -) +ERROR_RELEASABLE_AMOUNT = "Entire releasable amount of drug (M) must be non-negative." # Hopfenberg model error messages ERROR_INVALID_EROSION_CONSTANT = "Erosion rate constant (k0) must be non-negative." ERROR_INVALID_INITIAL_RADIUS = "Initial radius or half-thickness (a0) must be positive." -ERROR_INVALID_GEOMETRY_FACTOR = "Geometry factor (n) must be 1 (slab), 2 (cylinder), or 3 (sphere)." +ERROR_INVALID_GEOMETRY_FACTOR = ( + "Geometry factor (n) must be 1 (slab), 2 (cylinder), or 3 (sphere)." +) From 9f38e5e94dc9277687f76e087226c8aac2c07ef0 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:21:01 +0100 Subject: [PATCH 06/32] update with central model registry --- drux/zero_order.py | 57 +++++++--------------------------------------- 1 file changed, 8 insertions(+), 49 deletions(-) diff --git a/drux/zero_order.py b/drux/zero_order.py index 57ac1e0..bc52664 100644 --- a/drux/zero_order.py +++ b/drux/zero_order.py @@ -2,60 +2,19 @@ """Drux zero-order model implementation.""" from .base_model import DrugReleaseModel -from .messages import ERROR_ZERO_ORDER_RELEASE_RATE, ERROR_ZERO_ORDER_INITIAL_AMOUNT -from dataclasses import dataclass - - -@dataclass -class ZeroOrderParameters: - """ - Parameters for the Zero-order model. - - Attributes: - M0 (float): initial amount of drug in the solution (most times, M0 = 0) - k0 (float): zero-order release rate constant (mg/s) - """ - - M0: float - k0: float +from .utils import create_parameters_dataclass class ZeroOrderModel(DrugReleaseModel): - """Simulator for the Zero-order drug release model.""" - - def __init__(self, k0: float, M0: float = 0) -> None: - """ - Initialize the zero-order model with the given parameters. - - :param k0: Zero-order release rate constant (mg/s) - :param M0: Initial amount of drug in the solution (mg), default is 0 - """ + def __init__(self, k0: float, M0: float = 0): super().__init__() - self._parameters = ZeroOrderParameters(k0=k0, M0=M0) + self._model_name = "zero_order" + _params_class = create_parameters_dataclass(self._model_name) + self._parameters = _params_class(k0=k0, M0=M0) self._plot_parameters["label"] = "Zero-Order Model" def __repr__(self): """Return a string representation of the Zero-Order model.""" - return f"drux.ZeroOrderModel(k0={self._parameters.k0}, M0={self._parameters.M0})" - - def _model_function(self, t: float) -> float: - """ - Calculate the drug release at time t using the zero-order model. - - Formula: - - M(t) = M0 + k0 * t - :param t: time (s) - """ - M0 = self._parameters.M0 - k0 = self._parameters.k0 - - Mt = M0 + k0 * t - - return Mt - - def _validate_parameters(self) -> None: - """Validate the parameters of the zero-order model.""" - if self._parameters.M0 < 0: - raise ValueError(ERROR_ZERO_ORDER_INITIAL_AMOUNT) - if self._parameters.k0 < 0: - raise ValueError(ERROR_ZERO_ORDER_RELEASE_RATE) + return ( + f"drux.ZeroOrderModel(k0={self._parameters.k0}, M0={self._parameters.M0})" + ) From 4f63703e1318c74de224a81010f20f1099df8813 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:21:10 +0100 Subject: [PATCH 07/32] update with central model registry --- drux/first_order.py | 46 +++++---------------------------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/drux/first_order.py b/drux/first_order.py index 40e6215..ec245dd 100644 --- a/drux/first_order.py +++ b/drux/first_order.py @@ -1,29 +1,13 @@ # -*- coding: utf-8 -*- """Drux first-order model implementation.""" -from math import exp from .base_model import DrugReleaseModel -from .messages import ERROR_FIRST_ORDER_INITIAL_AMOUNT, ERROR_FIRST_ORDER_RELEASE_RATE -from dataclasses import dataclass - - -@dataclass -class FirstOrderParameters: - """ - Parameters for the first-order model. - - Attributes: - M0 (float): entire releasable amount of drug (normally M0 > 0) (mg) - k (float): first-order release rate constant (1/s) - """ - - M0: float - k: float +from .utils import create_parameters_dataclass class FirstOrderModel(DrugReleaseModel): """Simulator for the first-order drug release model.""" - def __init__(self, k: float, M0: float) -> None: + def __init__(self, k: float, M0: float = 1) -> None: """ Initialize the first-order model with the given parameters. @@ -31,31 +15,11 @@ def __init__(self, k: float, M0: float) -> None: :param M0: entire releasable amount of drug (the asymptotic maximum) (mg) """ super().__init__() - self._parameters = FirstOrderParameters(k=k, M0=M0) + self._model_name = "first_order" + _params_class = create_parameters_dataclass(self._model_name) + self._parameters = _params_class(k=k, M0=M0) self._plot_parameters["label"] = "First-Order Model" def __repr__(self): """Return a string representation of the First-Order model.""" return f"drux.FirstOrderModel(k={self._parameters.k}, M0={self._parameters.M0})" - - def _model_function(self, t: float) -> float: - """ - Calculate the drug release at time t using the first-order model. - - Formula: - - M(t) = M0 * (1 - exp(-k * t)) - :param t: time (s) - """ - M0 = self._parameters.M0 - k = self._parameters.k - - Mt = M0 * (1 - exp(-k * t)) - - return Mt - - def _validate_parameters(self) -> None: - """Validate the parameters of the first-order model.""" - if self._parameters.M0 < 0: - raise ValueError(ERROR_FIRST_ORDER_INITIAL_AMOUNT) - if self._parameters.k < 0: - raise ValueError(ERROR_FIRST_ORDER_RELEASE_RATE) From ca063fc3e5dd87b3c907dec7aecbb71669c96b42 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:21:18 +0100 Subject: [PATCH 08/32] update with central model registry --- drux/higuchi.py | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/drux/higuchi.py b/drux/higuchi.py index 9df2fbf..75bc324 100644 --- a/drux/higuchi.py +++ b/drux/higuchi.py @@ -2,30 +2,7 @@ """Drux Higuchi model implementation.""" from .base_model import DrugReleaseModel -from .messages import ( - ERROR_INVALID_DIFFUSION, - ERROR_INVALID_CONCENTRATION, - ERROR_INVALID_SOLUBILITY, - ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, -) -from dataclasses import dataclass -from math import sqrt - - -@dataclass -class HiguchiParameters: - """ - Parameters for the Higuchi model based on physical formulation. - - Attributes: - D (float): Drug diffusivity in the polymer carrier (cm^2/s) - c0 (float): Initial drug concentration (mg/cm^3) - cs (float): Drug solubility in the polymer (mg/cm^3) - """ - - D: float - c0: float - cs: float +from .utils import create_parameters_dataclass class HiguchiModel(DrugReleaseModel): From a5e3b7fd758fbd9b90e7cff35d6432ce37ce2d1f Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:21:29 +0100 Subject: [PATCH 09/32] update with central model registry --- drux/higuchi.py | 31 +++---------------------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/drux/higuchi.py b/drux/higuchi.py index 75bc324..bb7722e 100644 --- a/drux/higuchi.py +++ b/drux/higuchi.py @@ -17,7 +17,9 @@ def __init__(self, D: float, c0: float, cs: float) -> None: :param cs: Drug solubility in the polymer (mg/cm^3) """ super().__init__() - self._parameters = HiguchiParameters(D=D, c0=c0, cs=cs) + self._model_name = "higuchi" + _params_class = create_parameters_dataclass(self._model_name) + self._parameters = _params_class(D=D, c0=c0, cs=cs) self._plot_parameters["label"] = "Higuchi Model" def __repr__(self): @@ -26,30 +28,3 @@ def __repr__(self): f"drux.HiguchiModel(D={self._parameters.D}, " f"c0={self._parameters.c0}, cs={self._parameters.cs})" ) - - def _model_function(self, t: float) -> float: - """ - Calculate the drug release at time t using the Higuchi model. - - Formula: - - General case: Mt = sqrt(D * c0 * (2*c0 - cs) * cs * t) - :param t: time (s) - """ - D = self._parameters.D - c0 = self._parameters.c0 - cs = self._parameters.cs - - Mt = sqrt(D * (2 * c0 - cs) * cs * t) - - return Mt - - def _validate_parameters(self) -> None: - """Validate the parameters of the Higuchi model.""" - if self._parameters.D <= 0: - raise ValueError(ERROR_INVALID_DIFFUSION) - if self._parameters.c0 <= 0: - raise ValueError(ERROR_INVALID_CONCENTRATION) - if self._parameters.cs <= 0: - raise ValueError(ERROR_INVALID_SOLUBILITY) - if self._parameters.cs > self._parameters.c0: - raise ValueError(ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION) From e4972c36dfb65fd5a3bb689155cffaf1217f1814 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:21:35 +0100 Subject: [PATCH 10/32] update with central model registry --- drux/hopfenberg.py | 69 ++++------------------------------------------ 1 file changed, 5 insertions(+), 64 deletions(-) diff --git a/drux/hopfenberg.py b/drux/hopfenberg.py index 89074f9..f8e2495 100644 --- a/drux/hopfenberg.py +++ b/drux/hopfenberg.py @@ -2,39 +2,13 @@ """Drux Hopfenberg model implementation.""" from .base_model import DrugReleaseModel -from .messages import ( - ERROR_INVALID_EROSION_CONSTANT, - ERROR_INVALID_INITIAL_RADIUS, - ERROR_INVALID_GEOMETRY_FACTOR, - ERROR_INVALID_CONCENTRATION, - ERROR_RELEASABLE_AMOUNT, -) -from dataclasses import dataclass - - -@dataclass -class HopfenbergParameters: - """ - Parameters for the Hopfenberg model based on surface erosion. - - Attributes: - k0 (float): Erosion rate constant (mg/(mm^2·s)) - c0 (float): Initial drug concentration in the matrix (mg/mm^3) - a0 (float): Initial radius or half-thickness of the device (mm) - n (int): Geometry factor (1=slab, 2=cylinder, 3=sphere) - """ - - M: float - k0: float - c0: float - a0: float - n: int +from .utils import create_parameters_dataclass class HopfenbergModel(DrugReleaseModel): """Simulator for the Hopfenberg drug release model for surface-eroding polymers.""" - def __init__(self, M: float, k0: float, c0: float, a0: float, n: int) -> None: + def __init__(self, k0: float, c0: float, a0: float, n: int, M: float = 1) -> None: """ Initialize the Hopfenberg model with the given parameters. @@ -45,7 +19,9 @@ def __init__(self, M: float, k0: float, c0: float, a0: float, n: int) -> None: :param n: Geometry factor (1=slab, 2=cylinder, 3=sphere) """ super().__init__() - self._parameters = HopfenbergParameters(M=M, k0=k0, c0=c0, a0=a0, n=n) + self._model_name = "hopfenberg" + _params_class = create_parameters_dataclass(self._model_name) + self._parameters = _params_class(M=M, k0=k0, c0=c0, a0=a0, n=n) self._plot_parameters["label"] = "Hopfenberg Model" def __repr__(self): @@ -55,38 +31,3 @@ def __repr__(self): f"c0={self._parameters.c0}, a0={self._parameters.a0}, " f"n={self._parameters.n})" ) - - def _model_function(self, t: float) -> float: - """ - Calculate the fractional drug release at time t using the Hopfenberg model. - - Formula: - - Mt = M∞(1 - (1 - k0*t / (c0*a0))^n) - - :param t: time (s) - :return: drug release - """ - M = self._parameters.M - k0 = self._parameters.k0 - c0 = self._parameters.c0 - a0 = self._parameters.a0 - n = self._parameters.n - - inner_term = 1 - (k0 * t) / (c0 * a0) - - Mt = M * (1 - (inner_term**n)) - - return Mt - - def _validate_parameters(self) -> None: - """Validate the parameters of the Hopfenberg model.""" - if self._parameters.M < 0: - raise ValueError(ERROR_RELEASABLE_AMOUNT) - if self._parameters.k0 < 0: - raise ValueError(ERROR_INVALID_EROSION_CONSTANT) - if self._parameters.c0 <= 0: - raise ValueError(ERROR_INVALID_CONCENTRATION) - if self._parameters.a0 <= 0: - raise ValueError(ERROR_INVALID_INITIAL_RADIUS) - if self._parameters.n not in (1, 2, 3): - raise ValueError(ERROR_INVALID_GEOMETRY_FACTOR) From 36398fe6cb2ae31cb837bbf35290ea8034d16b4d Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:21:51 +0100 Subject: [PATCH 11/32] update with central model registry --- drux/weibull.py | 55 +++++-------------------------------------------- 1 file changed, 5 insertions(+), 50 deletions(-) diff --git a/drux/weibull.py b/drux/weibull.py index 8754df2..e4aaa76 100644 --- a/drux/weibull.py +++ b/drux/weibull.py @@ -2,35 +2,13 @@ """Drux Weibull model implementation.""" from .base_model import DrugReleaseModel -from .messages import ( - ERROR_WEIBULL_SCALE_PARAMETER, - ERROR_RELEASABLE_AMOUNT, - ERROR_WEIBULL_SHAPE_PARAMETER, -) -from dataclasses import dataclass -from math import exp - - -@dataclass -class WeibullParameters: - """ - Parameters for the Weibull model based on physical formulation. - - Attributes: - M (float): entire releasable amount of drug (normally M > 0) (mg) - a (float): scale factor - b (float): shape factor - """ - - M: float - a: float - b: float +from .utils import create_parameters_dataclass class WeibullModel(DrugReleaseModel): """Simulator for the Weibull drug release model using analytical expressions based on concentration conditions.""" - def __init__(self, M: float, a: float, b: float) -> None: + def __init__(self, a: float, b: float, M: float = 1) -> None: """ Initialize the Weibull model with the given parameters. @@ -39,34 +17,11 @@ def __init__(self, M: float, a: float, b: float) -> None: :param b: shape factor """ super().__init__() - self._parameters = WeibullParameters(M=M, a=a, b=b) + self._model_name = "weibull" + _params_class = create_parameters_dataclass(self._model_name) + self._parameters = _params_class(M=M, a=a, b=b) self._plot_parameters["label"] = "Weibull Model" def __repr__(self): """Return a string representation of the Weibull model.""" return f"drux.WeibullModel(M={self._parameters.M}, a={self._parameters.a}, b={self._parameters.b})" - - def _model_function(self, t: float) -> float: - """ - Calculate the drug release at time t using the Weibull model. - - Formula: - - General case: Mt = M * (1 - exp(-a*t ** b)) - :param t: time (s) - """ - M = self._parameters.M - a = self._parameters.a - b = self._parameters.b - - Mt = M * (1 - exp(-a * t**b)) - - return Mt - - def _validate_parameters(self) -> None: - """Validate the parameters of the Weibull model.""" - if self._parameters.M < 0: - raise ValueError(ERROR_RELEASABLE_AMOUNT) - if self._parameters.a <= 0: - raise ValueError(ERROR_WEIBULL_SCALE_PARAMETER) - if self._parameters.b <= 0: - raise ValueError(ERROR_WEIBULL_SHAPE_PARAMETER) From cdb5ef325b79def7464a14c809fff9c3e9404132 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:38:56 +0100 Subject: [PATCH 12/32] remove eval --- drux/params.py | 28 ++++++++++++++-------------- drux/utils.py | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/drux/params.py b/drux/params.py index fd2e51c..5f2518e 100644 --- a/drux/params.py +++ b/drux/params.py @@ -25,13 +25,13 @@ "zero_order": { "params": { "k0": { - "type": "float", + "type": float, "description": "Zero-order release rate constant", "unit": "mg/s", "default": None, }, "M0": { - "type": "float", + "type": float, "description": "Initial amount of drug in the solution", "unit": "mg", "default": 0, @@ -46,13 +46,13 @@ "first_order": { "params": { "k": { - "type": "float", + "type": float, "description": "First-order release rate constant", "unit": "1/s", "default": None, }, "M0": { - "type": "float", + "type": float, "description": "Entire releasable amount of drug", "unit": "mg", "default": 1, @@ -70,19 +70,19 @@ "higuchi": { "params": { "D": { - "type": "float", + "type": float, "description": "Drug diffusivity in the polymer carrier", "unit": "cm^2/s", "default": None, }, "c0": { - "type": "float", + "type": float, "description": "Initial drug concentration", "unit": "mg/cm^3", "default": None, }, "cs": { - "type": "float", + "type": float, "description": "Drug solubility in the polymer", "unit": "mg/cm^3", "default": None, @@ -103,19 +103,19 @@ "weibull": { "params": { "a": { - "type": "float", + "type": float, "description": "Scale factor", "unit": "dimensionless", "default": None, }, "b": { - "type": "float", + "type": float, "description": "Shape factor", "unit": "dimensionless", "default": None, }, "M": { - "type": "float", + "type": float, "description": "Entire releasable amount of drug", "unit": "mg", "default": 1, @@ -131,19 +131,19 @@ "hopfenberg": { "params": { "k0": { - "type": "float", + "type": float, "description": "Erosion rate constant", "unit": "mg/(mm^2·s)", "default": None, }, "c0": { - "type": "float", + "type": float, "description": "Initial drug concentration in the matrix", "unit": "mg/mm^3", "default": None, }, "a0": { - "type": "float", + "type": float, "description": "Initial radius or half-thickness of the device", "unit": "mm", "default": None, @@ -155,7 +155,7 @@ "default": None, }, "M": { - "type": "float", + "type": float, "description": "Entire releasable amount of drug", "unit": "mg", "default": 1, diff --git a/drux/utils.py b/drux/utils.py index c700835..afa1325 100644 --- a/drux/utils.py +++ b/drux/utils.py @@ -13,7 +13,7 @@ def create_parameters_dataclass(model_name: str): fields = [] for param_name, param_info in config["params"].items(): - param_type = eval(param_info["type"]) + param_type = param_info["type"] default_value = param_info["default"] if default_value is not None: From a59125d6d66d515ed94234622c9f6ecccc24fec1 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:41:25 +0100 Subject: [PATCH 13/32] add file docstring --- drux/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/drux/utils.py b/drux/utils.py index afa1325..2672e8d 100644 --- a/drux/utils.py +++ b/drux/utils.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +"""Generic utilities for Drux.""" + from dataclasses import field, make_dataclass from .params import MODELS_REGISTRY From 99d620bc5356c738c689048395f33bd18857140b Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:45:35 +0100 Subject: [PATCH 14/32] add docstring --- drux/zero_order.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drux/zero_order.py b/drux/zero_order.py index bc52664..7e669ef 100644 --- a/drux/zero_order.py +++ b/drux/zero_order.py @@ -7,6 +7,12 @@ class ZeroOrderModel(DrugReleaseModel): def __init__(self, k0: float, M0: float = 0): + """ + Initialize the Zero-Order model with the given parameters. + + :param k0: Zero-order release rate constant (amount/time) + :param M0: Initial amount of drug released at time zero (default is 0 + """ super().__init__() self._model_name = "zero_order" _params_class = create_parameters_dataclass(self._model_name) From 5060639185eda3a82668979f791fbf14f3e39f6b Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:50:12 +0100 Subject: [PATCH 15/32] add docstring --- drux/zero_order.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/drux/zero_order.py b/drux/zero_order.py index 7e669ef..028fbe3 100644 --- a/drux/zero_order.py +++ b/drux/zero_order.py @@ -6,6 +6,8 @@ class ZeroOrderModel(DrugReleaseModel): + """Simulator for the zero-order drug release model.""" + def __init__(self, k0: float, M0: float = 0): """ Initialize the Zero-Order model with the given parameters. From 92448a583282fffcb6769584999252094dbc539e Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Tue, 24 Feb 2026 12:57:03 +0100 Subject: [PATCH 16/32] minor edit --- drux/zero_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drux/zero_order.py b/drux/zero_order.py index 028fbe3..95f7a73 100644 --- a/drux/zero_order.py +++ b/drux/zero_order.py @@ -7,7 +7,7 @@ class ZeroOrderModel(DrugReleaseModel): """Simulator for the zero-order drug release model.""" - + def __init__(self, k0: float, M0: float = 0): """ Initialize the Zero-Order model with the given parameters. From 2991482be9ff29bf64fe24141657d2490a2a4576 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:19:06 +0200 Subject: [PATCH 17/32] centralized equation and validation registry --- drux/equations.py | 187 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 drux/equations.py diff --git a/drux/equations.py b/drux/equations.py new file mode 100644 index 0000000..b518e7b --- /dev/null +++ b/drux/equations.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +"""Centralized equation and validation registry for drug release models. + +Use the @equation decorator to register model equations. Each equation +receives a parameters object `p` and a time value `t`, and returns the +computed drug release amount. + +Use the @validation decorator to register validation rules for model +parameters, co-located with the equations they belong to. +""" + +from math import exp, sqrt + +# --------------------------------------------------------------------------- +# Internal registries – accessed via helper functions, not directly. +# --------------------------------------------------------------------------- +_EQUATIONS = {} +_VALIDATIONS = {} + + +# --------------------------------------------------------------------------- +# Decorators +# --------------------------------------------------------------------------- + +def equation(name): + """Register a simulation equation for the given model name. + + The decorated function must have the signature ``(p, t) -> float`` + where *p* is a parameters dataclass instance. + """ + def decorator(fn): + _EQUATIONS[name] = fn + return fn + return decorator + + +def validation(name): + """Register validation rules for the given model name. + + The decorated function must return a dict of validation rules in the form:: + + { + "param_name": {"check": callable, "error": str}, + ... + } + + Cross-parameter validations should include ``"cross_param": True``. + """ + def decorator(fn): + _VALIDATIONS[name] = fn() + return fn + return decorator + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + +def get_equation(name): + """Return the simulation equation for *name*, or raise KeyError.""" + return _EQUATIONS[name] + + +def get_validation(name): + """Return the validation rules for *name*, or raise KeyError.""" + return _VALIDATIONS[name] + + +def list_equations(): + """Return a list of registered equation names.""" + return list(_EQUATIONS.keys()) + + +# --------------------------------------------------------------------------- +# Simulation equations (p, t) -> float +# --------------------------------------------------------------------------- + +@equation("zero_order") +def zero_order(p, t): + """Zero-order kinetics: M(t) = M0 + k0 * t.""" + return p.M0 + p.k0 * t + + +@equation("first_order") +def first_order(p, t): + """First-order kinetics: M(t) = M0 * (1 - exp(-k * t)).""" + return p.M0 * (1 - exp(-p.k * t)) + + +@equation("higuchi") +def higuchi(p, t): + """Higuchi model: M(t) = sqrt(D * (2*c0 - cs) * cs * t).""" + return sqrt(p.D * (2 * p.c0 - p.cs) * p.cs * t) + + +@equation("weibull") +def weibull(p, t): + """Weibull model: M(t) = M * (1 - exp(-a * t^b)).""" + return p.M * (1 - exp(-p.a * t ** p.b)) + + +@equation("hopfenberg") +def hopfenberg(p, t): + """Hopfenberg model: M(t) = M * (1 - (1 - k0*t/(c0*a0))^n).""" + return p.M * (1 - (1 - (p.k0 * t) / (p.c0 * p.a0)) ** p.n) + + +# --------------------------------------------------------------------------- +# Validation rules – co-located with equations +# --------------------------------------------------------------------------- + +@validation("zero_order") +def zero_order_validation(): + """Validation rules for the zero-order model.""" + from .messages import ERROR_ZERO_ORDER_RELEASE_RATE, ERROR_ZERO_ORDER_INITIAL_AMOUNT + return { + "k0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_RELEASE_RATE}, + "M0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_INITIAL_AMOUNT}, + } + + +@validation("first_order") +def first_order_validation(): + """Validation rules for the first-order model.""" + from .messages import ERROR_FIRST_ORDER_RELEASE_RATE, ERROR_FIRST_ORDER_INITIAL_AMOUNT + return { + "k": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_RELEASE_RATE}, + "M0": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_INITIAL_AMOUNT}, + } + + +@validation("higuchi") +def higuchi_validation(): + """Validation rules for the Higuchi model.""" + from .messages import ( + ERROR_INVALID_DIFFUSION, + ERROR_INVALID_CONCENTRATION, + ERROR_INVALID_SOLUBILITY, + ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, + ) + return { + "D": {"check": lambda v: v > 0, "error": ERROR_INVALID_DIFFUSION}, + "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, + "cs": {"check": lambda v: v > 0, "error": ERROR_INVALID_SOLUBILITY}, + "cs_vs_c0": { + "check": lambda p: p.cs <= p.c0, + "error": ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, + "cross_param": True, + }, + } + + +@validation("weibull") +def weibull_validation(): + """Validation rules for the Weibull model.""" + from .messages import ( + ERROR_RELEASABLE_AMOUNT, + ERROR_WEIBULL_SCALE_PARAMETER, + ERROR_WEIBULL_SHAPE_PARAMETER, + ) + return { + "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, + "a": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SCALE_PARAMETER}, + "b": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SHAPE_PARAMETER}, + } + + +@validation("hopfenberg") +def hopfenberg_validation(): + """Validation rules for the Hopfenberg model.""" + from .messages import ( + ERROR_RELEASABLE_AMOUNT, + ERROR_INVALID_EROSION_CONSTANT, + ERROR_INVALID_CONCENTRATION, + ERROR_INVALID_INITIAL_RADIUS, + ERROR_INVALID_GEOMETRY_FACTOR, + ) + return { + "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, + "k0": {"check": lambda v: v >= 0, "error": ERROR_INVALID_EROSION_CONSTANT}, + "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, + "a0": {"check": lambda v: v > 0, "error": ERROR_INVALID_INITIAL_RADIUS}, + "n": { + "check": lambda v: v in (1, 2, 3), + "error": ERROR_INVALID_GEOMETRY_FACTOR, + }, + } From 2769eae89db1143c7e57b6f8d3ad4613217f2a58 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:20:23 +0200 Subject: [PATCH 18/32] use equation and validation registries --- drux/params.py | 89 ++++++++++++-------------------------------------- 1 file changed, 21 insertions(+), 68 deletions(-) diff --git a/drux/params.py b/drux/params.py index 5f2518e..d8a534e 100644 --- a/drux/params.py +++ b/drux/params.py @@ -1,23 +1,7 @@ # -*- coding: utf-8 -*- """Drux parameters and constants.""" -from math import exp, sqrt -from .messages import ( - ERROR_ZERO_ORDER_RELEASE_RATE, - ERROR_ZERO_ORDER_INITIAL_AMOUNT, - ERROR_FIRST_ORDER_RELEASE_RATE, - ERROR_FIRST_ORDER_INITIAL_AMOUNT, - ERROR_INVALID_DIFFUSION, - ERROR_INVALID_CONCENTRATION, - ERROR_INVALID_SOLUBILITY, - ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, - ERROR_RELEASABLE_AMOUNT, - ERROR_WEIBULL_SCALE_PARAMETER, - ERROR_WEIBULL_SHAPE_PARAMETER, - ERROR_INVALID_EROSION_CONSTANT, - ERROR_INVALID_INITIAL_RADIUS, - ERROR_INVALID_GEOMETRY_FACTOR, -) +from .equations import get_equation, get_validation DRUX_VERSION = "0.3" @@ -37,11 +21,8 @@ "default": 0, }, }, - "equation": lambda p, t: p.M0 + p.k0 * t, - "validation": { - "k0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_RELEASE_RATE}, - "M0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_INITIAL_AMOUNT}, - }, + "equation": get_equation("zero_order"), + "validation": get_validation("zero_order"), }, "first_order": { "params": { @@ -58,14 +39,8 @@ "default": 1, }, }, - "equation": lambda p, t: p.M0 * (1 - exp(-p.k * t)), - "validation": { - "k": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_RELEASE_RATE}, - "M0": { - "check": lambda v: v >= 0, - "error": ERROR_FIRST_ORDER_INITIAL_AMOUNT, - }, - }, + "equation": get_equation("first_order"), + "validation": get_validation("first_order"), }, "higuchi": { "params": { @@ -88,20 +63,17 @@ "default": None, }, }, - "equation": lambda p, t: sqrt(p.D * (2 * p.c0 - p.cs) * p.cs * t), - "validation": { - "D": {"check": lambda v: v > 0, "error": ERROR_INVALID_DIFFUSION}, - "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, - "cs": {"check": lambda v: v > 0, "error": ERROR_INVALID_SOLUBILITY}, - "cs_vs_c0": { - "check": lambda p: p.cs <= p.c0, - "error": ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, - "cross_param": True, - }, - }, + "equation": get_equation("higuchi"), + "validation": get_validation("higuchi"), }, "weibull": { "params": { + "M": { + "type": float, + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, "a": { "type": float, "description": "Scale factor", @@ -114,22 +86,18 @@ "unit": "dimensionless", "default": None, }, + }, + "equation": get_equation("weibull"), + "validation": get_validation("weibull"), + }, + "hopfenberg": { + "params": { "M": { "type": float, "description": "Entire releasable amount of drug", "unit": "mg", "default": 1, }, - }, - "equation": lambda p, t: p.M * (1 - exp(-p.a * t**p.b)), - "validation": { - "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, - "a": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SCALE_PARAMETER}, - "b": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SHAPE_PARAMETER}, - }, - }, - "hopfenberg": { - "params": { "k0": { "type": float, "description": "Erosion rate constant", @@ -154,23 +122,8 @@ "unit": "dimensionless", "default": None, }, - "M": { - "type": float, - "description": "Entire releasable amount of drug", - "unit": "mg", - "default": 1, - }, - }, - "equation": lambda p, t: p.M * (1 - (1 - (p.k0 * t) / (p.c0 * p.a0)) ** p.n), - "validation": { - "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, - "k0": {"check": lambda v: v >= 0, "error": ERROR_INVALID_EROSION_CONSTANT}, - "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, - "a0": {"check": lambda v: v > 0, "error": ERROR_INVALID_INITIAL_RADIUS}, - "n": { - "check": lambda v: v in (1, 2, 3), - "error": ERROR_INVALID_GEOMETRY_FACTOR, - }, }, + "equation": get_equation("hopfenberg"), + "validation": get_validation("hopfenberg"), }, } From d1394607be0ee07899d5d8924d56b1fd3ebcc16d Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:21:08 +0200 Subject: [PATCH 19/32] split params into required and optional --- drux/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/drux/utils.py b/drux/utils.py index 2672e8d..4535b3d 100644 --- a/drux/utils.py +++ b/drux/utils.py @@ -13,16 +13,20 @@ def create_parameters_dataclass(model_name: str): :return: Dataclass type for model parameters """ config = MODELS_REGISTRY[model_name] - fields = [] + required_fields = [] + optional_fields = [] for param_name, param_info in config["params"].items(): param_type = param_info["type"] default_value = param_info["default"] if default_value is not None: - fields.append((param_name, param_type, field(default=default_value))) + optional_fields.append((param_name, param_type, field(default=default_value))) else: - fields.append((param_name, param_type)) + required_fields.append((param_name, param_type)) + + # Required fields must come before optional fields in a dataclass + fields = required_fields + optional_fields class_name = f"{model_name.title().replace('_', '')}Parameters" return make_dataclass(class_name, fields) From 70a8fa5ae9c20b9f8b466545f463466820567a30 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:21:50 +0200 Subject: [PATCH 20/32] add create_model_class func --- drux/base_model.py | 70 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/drux/base_model.py b/drux/base_model.py index dc96169..f097780 100644 --- a/drux/base_model.py +++ b/drux/base_model.py @@ -15,6 +15,7 @@ ) from .params import MODELS_REGISTRY +from .utils import create_parameters_dataclass class DrugReleaseModel(ABC): @@ -24,9 +25,10 @@ class DrugReleaseModel(ABC): This class provides a common interface and functionality for various mathematical models of drug release from delivery systems. - Subclasses should implement: - - _model_function(): Core model equation - - _validate_parameters(): Parameter validation + Model classes are typically generated via :func:`create_model_class` + from the centralized ``MODELS_REGISTRY``. All equation logic, + parameter validation, and parameter metadata live in the registry + so that subclasses need no custom overrides. """ def __init__(self): @@ -185,3 +187,65 @@ def time_for_release(self, target_release: float) -> float: # Find first time point where release >= target idx = np.argmax(self._release_profile >= target_release) return self._time_points[idx] + + +def create_model_class(model_name: str, class_name: str, label: str, docstring: str = "") -> type: + """ + Generate a :class:`DrugReleaseModel` subclass from ``MODELS_REGISTRY``. + + Parameters + ---------- + model_name : str + Key in ``MODELS_REGISTRY`` (e.g. ``"zero_order"``). + class_name : str + Name of the generated class (e.g. ``"ZeroOrderModel"``). + label : str + Plot legend label (e.g. ``"Zero-Order Model"``). + docstring : str, optional + Docstring for the generated class. + + Returns + ------- + type + A new class that inherits from :class:`DrugReleaseModel`. + """ + config = MODELS_REGISTRY[model_name] + param_specs = config["params"] + + # Build the ordered list of (name, type, default|REQUIRED) for __init__ + required_params = [] + optional_params = [] + for pname, pinfo in param_specs.items(): + if pinfo["default"] is not None: + optional_params.append((pname, pinfo["type"], pinfo["default"])) + else: + required_params.append((pname, pinfo["type"])) + + def __init__(self, **kwargs): + # Apply defaults for missing optional params + for pname, _ptype, pdefault in optional_params: + kwargs.setdefault(pname, pdefault) + # Check all required params are present + for pname, _ptype in required_params: + if pname not in kwargs: + raise TypeError(f"Missing required parameter: {pname}") + DrugReleaseModel.__init__(self) + self._model_name = model_name + _params_class = create_parameters_dataclass(model_name) + self._parameters = _params_class(**kwargs) + self._plot_parameters["label"] = label + + def __repr__(self): + parts = ", ".join( + f"{pname}={getattr(self._parameters, pname)}" + for pname in param_specs + ) + return f"drux.{class_name}({parts})" + + cls = type(class_name, (DrugReleaseModel,), { + "__init__": __init__, + "__repr__": __repr__, + "__doc__": docstring or f"Simulator for the {label.lower()}.", + }) + + return cls From 4bf1a94a534b22cb078c3d8bf25ba6c3f83a3a60 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:22:35 +0200 Subject: [PATCH 21/32] use create_model_class --- drux/zero_order.py | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/drux/zero_order.py b/drux/zero_order.py index 95f7a73..7b02c6f 100644 --- a/drux/zero_order.py +++ b/drux/zero_order.py @@ -1,28 +1,11 @@ # -*- coding: utf-8 -*- """Drux zero-order model implementation.""" -from .base_model import DrugReleaseModel -from .utils import create_parameters_dataclass - - -class ZeroOrderModel(DrugReleaseModel): - """Simulator for the zero-order drug release model.""" - - def __init__(self, k0: float, M0: float = 0): - """ - Initialize the Zero-Order model with the given parameters. - - :param k0: Zero-order release rate constant (amount/time) - :param M0: Initial amount of drug released at time zero (default is 0 - """ - super().__init__() - self._model_name = "zero_order" - _params_class = create_parameters_dataclass(self._model_name) - self._parameters = _params_class(k0=k0, M0=M0) - self._plot_parameters["label"] = "Zero-Order Model" - - def __repr__(self): - """Return a string representation of the Zero-Order model.""" - return ( - f"drux.ZeroOrderModel(k0={self._parameters.k0}, M0={self._parameters.M0})" - ) +from .base_model import create_model_class + +ZeroOrderModel = create_model_class( + model_name="zero_order", + class_name="ZeroOrderModel", + label="Zero-Order Model", + docstring="Simulator for the zero-order drug release model.", +) From 23ee1210867bf48f6bd1fe3468fa80c5244c95cb Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:22:46 +0200 Subject: [PATCH 22/32] use create_model_class --- drux/first_order.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/drux/first_order.py b/drux/first_order.py index ec245dd..29eb081 100644 --- a/drux/first_order.py +++ b/drux/first_order.py @@ -1,25 +1,11 @@ # -*- coding: utf-8 -*- """Drux first-order model implementation.""" -from .base_model import DrugReleaseModel -from .utils import create_parameters_dataclass +from .base_model import create_model_class -class FirstOrderModel(DrugReleaseModel): - """Simulator for the first-order drug release model.""" - - def __init__(self, k: float, M0: float = 1) -> None: - """ - Initialize the first-order model with the given parameters. - - :param k: first-order release rate constant (1/s) - :param M0: entire releasable amount of drug (the asymptotic maximum) (mg) - """ - super().__init__() - self._model_name = "first_order" - _params_class = create_parameters_dataclass(self._model_name) - self._parameters = _params_class(k=k, M0=M0) - self._plot_parameters["label"] = "First-Order Model" - - def __repr__(self): - """Return a string representation of the First-Order model.""" - return f"drux.FirstOrderModel(k={self._parameters.k}, M0={self._parameters.M0})" +FirstOrderModel = create_model_class( + model_name="first_order", + class_name="FirstOrderModel", + label="First-Order Model", + docstring="Simulator for the first-order drug release model.", +) From de6d3797d196984a0d4ac8d5a9195c36e83c7a35 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:22:54 +0200 Subject: [PATCH 23/32] use create_model_class --- drux/higuchi.py | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/drux/higuchi.py b/drux/higuchi.py index bb7722e..e8f7966 100644 --- a/drux/higuchi.py +++ b/drux/higuchi.py @@ -1,30 +1,11 @@ # -*- coding: utf-8 -*- """Drux Higuchi model implementation.""" -from .base_model import DrugReleaseModel -from .utils import create_parameters_dataclass - - -class HiguchiModel(DrugReleaseModel): - """Simulator for the Higuchi drug release model using analytical expressions based on concentration conditions.""" - - def __init__(self, D: float, c0: float, cs: float) -> None: - """ - Initialize the Higuchi model with the given parameters. - - :param D: Drug diffusivity in the polymer carrier (cm^2/s) - :param c0: Initial drug concentration (mg/cm^3) - :param cs: Drug solubility in the polymer (mg/cm^3) - """ - super().__init__() - self._model_name = "higuchi" - _params_class = create_parameters_dataclass(self._model_name) - self._parameters = _params_class(D=D, c0=c0, cs=cs) - self._plot_parameters["label"] = "Higuchi Model" - - def __repr__(self): - """Return a string representation of the Higuchi model.""" - return ( - f"drux.HiguchiModel(D={self._parameters.D}, " - f"c0={self._parameters.c0}, cs={self._parameters.cs})" - ) +from .base_model import create_model_class + +HiguchiModel = create_model_class( + model_name="higuchi", + class_name="HiguchiModel", + label="Higuchi Model", + docstring="Simulator for the Higuchi drug release model using analytical expressions based on concentration conditions.", +) From f12b2338862027b0dfeeae6ba847b797d3d99132 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:23:02 +0200 Subject: [PATCH 24/32] use create_model_class --- drux/weibull.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/drux/weibull.py b/drux/weibull.py index e4aaa76..fc24e7f 100644 --- a/drux/weibull.py +++ b/drux/weibull.py @@ -1,27 +1,11 @@ # -*- coding: utf-8 -*- """Drux Weibull model implementation.""" -from .base_model import DrugReleaseModel -from .utils import create_parameters_dataclass - - -class WeibullModel(DrugReleaseModel): - """Simulator for the Weibull drug release model using analytical expressions based on concentration conditions.""" - - def __init__(self, a: float, b: float, M: float = 1) -> None: - """ - Initialize the Weibull model with the given parameters. - - :param M: entire releasable amount of drug (normally M > 0) (mg) - :param a: scale factor - :param b: shape factor - """ - super().__init__() - self._model_name = "weibull" - _params_class = create_parameters_dataclass(self._model_name) - self._parameters = _params_class(M=M, a=a, b=b) - self._plot_parameters["label"] = "Weibull Model" - - def __repr__(self): - """Return a string representation of the Weibull model.""" - return f"drux.WeibullModel(M={self._parameters.M}, a={self._parameters.a}, b={self._parameters.b})" +from .base_model import create_model_class + +WeibullModel = create_model_class( + model_name="weibull", + class_name="WeibullModel", + label="Weibull Model", + docstring="Simulator for the Weibull drug release model using analytical expressions based on concentration conditions.", +) From 03a95ed4ebc1a22b092f186efc619a2f32c9cbf1 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:23:11 +0200 Subject: [PATCH 25/32] use create_model_class --- drux/hopfenberg.py | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/drux/hopfenberg.py b/drux/hopfenberg.py index f8e2495..d11015b 100644 --- a/drux/hopfenberg.py +++ b/drux/hopfenberg.py @@ -1,33 +1,11 @@ # -*- coding: utf-8 -*- """Drux Hopfenberg model implementation.""" -from .base_model import DrugReleaseModel -from .utils import create_parameters_dataclass - - -class HopfenbergModel(DrugReleaseModel): - """Simulator for the Hopfenberg drug release model for surface-eroding polymers.""" - - def __init__(self, k0: float, c0: float, a0: float, n: int, M: float = 1) -> None: - """ - Initialize the Hopfenberg model with the given parameters. - - :param M: entire releasable amount of drug (normally M > 0) (mg) - :param k0: Erosion rate constant (mg/(mm^2·s)) - :param c0: Initial drug concentration in the matrix (mg/mm^3) - :param a0: Initial radius or half-thickness of the device (mm) - :param n: Geometry factor (1=slab, 2=cylinder, 3=sphere) - """ - super().__init__() - self._model_name = "hopfenberg" - _params_class = create_parameters_dataclass(self._model_name) - self._parameters = _params_class(M=M, k0=k0, c0=c0, a0=a0, n=n) - self._plot_parameters["label"] = "Hopfenberg Model" - - def __repr__(self): - """Return a string representation of the Hopfenberg model.""" - return ( - f"drux.HopfenbergModel(M={self._parameters.M}, k0={self._parameters.k0}, " - f"c0={self._parameters.c0}, a0={self._parameters.a0}, " - f"n={self._parameters.n})" - ) +from .base_model import create_model_class + +HopfenbergModel = create_model_class( + model_name="hopfenberg", + class_name="HopfenbergModel", + label="Hopfenberg Model", + docstring="Simulator for the Hopfenberg drug release model for surface-eroding polymers.", +) From 6bc46a60e24b289082f15844c8b8fc0f98dbc5fa Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 13:39:19 +0200 Subject: [PATCH 26/32] minor fixes --- drux/base_model.py | 1 + drux/equations.py | 26 ++++++++++---------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/drux/base_model.py b/drux/base_model.py index f097780..7b8bea6 100644 --- a/drux/base_model.py +++ b/drux/base_model.py @@ -208,6 +208,7 @@ def create_model_class(model_name: str, class_name: str, label: str, docstring: ------- type A new class that inherits from :class:`DrugReleaseModel`. + """ config = MODELS_REGISTRY[model_name] param_specs = config["params"] diff --git a/drux/equations.py b/drux/equations.py index b518e7b..20cc952 100644 --- a/drux/equations.py +++ b/drux/equations.py @@ -1,13 +1,5 @@ # -*- coding: utf-8 -*- -"""Centralized equation and validation registry for drug release models. - -Use the @equation decorator to register model equations. Each equation -receives a parameters object `p` and a time value `t`, and returns the -computed drug release amount. - -Use the @validation decorator to register validation rules for model -parameters, co-located with the equations they belong to. -""" +"""Centralized equation and validation registry for drug release models.""" from math import exp, sqrt @@ -23,7 +15,8 @@ # --------------------------------------------------------------------------- def equation(name): - """Register a simulation equation for the given model name. + """ + Register a simulation equation for the given model name. The decorated function must have the signature ``(p, t) -> float`` where *p* is a parameters dataclass instance. @@ -35,7 +28,8 @@ def decorator(fn): def validation(name): - """Register validation rules for the given model name. + """ + Register validation rules for the given model name. The decorated function must return a dict of validation rules in the form:: @@ -111,7 +105,7 @@ def hopfenberg(p, t): @validation("zero_order") def zero_order_validation(): - """Validation rules for the zero-order model.""" + """Rules for validating the zero-order model.""" from .messages import ERROR_ZERO_ORDER_RELEASE_RATE, ERROR_ZERO_ORDER_INITIAL_AMOUNT return { "k0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_RELEASE_RATE}, @@ -121,7 +115,7 @@ def zero_order_validation(): @validation("first_order") def first_order_validation(): - """Validation rules for the first-order model.""" + """Rules for validating the first-order model.""" from .messages import ERROR_FIRST_ORDER_RELEASE_RATE, ERROR_FIRST_ORDER_INITIAL_AMOUNT return { "k": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_RELEASE_RATE}, @@ -131,7 +125,7 @@ def first_order_validation(): @validation("higuchi") def higuchi_validation(): - """Validation rules for the Higuchi model.""" + """Rules for validating the Higuchi model.""" from .messages import ( ERROR_INVALID_DIFFUSION, ERROR_INVALID_CONCENTRATION, @@ -152,7 +146,7 @@ def higuchi_validation(): @validation("weibull") def weibull_validation(): - """Validation rules for the Weibull model.""" + """Rules for validating the Weibull model.""" from .messages import ( ERROR_RELEASABLE_AMOUNT, ERROR_WEIBULL_SCALE_PARAMETER, @@ -167,7 +161,7 @@ def weibull_validation(): @validation("hopfenberg") def hopfenberg_validation(): - """Validation rules for the Hopfenberg model.""" + """Rules for validating the Hopfenberg model.""" from .messages import ( ERROR_RELEASABLE_AMOUNT, ERROR_INVALID_EROSION_CONSTANT, From 4251f2f64a1b4b552f535679d081df6b79a34a15 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 17:27:22 +0200 Subject: [PATCH 27/32] minor change in dataclass factory --- drux/equations.py | 3 +-- drux/utils.py | 14 ++++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/drux/equations.py b/drux/equations.py index 20cc952..a8a2175 100644 --- a/drux/equations.py +++ b/drux/equations.py @@ -15,8 +15,7 @@ # --------------------------------------------------------------------------- def equation(name): - """ - Register a simulation equation for the given model name. + """Register a simulation equation for the given model name. The decorated function must have the signature ``(p, t) -> float`` where *p* is a parameters dataclass instance. diff --git a/drux/utils.py b/drux/utils.py index 4535b3d..382b139 100644 --- a/drux/utils.py +++ b/drux/utils.py @@ -6,27 +6,25 @@ def create_parameters_dataclass(model_name: str): - """ - Create a dataclass from MODELS_REGISTRY configuration. + """Create a dataclass from MODELS_REGISTRY configuration. :param model_name: Name of the model in MODELS_REGISTRY :return: Dataclass type for model parameters """ config = MODELS_REGISTRY[model_name] - required_fields = [] - optional_fields = [] + fields = [] for param_name, param_info in config["params"].items(): param_type = param_info["type"] default_value = param_info["default"] if default_value is not None: - optional_fields.append((param_name, param_type, field(default=default_value))) + fields.append((param_name, param_type, field(default=default_value))) else: - required_fields.append((param_name, param_type)) + fields.append((param_name, param_type)) - # Required fields must come before optional fields in a dataclass - fields = required_fields + optional_fields + # Required fields (2-tuples) before optional fields (3-tuples) + fields.sort(key=lambda f: len(f)) class_name = f"{model_name.title().replace('_', '')}Parameters" return make_dataclass(class_name, fields) From da15f99a1ee9395eb778b36097c26c4c9e0ac740 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 20:00:13 +0200 Subject: [PATCH 28/32] Add registry by merging params and equations --- drux/registry.py | 315 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 drux/registry.py diff --git a/drux/registry.py b/drux/registry.py new file mode 100644 index 0000000..52a675b --- /dev/null +++ b/drux/registry.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +"""Centralized model registry for drug release models.""" + +from math import exp, sqrt + +from .messages import ( + ERROR_ZERO_ORDER_RELEASE_RATE, + ERROR_ZERO_ORDER_INITIAL_AMOUNT, + ERROR_FIRST_ORDER_RELEASE_RATE, + ERROR_FIRST_ORDER_INITIAL_AMOUNT, + ERROR_INVALID_DIFFUSION, + ERROR_INVALID_CONCENTRATION, + ERROR_INVALID_SOLUBILITY, + ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, + ERROR_RELEASABLE_AMOUNT, + ERROR_WEIBULL_SCALE_PARAMETER, + ERROR_WEIBULL_SHAPE_PARAMETER, + ERROR_INVALID_EROSION_CONSTANT, + ERROR_INVALID_INITIAL_RADIUS, + ERROR_INVALID_GEOMETRY_FACTOR, +) + + +# --------------------------------------------------------------------------- +# Internal registries +# --------------------------------------------------------------------------- +_EQUATIONS = {} +_VALIDATIONS = {} +_PARAMS = {} + + +# --------------------------------------------------------------------------- +# Registration helpers +# --------------------------------------------------------------------------- + +def equation(name): + """Register a simulation equation for the given model name. + + The decorated function must have the signature ``(p, t) -> float`` + where *p* is a parameters namespace instance. + """ + def decorator(fn): + _EQUATIONS[name] = fn + return fn + return decorator + + +def register_validation(name, rules): + """Register validation rules for the given model name. + + :param name: Model name key. + :param rules: Dict of validation rules, e.g.:: + + { + "param_name": {"check": callable, "error": str}, + ... + } + + Cross-parameter validations should include ``"cross_param": True``. + """ + _VALIDATIONS[name] = rules + + +def register_params(name, params): + """Register parameter metadata for the given model name. + + :param name: Model name key. + :param params: OrderedDict-style dict of parameter specs, e.g.:: + + { + "k0": {"type": float, "description": "...", "unit": "...", "default": None}, + ... + } + """ + _PARAMS[name] = params + + +# --------------------------------------------------------------------------- +# Public accessors +# --------------------------------------------------------------------------- + +def get_model_config(name): + """Return the full model configuration dict for *name*. + + :returns: ``{"params": ..., "equation": ..., "validation": ...}`` + :raises KeyError: if *name* is not registered. + """ + return { + "params": _PARAMS[name], + "equation": _EQUATIONS[name], + "validation": _VALIDATIONS[name], + } + + +def get_equation(name): + """Return the simulation equation for *name*.""" + return _EQUATIONS[name] + + +def get_validation(name): + """Return the validation rules for *name*.""" + return _VALIDATIONS[name] + + +def get_params(name): + """Return the parameter metadata for *name*.""" + return _PARAMS[name] + + +def list_models(): + """Return a list of registered model names.""" + return list(_EQUATIONS.keys()) + + +# --------------------------------------------------------------------------- +# Zero-order model +# --------------------------------------------------------------------------- + +register_params("zero_order", { + "k0": { + "type": float, + "description": "Zero-order release rate constant", + "unit": "mg/s", + "default": None, + }, + "M0": { + "type": float, + "description": "Initial amount of drug in the solution", + "unit": "mg", + "default": 0, + }, +}) + + +@equation("zero_order") +def zero_order(p, t): + """Zero-order kinetics: M(t) = M0 + k0 * t.""" + return p.M0 + p.k0 * t + + +register_validation("zero_order", { + "k0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_RELEASE_RATE}, + "M0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_INITIAL_AMOUNT}, +}) + + +# --------------------------------------------------------------------------- +# First-order model +# --------------------------------------------------------------------------- + +register_params("first_order", { + "k": { + "type": float, + "description": "First-order release rate constant", + "unit": "1/s", + "default": None, + }, + "M0": { + "type": float, + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, +}) + + +@equation("first_order") +def first_order(p, t): + """First-order kinetics: M(t) = M0 * (1 - exp(-k * t)).""" + return p.M0 * (1 - exp(-p.k * t)) + + +register_validation("first_order", { + "k": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_RELEASE_RATE}, + "M0": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_INITIAL_AMOUNT}, +}) + + +# --------------------------------------------------------------------------- +# Higuchi model +# --------------------------------------------------------------------------- + +register_params("higuchi", { + "D": { + "type": float, + "description": "Drug diffusivity in the polymer carrier", + "unit": "cm^2/s", + "default": None, + }, + "c0": { + "type": float, + "description": "Initial drug concentration", + "unit": "mg/cm^3", + "default": None, + }, + "cs": { + "type": float, + "description": "Drug solubility in the polymer", + "unit": "mg/cm^3", + "default": None, + }, +}) + + +@equation("higuchi") +def higuchi(p, t): + """Higuchi model: M(t) = sqrt(D * (2*c0 - cs) * cs * t).""" + return sqrt(p.D * (2 * p.c0 - p.cs) * p.cs * t) + + +register_validation("higuchi", { + "D": {"check": lambda v: v > 0, "error": ERROR_INVALID_DIFFUSION}, + "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, + "cs": {"check": lambda v: v > 0, "error": ERROR_INVALID_SOLUBILITY}, + "cs_vs_c0": { + "check": lambda p: p.cs <= p.c0, + "error": ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, + "cross_param": True, + }, +}) + + +# --------------------------------------------------------------------------- +# Weibull model +# --------------------------------------------------------------------------- + +register_params("weibull", { + "M": { + "type": float, + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, + "a": { + "type": float, + "description": "Scale factor", + "unit": "dimensionless", + "default": None, + }, + "b": { + "type": float, + "description": "Shape factor", + "unit": "dimensionless", + "default": None, + }, +}) + + +@equation("weibull") +def weibull(p, t): + """Weibull model: M(t) = M * (1 - exp(-a * t^b)).""" + return p.M * (1 - exp(-p.a * t ** p.b)) + + +register_validation("weibull", { + "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, + "a": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SCALE_PARAMETER}, + "b": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SHAPE_PARAMETER}, +}) + + +# --------------------------------------------------------------------------- +# Hopfenberg model +# --------------------------------------------------------------------------- + +register_params("hopfenberg", { + "M": { + "type": float, + "description": "Entire releasable amount of drug", + "unit": "mg", + "default": 1, + }, + "k0": { + "type": float, + "description": "Erosion rate constant", + "unit": "mg/(mm^2·s)", + "default": None, + }, + "c0": { + "type": float, + "description": "Initial drug concentration in the matrix", + "unit": "mg/mm^3", + "default": None, + }, + "a0": { + "type": float, + "description": "Initial radius or half-thickness of the device", + "unit": "mm", + "default": None, + }, + "n": { + "type": "int", + "description": "Geometry factor (1=slab, 2=cylinder, 3=sphere)", + "unit": "dimensionless", + "default": None, + }, +}) + + +@equation("hopfenberg") +def hopfenberg(p, t): + """Hopfenberg model: M(t) = M * (1 - (1 - k0*t/(c0*a0))^n).""" + return p.M * (1 - (1 - (p.k0 * t) / (p.c0 * p.a0)) ** p.n) + + +register_validation("hopfenberg", { + "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, + "k0": {"check": lambda v: v >= 0, "error": ERROR_INVALID_EROSION_CONSTANT}, + "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, + "a0": {"check": lambda v: v > 0, "error": ERROR_INVALID_INITIAL_RADIUS}, + "n": { + "check": lambda v: v in (1, 2, 3), + "error": ERROR_INVALID_GEOMETRY_FACTOR, + }, +}) From 18dc49eaa618b74a2b14dbf248737ca8efa4fbd1 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 20:00:49 +0200 Subject: [PATCH 29/32] hardcode versions here --- drux/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drux/__init__.py b/drux/__init__.py index 29fb78f..924bd67 100644 --- a/drux/__init__.py +++ b/drux/__init__.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """Drux modules.""" -from .params import DRUX_VERSION +__version__ = "0.3" + from .higuchi import HiguchiModel from .zero_order import ZeroOrderModel from .first_order import FirstOrderModel from .weibull import WeibullModel from .hopfenberg import HopfenbergModel -__version__ = DRUX_VERSION From 5b30bd71527e68defa19ae837f5884d5407f814c Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 20:04:44 +0200 Subject: [PATCH 30/32] merge utils into base_model --- drux/base_model.py | 60 +++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/drux/base_model.py b/drux/base_model.py index 7b8bea6..3732cc3 100644 --- a/drux/base_model.py +++ b/drux/base_model.py @@ -2,7 +2,7 @@ import numpy as np import matplotlib.pyplot as plt -from abc import ABC +from types import SimpleNamespace from typing import Any, Optional from .messages import ( @@ -14,19 +14,17 @@ ERROR_TARGET_RELEASE_EXCEEDS_MAX, ) -from .params import MODELS_REGISTRY -from .utils import create_parameters_dataclass +from .registry import get_model_config -class DrugReleaseModel(ABC): - """ - Abstract base class for drug release models. +class DrugReleaseModel: + """Base class for drug release models. This class provides a common interface and functionality for various mathematical models of drug release from delivery systems. Model classes are typically generated via :func:`create_model_class` - from the centralized ``MODELS_REGISTRY``. All equation logic, + from the centralized model registry. All equation logic, parameter validation, and parameter metadata live in the registry so that subclasses need no custom overrides. """ @@ -45,15 +43,11 @@ def __init__(self): self._model_name = None def _validate_parameters(self) -> None: - """ - Validate model parameters using MODELS_REGISTRY. - - Raises ValueError if parameters are invalid. - """ + """Validate model parameters using the model registry.""" if self._model_name is None or self._parameters is None: raise ValueError("Model name and parameters must be set") - config = MODELS_REGISTRY[self._model_name] + config = get_model_config(self._model_name) for param_name, rule in config["validation"].items(): if rule.get("cross_param"): @@ -75,7 +69,7 @@ def _model_function(self, t: float) -> float: if self._model_name is None or self._parameters is None: raise ValueError("Model name and parameters must be set") - config = MODELS_REGISTRY[self._model_name] + config = get_model_config(self._model_name) return config["equation"](self._parameters, t) def _get_release_profile(self) -> np.ndarray: @@ -189,28 +183,23 @@ def time_for_release(self, target_release: float) -> float: return self._time_points[idx] -def create_model_class(model_name: str, class_name: str, label: str, docstring: str = "") -> type: +def _create_parameters(**kwargs): + """Create a parameters namespace. + + :param kwargs: Parameter values """ - Generate a :class:`DrugReleaseModel` subclass from ``MODELS_REGISTRY``. - - Parameters - ---------- - model_name : str - Key in ``MODELS_REGISTRY`` (e.g. ``"zero_order"``). - class_name : str - Name of the generated class (e.g. ``"ZeroOrderModel"``). - label : str - Plot legend label (e.g. ``"Zero-Order Model"``). - docstring : str, optional - Docstring for the generated class. - - Returns - ------- - type - A new class that inherits from :class:`DrugReleaseModel`. - + return SimpleNamespace(**kwargs) + + +def create_model_class(model_name: str, class_name: str, label: str, docstring: str = "") -> type: + """Generate a :class:`DrugReleaseModel` subclass from the model registry. + + :param model_name: Key in the model registry (e.g. ``"zero_order"``) + :param class_name: Name of the generated class (e.g. ``"ZeroOrderModel"``) + :param label: Plot legend label (e.g. ``"Zero-Order Model"``) + :param docstring: Docstring for the generated class """ - config = MODELS_REGISTRY[model_name] + config = get_model_config(model_name) param_specs = config["params"] # Build the ordered list of (name, type, default|REQUIRED) for __init__ @@ -232,8 +221,7 @@ def __init__(self, **kwargs): raise TypeError(f"Missing required parameter: {pname}") DrugReleaseModel.__init__(self) self._model_name = model_name - _params_class = create_parameters_dataclass(model_name) - self._parameters = _params_class(**kwargs) + self._parameters = _create_parameters(**kwargs) self._plot_parameters["label"] = label def __repr__(self): From 29510e1664dd4183895901e7ded25fad3de6064e Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 20:05:14 +0200 Subject: [PATCH 31/32] remove unused files --- drux/equations.py | 180 ---------------------------------------------- drux/params.py | 129 --------------------------------- drux/utils.py | 30 -------- 3 files changed, 339 deletions(-) delete mode 100644 drux/equations.py delete mode 100644 drux/params.py delete mode 100644 drux/utils.py diff --git a/drux/equations.py b/drux/equations.py deleted file mode 100644 index a8a2175..0000000 --- a/drux/equations.py +++ /dev/null @@ -1,180 +0,0 @@ -# -*- coding: utf-8 -*- -"""Centralized equation and validation registry for drug release models.""" - -from math import exp, sqrt - -# --------------------------------------------------------------------------- -# Internal registries – accessed via helper functions, not directly. -# --------------------------------------------------------------------------- -_EQUATIONS = {} -_VALIDATIONS = {} - - -# --------------------------------------------------------------------------- -# Decorators -# --------------------------------------------------------------------------- - -def equation(name): - """Register a simulation equation for the given model name. - - The decorated function must have the signature ``(p, t) -> float`` - where *p* is a parameters dataclass instance. - """ - def decorator(fn): - _EQUATIONS[name] = fn - return fn - return decorator - - -def validation(name): - """ - Register validation rules for the given model name. - - The decorated function must return a dict of validation rules in the form:: - - { - "param_name": {"check": callable, "error": str}, - ... - } - - Cross-parameter validations should include ``"cross_param": True``. - """ - def decorator(fn): - _VALIDATIONS[name] = fn() - return fn - return decorator - - -# --------------------------------------------------------------------------- -# Public helpers -# --------------------------------------------------------------------------- - -def get_equation(name): - """Return the simulation equation for *name*, or raise KeyError.""" - return _EQUATIONS[name] - - -def get_validation(name): - """Return the validation rules for *name*, or raise KeyError.""" - return _VALIDATIONS[name] - - -def list_equations(): - """Return a list of registered equation names.""" - return list(_EQUATIONS.keys()) - - -# --------------------------------------------------------------------------- -# Simulation equations (p, t) -> float -# --------------------------------------------------------------------------- - -@equation("zero_order") -def zero_order(p, t): - """Zero-order kinetics: M(t) = M0 + k0 * t.""" - return p.M0 + p.k0 * t - - -@equation("first_order") -def first_order(p, t): - """First-order kinetics: M(t) = M0 * (1 - exp(-k * t)).""" - return p.M0 * (1 - exp(-p.k * t)) - - -@equation("higuchi") -def higuchi(p, t): - """Higuchi model: M(t) = sqrt(D * (2*c0 - cs) * cs * t).""" - return sqrt(p.D * (2 * p.c0 - p.cs) * p.cs * t) - - -@equation("weibull") -def weibull(p, t): - """Weibull model: M(t) = M * (1 - exp(-a * t^b)).""" - return p.M * (1 - exp(-p.a * t ** p.b)) - - -@equation("hopfenberg") -def hopfenberg(p, t): - """Hopfenberg model: M(t) = M * (1 - (1 - k0*t/(c0*a0))^n).""" - return p.M * (1 - (1 - (p.k0 * t) / (p.c0 * p.a0)) ** p.n) - - -# --------------------------------------------------------------------------- -# Validation rules – co-located with equations -# --------------------------------------------------------------------------- - -@validation("zero_order") -def zero_order_validation(): - """Rules for validating the zero-order model.""" - from .messages import ERROR_ZERO_ORDER_RELEASE_RATE, ERROR_ZERO_ORDER_INITIAL_AMOUNT - return { - "k0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_RELEASE_RATE}, - "M0": {"check": lambda v: v >= 0, "error": ERROR_ZERO_ORDER_INITIAL_AMOUNT}, - } - - -@validation("first_order") -def first_order_validation(): - """Rules for validating the first-order model.""" - from .messages import ERROR_FIRST_ORDER_RELEASE_RATE, ERROR_FIRST_ORDER_INITIAL_AMOUNT - return { - "k": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_RELEASE_RATE}, - "M0": {"check": lambda v: v >= 0, "error": ERROR_FIRST_ORDER_INITIAL_AMOUNT}, - } - - -@validation("higuchi") -def higuchi_validation(): - """Rules for validating the Higuchi model.""" - from .messages import ( - ERROR_INVALID_DIFFUSION, - ERROR_INVALID_CONCENTRATION, - ERROR_INVALID_SOLUBILITY, - ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, - ) - return { - "D": {"check": lambda v: v > 0, "error": ERROR_INVALID_DIFFUSION}, - "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, - "cs": {"check": lambda v: v > 0, "error": ERROR_INVALID_SOLUBILITY}, - "cs_vs_c0": { - "check": lambda p: p.cs <= p.c0, - "error": ERROR_SOLUBILITY_HIGHER_THAN_CONCENTRATION, - "cross_param": True, - }, - } - - -@validation("weibull") -def weibull_validation(): - """Rules for validating the Weibull model.""" - from .messages import ( - ERROR_RELEASABLE_AMOUNT, - ERROR_WEIBULL_SCALE_PARAMETER, - ERROR_WEIBULL_SHAPE_PARAMETER, - ) - return { - "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, - "a": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SCALE_PARAMETER}, - "b": {"check": lambda v: v > 0, "error": ERROR_WEIBULL_SHAPE_PARAMETER}, - } - - -@validation("hopfenberg") -def hopfenberg_validation(): - """Rules for validating the Hopfenberg model.""" - from .messages import ( - ERROR_RELEASABLE_AMOUNT, - ERROR_INVALID_EROSION_CONSTANT, - ERROR_INVALID_CONCENTRATION, - ERROR_INVALID_INITIAL_RADIUS, - ERROR_INVALID_GEOMETRY_FACTOR, - ) - return { - "M": {"check": lambda v: v >= 0, "error": ERROR_RELEASABLE_AMOUNT}, - "k0": {"check": lambda v: v >= 0, "error": ERROR_INVALID_EROSION_CONSTANT}, - "c0": {"check": lambda v: v > 0, "error": ERROR_INVALID_CONCENTRATION}, - "a0": {"check": lambda v: v > 0, "error": ERROR_INVALID_INITIAL_RADIUS}, - "n": { - "check": lambda v: v in (1, 2, 3), - "error": ERROR_INVALID_GEOMETRY_FACTOR, - }, - } diff --git a/drux/params.py b/drux/params.py deleted file mode 100644 index d8a534e..0000000 --- a/drux/params.py +++ /dev/null @@ -1,129 +0,0 @@ -# -*- coding: utf-8 -*- -"""Drux parameters and constants.""" - -from .equations import get_equation, get_validation - -DRUX_VERSION = "0.3" - -MODELS_REGISTRY = { - "zero_order": { - "params": { - "k0": { - "type": float, - "description": "Zero-order release rate constant", - "unit": "mg/s", - "default": None, - }, - "M0": { - "type": float, - "description": "Initial amount of drug in the solution", - "unit": "mg", - "default": 0, - }, - }, - "equation": get_equation("zero_order"), - "validation": get_validation("zero_order"), - }, - "first_order": { - "params": { - "k": { - "type": float, - "description": "First-order release rate constant", - "unit": "1/s", - "default": None, - }, - "M0": { - "type": float, - "description": "Entire releasable amount of drug", - "unit": "mg", - "default": 1, - }, - }, - "equation": get_equation("first_order"), - "validation": get_validation("first_order"), - }, - "higuchi": { - "params": { - "D": { - "type": float, - "description": "Drug diffusivity in the polymer carrier", - "unit": "cm^2/s", - "default": None, - }, - "c0": { - "type": float, - "description": "Initial drug concentration", - "unit": "mg/cm^3", - "default": None, - }, - "cs": { - "type": float, - "description": "Drug solubility in the polymer", - "unit": "mg/cm^3", - "default": None, - }, - }, - "equation": get_equation("higuchi"), - "validation": get_validation("higuchi"), - }, - "weibull": { - "params": { - "M": { - "type": float, - "description": "Entire releasable amount of drug", - "unit": "mg", - "default": 1, - }, - "a": { - "type": float, - "description": "Scale factor", - "unit": "dimensionless", - "default": None, - }, - "b": { - "type": float, - "description": "Shape factor", - "unit": "dimensionless", - "default": None, - }, - }, - "equation": get_equation("weibull"), - "validation": get_validation("weibull"), - }, - "hopfenberg": { - "params": { - "M": { - "type": float, - "description": "Entire releasable amount of drug", - "unit": "mg", - "default": 1, - }, - "k0": { - "type": float, - "description": "Erosion rate constant", - "unit": "mg/(mm^2·s)", - "default": None, - }, - "c0": { - "type": float, - "description": "Initial drug concentration in the matrix", - "unit": "mg/mm^3", - "default": None, - }, - "a0": { - "type": float, - "description": "Initial radius or half-thickness of the device", - "unit": "mm", - "default": None, - }, - "n": { - "type": "int", - "description": "Geometry factor (1=slab, 2=cylinder, 3=sphere)", - "unit": "dimensionless", - "default": None, - }, - }, - "equation": get_equation("hopfenberg"), - "validation": get_validation("hopfenberg"), - }, -} diff --git a/drux/utils.py b/drux/utils.py deleted file mode 100644 index 382b139..0000000 --- a/drux/utils.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -"""Generic utilities for Drux.""" - -from dataclasses import field, make_dataclass -from .params import MODELS_REGISTRY - - -def create_parameters_dataclass(model_name: str): - """Create a dataclass from MODELS_REGISTRY configuration. - - :param model_name: Name of the model in MODELS_REGISTRY - :return: Dataclass type for model parameters - """ - config = MODELS_REGISTRY[model_name] - fields = [] - - for param_name, param_info in config["params"].items(): - param_type = param_info["type"] - default_value = param_info["default"] - - if default_value is not None: - fields.append((param_name, param_type, field(default=default_value))) - else: - fields.append((param_name, param_type)) - - # Required fields (2-tuples) before optional fields (3-tuples) - fields.sort(key=lambda f: len(f)) - - class_name = f"{model_name.title().replace('_', '')}Parameters" - return make_dataclass(class_name, fields) From 6dc953738d02792bcc60c0765dcfde754568c519 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Thu, 14 May 2026 20:11:22 +0200 Subject: [PATCH 32/32] revert params for version --- drux/__init__.py | 4 +++- drux/params.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 drux/params.py diff --git a/drux/__init__.py b/drux/__init__.py index 924bd67..69fb951 100644 --- a/drux/__init__.py +++ b/drux/__init__.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- """Drux modules.""" -__version__ = "0.3" +from .params import DRUX_VERSION + +__version__ = DRUX_VERSION from .higuchi import HiguchiModel from .zero_order import ZeroOrderModel diff --git a/drux/params.py b/drux/params.py new file mode 100644 index 0000000..d3408e1 --- /dev/null +++ b/drux/params.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +"""Drux parameters and constants.""" + +DRUX_VERSION = "0.3" +