From f92b58d1d6303ceb12caed18e54f9f5aa9820af3 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 12:40:45 +0100 Subject: [PATCH 01/10] init hopfenberg --- drux/hopfenberg.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ drux/messages.py | 7 +++- drux/weibull.py | 4 +- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 drux/hopfenberg.py diff --git a/drux/hopfenberg.py b/drux/hopfenberg.py new file mode 100644 index 0000000..cca1115 --- /dev/null +++ b/drux/hopfenberg.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""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 + + +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: + """ + 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._parameters = HopfenbergParameters(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})" + ) + + 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) diff --git a/drux/messages.py b/drux/messages.py index 1ca0b1d..9d7b189 100644 --- a/drux/messages.py +++ b/drux/messages.py @@ -36,6 +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_WEIBULL_INITIAL_AMOUNT = ( +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)." diff --git a/drux/weibull.py b/drux/weibull.py index 522ec8a..8754df2 100644 --- a/drux/weibull.py +++ b/drux/weibull.py @@ -4,7 +4,7 @@ from .base_model import DrugReleaseModel from .messages import ( ERROR_WEIBULL_SCALE_PARAMETER, - ERROR_WEIBULL_INITIAL_AMOUNT, + ERROR_RELEASABLE_AMOUNT, ERROR_WEIBULL_SHAPE_PARAMETER, ) from dataclasses import dataclass @@ -65,7 +65,7 @@ def _model_function(self, t: float) -> float: def _validate_parameters(self) -> None: """Validate the parameters of the Weibull model.""" if self._parameters.M < 0: - raise ValueError(ERROR_WEIBULL_INITIAL_AMOUNT) + raise ValueError(ERROR_RELEASABLE_AMOUNT) if self._parameters.a <= 0: raise ValueError(ERROR_WEIBULL_SCALE_PARAMETER) if self._parameters.b <= 0: From b37b08635c5d475e8a5ce54e039b3cdb5e25b403 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 12:41:07 +0100 Subject: [PATCH 02/10] add hopfenberg --- drux/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/drux/__init__.py b/drux/__init__.py index f2c91ef..e6788d6 100644 --- a/drux/__init__.py +++ b/drux/__init__.py @@ -6,5 +6,6 @@ from .zero_order import ZeroOrderModel, ZeroOrderParameters from .first_order import FirstOrderModel, FirstOrderParameters from .weibull import WeibullModel, WeibullParameters +from .hopfenberg import HopfenbergModel, HopfenbergParameters __version__ = DRUX_VERSION From f374fe10be5958943d7b2c766b1b3f37fdb785fe Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 12:42:46 +0100 Subject: [PATCH 03/10] add plot --- otherfiles/hopfenberg_plot.png | Bin 0 -> 29193 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 otherfiles/hopfenberg_plot.png diff --git a/otherfiles/hopfenberg_plot.png b/otherfiles/hopfenberg_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..dd9825fd2e489bb3bd2ac948f74d40aaaf726d6b GIT binary patch literal 29193 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

q>CUZcT6XEBp{ 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 ZsxwtW-}}a@8}Q*MyLFB>`BuA5{s(CB8BhQK literal 0 HcmV?d00001 From 0e29f25d3a7037143cbcee025d9fa353a71605d1 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 12:43:52 +0100 Subject: [PATCH 04/10] reformatted by black --- drux/hopfenberg.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/drux/hopfenberg.py b/drux/hopfenberg.py index cca1115..89074f9 100644 --- a/drux/hopfenberg.py +++ b/drux/hopfenberg.py @@ -7,7 +7,7 @@ ERROR_INVALID_INITIAL_RADIUS, ERROR_INVALID_GEOMETRY_FACTOR, ERROR_INVALID_CONCENTRATION, - ERROR_RELEASABLE_AMOUNT + ERROR_RELEASABLE_AMOUNT, ) from dataclasses import dataclass @@ -23,6 +23,7 @@ class HopfenbergParameters: 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 @@ -33,7 +34,7 @@ class HopfenbergParameters: 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, M: float, k0: float, c0: float, a0: float, n: int) -> None: """ Initialize the Hopfenberg model with the given parameters. @@ -73,7 +74,7 @@ def _model_function(self, t: float) -> float: inner_term = 1 - (k0 * t) / (c0 * a0) - Mt = M*(1 - (inner_term ** n)) + Mt = M * (1 - (inner_term**n)) return Mt From f72bab5ca154f43d9557601f68ba5281d2029d6f Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 12:44:14 +0100 Subject: [PATCH 05/10] add hopfenberg --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 87a7aae..74dd384 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,29 @@ where: 3. Comparative studies 4. Vivo predictions +### Hopfenberg +The Hopfenberg model describes drug release from surface-eroding polymers with a constant surface area. +It is particularly useful for modeling drug release from biodegradable polymers where the drug is uniformly distributed throughout the matrix. +According to this model, the cumulative amount of drug released at time $t$ is given by: + +$$ +M_t = M_{\infty} \left(1 - \left(1 - \frac{k_0t}{c_0 a_0}\right)^n\right) +$$ + +where: +- $M_t (mg)$ is the cumulative absolute amount of drug released at time $t +- $M_{\infty} (mg)$ is the total amount of drug released at infinite time +- $k_0 (\frac{mg}{mm^2 s})$ is the surface erosion rate constant +- $c_0 (\frac{mg}{mm^3})$ is the initial drug concentration in the polymer matrix +- $a_0 (mm)$ is the initial radius (for spheres) or half-thickness (for slabs) of the device +- $n$ is the geometry-dependent exponent (1 for slabs, 2 for cylinders, 3 for spheres) + +#### Applications +1. Biodegradable Implants +2. Surface-eroding drug delivery systems +3. Transdermal Patches +4. Injectable depots + ## Usage ### Zero-Order Model ```python @@ -188,6 +211,17 @@ model.plot(show=True) ``` Weibull Plot +### Hopfenberg Model + +```python +from drux import HopfenbergModel +model = HopfenbergModel(M=1, k0=0.00067, c0=0.0374, a0=3.51, n=2) +model.simulate(duration=100, time_step=1) +model.plot(show=True) +``` +Hopfenberg Plot + + ## Issues & bug reports Just fill an issue and describe it. We'll check it ASAP! or send an email to [drux@openscilab.com](mailto:drux@openscilab.com "drux@openscilab.com"). From 8aff40f349e783ca1d6a93fc598996a670238e60 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 12:44:37 +0100 Subject: [PATCH 06/10] add hopfenberg test --- tests/test_hopfenberg.py | 149 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/test_hopfenberg.py diff --git a/tests/test_hopfenberg.py b/tests/test_hopfenberg.py new file mode 100644 index 0000000..a91e353 --- /dev/null +++ b/tests/test_hopfenberg.py @@ -0,0 +1,149 @@ +"""Tests for the Hopfenberg model implementation in drux package.""" + +from pytest import raises +from numpy import isclose +from re import escape +from drux import HopfenbergModel + +TEST_CASE_NAME = "Hopfenberg model tests" +M, k0, c0, a0, n = 1, 0.00067, 0.0374, 3.51, 2 +SIM_DURATION, SIM_TIME_STEP = 100, 1 +RELATIVE_TOLERANCE = 1e-1 + + +def test_hopfenberg_parameters(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + assert model._parameters.M == M + assert model._parameters.k0 == k0 + assert model._parameters.c0 == c0 + assert model._parameters.a0 == a0 + assert model._parameters.n == n + + +def test_invalid_parameters(): + with raises(ValueError, match=escape("Entire releasable amount of drug (M) must be non-negative.")): + HopfenbergModel(M=-M, k0=k0, c0=c0, a0=a0, n=n).simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + with raises(ValueError, match=escape("Erosion rate constant (k0) must be non-negative")): + HopfenbergModel(M=M, k0=-k0, c0=c0, a0=a0, n=n).simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + with raises(ValueError, match=escape("Initial drug concentration (c0) must be positive.")): + HopfenbergModel(M=M, k0=k0, c0=-c0, a0=a0, n=n).simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + with raises(ValueError, match=escape("Initial radius or half-thickness (a0) must be positive.")): + HopfenbergModel(M=M, k0=k0, c0=c0, a0=-a0, n=n).simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + with raises(ValueError, match=escape("Geometry factor (n) must be 1 (slab), 2 (cylinder), or 3 (sphere).")): + HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=4).simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + +def test_repr(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + repr_str = repr(model) + assert repr_str == f"drux.HopfenbergModel(M={M}, k0={k0}, c0={c0}, a0={a0}, n={n})" + + +def test_hopfenberg_simulation(): # Reference: https://pmc.ncbi.nlm.nih.gov/articles/PMC3500559/ + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + profile = model.simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + actual_release = [M * (1 - (1 - (k0 * t) / (c0 * a0))**n) for t in range(0, SIM_DURATION+SIM_TIME_STEP, SIM_TIME_STEP)] + assert all(isclose(p, r, rtol=RELATIVE_TOLERANCE) for p, r in zip(profile, actual_release)) + + +def test_hopfenberg_simulation_errors(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + + with raises(ValueError, match="Duration and time step must be positive values"): + model.simulate(duration=-100, time_step=10) + + with raises(ValueError, match="Duration and time step must be positive values"): + model.simulate(duration=100, time_step=-10) + + with raises(ValueError, match="Time step cannot be greater than duration"): + model.simulate(duration=10, time_step=20) + + +def test_hopfenberg_plot1(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + model.simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + fig, ax = model.plot() + assert fig is not None + assert ax is not None + assert ax.get_title() == model._plot_parameters["title"] + assert ax.get_xlabel() == model._plot_parameters["xlabel"] + assert ax.get_ylabel() == model._plot_parameters["ylabel"] + assert [text.get_text() for text in ax.get_legend().get_texts()] == [model._plot_parameters["label"]] + + +def test_hopfenberg_plot2(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + model.simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + fig, ax = model.plot(title="test-title", xlabel="test-xlabel", ylabel="test-ylabel", label="test-label") + assert fig is not None + assert ax is not None + assert ax.get_title() == "test-title" + assert ax.get_xlabel() == "test-xlabel" + assert ax.get_ylabel() == "test-ylabel" + assert [text.get_text() for text in ax.get_legend().get_texts()] == ["test-label"] + + +def test_hopfenberg_plot_error(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + + with raises(ValueError, match=escape("No simulation data available. Run simulate() first.")): + model.plot() + + model._time_points = [0] # manually set time points to simulate error (TODO: it will be caught with prior errors) + # manually set a too short profile to simulate error (TODO: it will be caught with prior errors) + model._release_profile = [0.0] + with raises(ValueError, match="Release profile is too short to calculate release rate."): + model.plot() + + +def test_hopfenberg_release_rate(): # Reference: https://www.wolframalpha.com/input?i=get+the+derivative+of+%28M+*+%281+-+%281+-+%28k0+*+t%29+%2F+%28c0+*+a0%29%29**n%29%29+with+respect+to+t + small_timestep = 0.001 # smaller time step for better accuracy in numerical derivative + small_duration = 10 + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + model.simulate(duration=small_duration, time_step=small_timestep) + rate = model.get_release_rate().tolist() + import numpy as np + actual_rate = [ + k0*M*n*((1-((k0*t)/(a0*c0)))**(n-1))/(a0*c0) + for t in np.arange(small_timestep, small_duration + small_timestep, small_timestep) + ] # avoid t=0 to prevent division by zero + assert all(isclose(r, ar, rtol=1e-1) for r, ar in zip(rate[2:], actual_rate[1:])) # skip first two points due to numerical derivative inaccuracies + + +def test_hopfenberg_release_rate_error(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + + with raises(ValueError, match=escape("No simulation data available. Run simulate() first.")): + model.get_release_rate() + + model._time_points = [0] # manually set time points to simulate error (TODO: it will be caught with prior errors) + # manually set a too short profile to simulate error (TODO: it will be caught with prior errors) + model._release_profile = [0.0] + with raises(ValueError, match="Release profile is too short to calculate release rate."): + model.get_release_rate() + + +def test_hopfenberg_time_for_release(): # Reference: https://www.wolframalpha.com/input?i=solve+for+t+in+%281+-+%281+-+%280.00067*+t%29+%2F+%280.0374+*+3.51%29%29**2%29+%3D+0.8*0.7602750594732341 + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + model.simulate(duration=SIM_DURATION, time_step=1) + tx = model.time_for_release(0.8 * model._release_profile[-1]) + assert isclose(tx, 74, rtol=1e-2) + + +def test_hopfenberg_time_for_release_error(): + model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) + + with raises(ValueError, match=escape("No simulation data available. Run simulate() first.")): + model.time_for_release(0.5) + model.simulate(duration=SIM_DURATION, time_step=SIM_TIME_STEP) + + with raises(ValueError, match="Target release must be non-negative."): + model.time_for_release(-0.1) + + with raises(ValueError, match="Target release exceeds maximum release of the simulated duration."): + model.time_for_release(model._release_profile[-1] + 0.1) From 83bb77491221af8da4b7767a0fa06894911fe4a1 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 13:07:26 +0100 Subject: [PATCH 07/10] minor edit --- tests/test_hopfenberg.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_hopfenberg.py b/tests/test_hopfenberg.py index a91e353..488fbc5 100644 --- a/tests/test_hopfenberg.py +++ b/tests/test_hopfenberg.py @@ -101,13 +101,14 @@ def test_hopfenberg_plot_error(): model.plot() -def test_hopfenberg_release_rate(): # Reference: https://www.wolframalpha.com/input?i=get+the+derivative+of+%28M+*+%281+-+%281+-+%28k0+*+t%29+%2F+%28c0+*+a0%29%29**n%29%29+with+respect+to+t +def test_hopfenberg_release_rate(): small_timestep = 0.001 # smaller time step for better accuracy in numerical derivative small_duration = 10 model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) model.simulate(duration=small_duration, time_step=small_timestep) rate = model.get_release_rate().tolist() import numpy as np + # Ref: https://www.wolframalpha.com/input?i=get+the+derivative+of+%28M+*+%281+-+%281+-+%28k0+*+t%29+%2F+%28c0+*+a0%29%29**n%29%29+with+respect+to+t actual_rate = [ k0*M*n*((1-((k0*t)/(a0*c0)))**(n-1))/(a0*c0) for t in np.arange(small_timestep, small_duration + small_timestep, small_timestep) @@ -128,10 +129,11 @@ def test_hopfenberg_release_rate_error(): model.get_release_rate() -def test_hopfenberg_time_for_release(): # Reference: https://www.wolframalpha.com/input?i=solve+for+t+in+%281+-+%281+-+%280.00067*+t%29+%2F+%280.0374+*+3.51%29%29**2%29+%3D+0.8*0.7602750594732341 +def test_hopfenberg_time_for_release(): model = HopfenbergModel(M=M, k0=k0, c0=c0, a0=a0, n=n) model.simulate(duration=SIM_DURATION, time_step=1) tx = model.time_for_release(0.8 * model._release_profile[-1]) + # Ref: https://www.wolframalpha.com/input?i=solve+for+t+in+%281+-+%281+-+%280.00067*+t%29+%2F+%280.0374+*+3.51%29%29**2%29+%3D+0.8*0.7602750594732341 assert isclose(tx, 74, rtol=1e-2) From 0a9a985c9b134d962c6cc209751185ebc3e24d28 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 15:23:16 +0100 Subject: [PATCH 08/10] fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74dd384..c44a17c 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ where: 4. Vivo predictions ### Hopfenberg -The Hopfenberg model describes drug release from surface-eroding polymers with a constant surface area. +The Hopfenberg model describes drug release from surface-eroding polymers with a constant surface area. It is particularly useful for modeling drug release from biodegradable polymers where the drug is uniformly distributed throughout the matrix. According to this model, the cumulative amount of drug released at time $t$ is given by: From ed2ebff9cb767bc3a10c441cb8ddabba173fb5b5 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Wed, 24 Dec 2025 15:25:06 +0100 Subject: [PATCH 09/10] add hopfenberg --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 574ad21..a680529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Hopfenberg model ## [0.3] - 2025-12-08 ### Added - Weibull model From 57d79a3330bbab38f4d89821b3ff5de1f6d52760 Mon Sep 17 00:00:00 2001 From: alirezazolanvari Date: Fri, 26 Dec 2025 12:49:38 +0100 Subject: [PATCH 10/10] add hopfenberg refs --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index c44a17c..61e754b 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,9 @@ You can also join our discord server

5- S. Dash, "Kinetic modeling on drug release from controlled drug delivery systems," Acta Poloniae Pharmaceutica, 2010.
6- K. H. Ramteke, P. A. Dighe, A. R. Kharat, S. V. Patil, Mathematical models of drug dissolution: A review, Sch. Acad. J. Pharm., vol. 3, no. 5, pp. 388-396, 2014.
7- C. Corsaro, G. Neri, A. M. Mezzasalma, and E. Fazio, "Weibull modeling of controlled drug release from Ag-PMA nanosystems," Polymers, vol. 13, no. 17, p. 2897, 2021.
+
8- H. B. Hopfenberg, "Controlled release from erodible slabs, cylinders, and spheres," in Controlled Release Polymeric Formulations, ACS Symposium Series, vol. 33, pp. 26–32, 1976.
+
9- H. V. Chavda, M. S. Patel, and C. N. Patel, "Preparation and in vitro evaluation of guar gum based triple-layer matrix tablet of diclofenac sodium," Research in Pharmaceutical Sciences, vol. 7, no. 1, pp. 57–64, Jan. 2012.
+ ## Show your support ### Star this repo