openpkflow 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openpkflow/__init__.py +10 -0
- openpkflow/bayes/__init__.py +1 -0
- openpkflow/cli.py +123 -0
- openpkflow/datasets/__init__.py +26 -0
- openpkflow/datasets/example_dissolution.csv +37 -0
- openpkflow/datasets/example_not_similar.csv +37 -0
- openpkflow/datasets/example_similar.csv +37 -0
- openpkflow/dissolution/__init__.py +18 -0
- openpkflow/dissolution/bootstrap.py +161 -0
- openpkflow/dissolution/loader.py +159 -0
- openpkflow/dissolution/plotting.py +68 -0
- openpkflow/dissolution/reporting.py +169 -0
- openpkflow/dissolution/similarity.py +163 -0
- openpkflow/dissolution/study.py +275 -0
- openpkflow/ml/__init__.py +1 -0
- openpkflow/nca/__init__.py +1 -0
- openpkflow/pop/__init__.py +1 -0
- openpkflow/py.typed +0 -0
- openpkflow/report/__init__.py +8 -0
- openpkflow/report/html.py +107 -0
- openpkflow/report/templates/dissolution_report.html +333 -0
- openpkflow/sim/__init__.py +1 -0
- openpkflow/validation/__init__.py +1 -0
- openpkflow-0.1.2.dist-info/METADATA +191 -0
- openpkflow-0.1.2.dist-info/RECORD +28 -0
- openpkflow-0.1.2.dist-info/WHEEL +4 -0
- openpkflow-0.1.2.dist-info/entry_points.txt +2 -0
- openpkflow-0.1.2.dist-info/licenses/LICENSE +21 -0
openpkflow/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""OpenPKFlow -- Python-first pharmacometrics and dissolution toolkit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__author__ = "Priyam Thakar"
|
|
7
|
+
__email__ = "priyamthakar1@gmail.com"
|
|
8
|
+
__license__ = "MIT"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bayesian PK module -- planned for v0.8.0."""
|
openpkflow/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""OpenPKFlow CLI - command-line interface built with Typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from openpkflow import __version__
|
|
11
|
+
from openpkflow.dissolution.loader import DissolutionCSVConfig
|
|
12
|
+
from openpkflow.dissolution.similarity import f1, f2
|
|
13
|
+
from openpkflow.dissolution.study import DissolutionStudy
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="openpkflow",
|
|
17
|
+
help="OpenPKFlow - Python-first pharmacometrics toolkit.",
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
dissolution_app = typer.Typer(help="Dissolution similarity commands.")
|
|
22
|
+
app.add_typer(dissolution_app, name="dissolution")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("version")
|
|
26
|
+
def version_command() -> None:
|
|
27
|
+
"""Print the installed version of openpkflow."""
|
|
28
|
+
typer.echo(f"openpkflow {__version__}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("similarity")
|
|
32
|
+
def similarity_command(
|
|
33
|
+
reference: str = typer.Option(
|
|
34
|
+
...,
|
|
35
|
+
"--reference",
|
|
36
|
+
help="Comma-separated reference dissolution profile (percent released per time point).",
|
|
37
|
+
),
|
|
38
|
+
test: str = typer.Option(
|
|
39
|
+
...,
|
|
40
|
+
"--test",
|
|
41
|
+
help="Comma-separated test dissolution profile (percent released per time point).",
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Compute f1 and f2 from two comma-separated dissolution profiles.
|
|
45
|
+
|
|
46
|
+
Example
|
|
47
|
+
-------
|
|
48
|
+
openpkflow similarity --reference "20,40,60,80,90" --test "21,39,61,79,88"
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
ref_vals = [float(v.strip()) for v in reference.split(",")]
|
|
52
|
+
tst_vals = [float(v.strip()) for v in test.split(",")]
|
|
53
|
+
f1_val = f1(ref_vals, tst_vals)
|
|
54
|
+
f2_val = f2(ref_vals, tst_vals)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
typer.echo(f"f1 = {f1_val:.3f}")
|
|
60
|
+
typer.echo(f"f2 = {f2_val:.2f}")
|
|
61
|
+
interpretation = (
|
|
62
|
+
"f2 >= 50: profiles are similar."
|
|
63
|
+
if f2_val >= 50.0
|
|
64
|
+
else "f2 < 50: profiles are not similar."
|
|
65
|
+
)
|
|
66
|
+
typer.echo(f"Interpretation: {interpretation}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dissolution_app.command("compare")
|
|
70
|
+
def dissolution_compare(
|
|
71
|
+
csv_path: Path = typer.Argument(
|
|
72
|
+
...,
|
|
73
|
+
help="Path to dissolution CSV file.",
|
|
74
|
+
exists=True,
|
|
75
|
+
file_okay=True,
|
|
76
|
+
dir_okay=False,
|
|
77
|
+
readable=True,
|
|
78
|
+
),
|
|
79
|
+
reference: str = typer.Option(
|
|
80
|
+
...,
|
|
81
|
+
"--reference",
|
|
82
|
+
help="Label of the reference formulation.",
|
|
83
|
+
),
|
|
84
|
+
test: str = typer.Option(
|
|
85
|
+
...,
|
|
86
|
+
"--test",
|
|
87
|
+
help="Label of the test formulation.",
|
|
88
|
+
),
|
|
89
|
+
formulation_col: str = typer.Option("formulation", help="Formulation column name."),
|
|
90
|
+
batch_col: str = typer.Option("batch", help="Batch column name."),
|
|
91
|
+
time_col: str = typer.Option("time", help="Time column name."),
|
|
92
|
+
percent_released_col: str = typer.Option(
|
|
93
|
+
"percent_released", help="Percent released column name."
|
|
94
|
+
),
|
|
95
|
+
report: Optional[Path] = typer.Option(
|
|
96
|
+
None,
|
|
97
|
+
"--report",
|
|
98
|
+
help="Write an HTML or Markdown report to this path (format inferred from extension).",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Compare two formulations in a dissolution CSV using f1 and f2."""
|
|
102
|
+
config = DissolutionCSVConfig(
|
|
103
|
+
formulation_col=formulation_col,
|
|
104
|
+
batch_col=batch_col,
|
|
105
|
+
time_col=time_col,
|
|
106
|
+
percent_released_col=percent_released_col,
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
study = DissolutionStudy.from_csv(csv_path, config)
|
|
110
|
+
result = study.compare(reference, test)
|
|
111
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
112
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
|
|
115
|
+
typer.echo(result.summary())
|
|
116
|
+
|
|
117
|
+
if report is not None:
|
|
118
|
+
fmt = "markdown" if str(report).endswith((".md", ".markdown")) else "html"
|
|
119
|
+
try:
|
|
120
|
+
result.report(report, format=fmt)
|
|
121
|
+
typer.echo(f"\nReport written to: {report}")
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
typer.echo(f"Warning: could not write report: {exc}", err=True)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Example datasets for OpenPKFlow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _dataset_path(name: str) -> str:
|
|
8
|
+
return str(files("openpkflow.datasets").joinpath(name))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def example_dissolution_path() -> str:
|
|
12
|
+
"""Path to the borderline-similar example dataset (f2 approx 57)."""
|
|
13
|
+
return _dataset_path("example_dissolution.csv")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def example_similar_path() -> str:
|
|
17
|
+
"""Path to the clearly-similar example dataset (f2 approx 80)."""
|
|
18
|
+
return _dataset_path("example_similar.csv")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def example_not_similar_path() -> str:
|
|
22
|
+
"""Path to the not-similar example dataset (f2 approx 38)."""
|
|
23
|
+
return _dataset_path("example_not_similar.csv")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["example_dissolution_path", "example_similar_path", "example_not_similar_path"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
formulation,batch,time,percent_released
|
|
2
|
+
reference,R1,5,14.2
|
|
3
|
+
reference,R1,10,29.1
|
|
4
|
+
reference,R1,15,47.3
|
|
5
|
+
reference,R1,20,61.4
|
|
6
|
+
reference,R1,30,77.6
|
|
7
|
+
reference,R1,45,89.5
|
|
8
|
+
reference,R2,5,15.8
|
|
9
|
+
reference,R2,10,31.2
|
|
10
|
+
reference,R2,15,48.9
|
|
11
|
+
reference,R2,20,62.7
|
|
12
|
+
reference,R2,30,78.4
|
|
13
|
+
reference,R2,45,90.6
|
|
14
|
+
reference,R3,5,15.0
|
|
15
|
+
reference,R3,10,29.7
|
|
16
|
+
reference,R3,15,47.8
|
|
17
|
+
reference,R3,20,62.0
|
|
18
|
+
reference,R3,30,78.0
|
|
19
|
+
reference,R3,45,90.0
|
|
20
|
+
test,T1,5,11.5
|
|
21
|
+
test,T1,10,23.4
|
|
22
|
+
test,T1,15,39.2
|
|
23
|
+
test,T1,20,54.3
|
|
24
|
+
test,T1,30,69.7
|
|
25
|
+
test,T1,45,81.8
|
|
26
|
+
test,T2,5,12.8
|
|
27
|
+
test,T2,10,24.9
|
|
28
|
+
test,T2,15,40.8
|
|
29
|
+
test,T2,20,55.6
|
|
30
|
+
test,T2,30,70.5
|
|
31
|
+
test,T2,45,82.4
|
|
32
|
+
test,T3,5,11.9
|
|
33
|
+
test,T3,10,23.8
|
|
34
|
+
test,T3,15,40.0
|
|
35
|
+
test,T3,20,55.0
|
|
36
|
+
test,T3,30,70.0
|
|
37
|
+
test,T3,45,82.0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
formulation,batch,time,percent_released
|
|
2
|
+
reference,R1,5,19.0
|
|
3
|
+
reference,R1,10,37.0
|
|
4
|
+
reference,R1,15,54.0
|
|
5
|
+
reference,R1,20,67.0
|
|
6
|
+
reference,R1,30,81.0
|
|
7
|
+
reference,R1,45,92.0
|
|
8
|
+
reference,R2,5,21.0
|
|
9
|
+
reference,R2,10,39.0
|
|
10
|
+
reference,R2,15,56.0
|
|
11
|
+
reference,R2,20,69.0
|
|
12
|
+
reference,R2,30,83.0
|
|
13
|
+
reference,R2,45,94.0
|
|
14
|
+
reference,R3,5,20.0
|
|
15
|
+
reference,R3,10,38.0
|
|
16
|
+
reference,R3,15,55.0
|
|
17
|
+
reference,R3,20,68.0
|
|
18
|
+
reference,R3,30,82.0
|
|
19
|
+
reference,R3,45,93.0
|
|
20
|
+
test,T1,5,11.0
|
|
21
|
+
test,T1,10,24.0
|
|
22
|
+
test,T1,15,37.0
|
|
23
|
+
test,T1,20,51.0
|
|
24
|
+
test,T1,30,67.0
|
|
25
|
+
test,T1,45,79.0
|
|
26
|
+
test,T2,5,13.0
|
|
27
|
+
test,T2,10,26.0
|
|
28
|
+
test,T2,15,39.0
|
|
29
|
+
test,T2,20,53.0
|
|
30
|
+
test,T2,30,69.0
|
|
31
|
+
test,T2,45,81.0
|
|
32
|
+
test,T3,5,12.0
|
|
33
|
+
test,T3,10,25.0
|
|
34
|
+
test,T3,15,38.0
|
|
35
|
+
test,T3,20,52.0
|
|
36
|
+
test,T3,30,68.0
|
|
37
|
+
test,T3,45,80.0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
formulation,batch,time,percent_released
|
|
2
|
+
reference,R1,5,19.0
|
|
3
|
+
reference,R1,10,37.0
|
|
4
|
+
reference,R1,15,54.0
|
|
5
|
+
reference,R1,20,67.0
|
|
6
|
+
reference,R1,30,81.0
|
|
7
|
+
reference,R1,45,92.0
|
|
8
|
+
reference,R2,5,21.0
|
|
9
|
+
reference,R2,10,39.0
|
|
10
|
+
reference,R2,15,56.0
|
|
11
|
+
reference,R2,20,69.0
|
|
12
|
+
reference,R2,30,83.0
|
|
13
|
+
reference,R2,45,94.0
|
|
14
|
+
reference,R3,5,20.0
|
|
15
|
+
reference,R3,10,38.0
|
|
16
|
+
reference,R3,15,55.0
|
|
17
|
+
reference,R3,20,68.0
|
|
18
|
+
reference,R3,30,82.0
|
|
19
|
+
reference,R3,45,93.0
|
|
20
|
+
test,T1,5,20.0
|
|
21
|
+
test,T1,10,36.0
|
|
22
|
+
test,T1,15,53.0
|
|
23
|
+
test,T1,20,68.0
|
|
24
|
+
test,T1,30,80.0
|
|
25
|
+
test,T1,45,91.0
|
|
26
|
+
test,T2,5,22.0
|
|
27
|
+
test,T2,10,38.0
|
|
28
|
+
test,T2,15,55.0
|
|
29
|
+
test,T2,20,70.0
|
|
30
|
+
test,T2,30,82.0
|
|
31
|
+
test,T2,45,93.0
|
|
32
|
+
test,T3,5,21.0
|
|
33
|
+
test,T3,10,37.0
|
|
34
|
+
test,T3,15,54.0
|
|
35
|
+
test,T3,20,69.0
|
|
36
|
+
test,T3,30,81.0
|
|
37
|
+
test,T3,45,92.0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .bootstrap import BootstrapF2Result, bootstrap_f2
|
|
4
|
+
from .loader import DissolutionCSVConfig, get_formulation_means, load_dissolution_csv
|
|
5
|
+
from .similarity import f1, f2
|
|
6
|
+
from .study import ComparisonResult, DissolutionStudy
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"f1",
|
|
10
|
+
"f2",
|
|
11
|
+
"bootstrap_f2",
|
|
12
|
+
"BootstrapF2Result",
|
|
13
|
+
"DissolutionCSVConfig",
|
|
14
|
+
"load_dissolution_csv",
|
|
15
|
+
"get_formulation_means",
|
|
16
|
+
"DissolutionStudy",
|
|
17
|
+
"ComparisonResult",
|
|
18
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Bootstrap f2 confidence interval for dissolution similarity.
|
|
2
|
+
|
|
3
|
+
References:
|
|
4
|
+
Shah VP et al. (1998) In vitro dissolution profile comparison —
|
|
5
|
+
statistics and analysis of the similarity factor, f2.
|
|
6
|
+
Pharm Res, 15(6):889–896.
|
|
7
|
+
|
|
8
|
+
Davit BM et al. (2013) Comparing dissolution profiles —
|
|
9
|
+
recommendations for regulatory discussions.
|
|
10
|
+
AAPS J, 15(4):1150–1157.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .similarity import f2 as _f2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class BootstrapF2Result:
|
|
24
|
+
"""Result of a bootstrap f2 analysis."""
|
|
25
|
+
|
|
26
|
+
f2_observed: float
|
|
27
|
+
ci_lower: float
|
|
28
|
+
ci_upper: float
|
|
29
|
+
n_replicates: int
|
|
30
|
+
confidence_level: float
|
|
31
|
+
n_timepoints: int
|
|
32
|
+
n_reference_vessels: int
|
|
33
|
+
n_test_vessels: int
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_similar(self) -> bool:
|
|
37
|
+
"""True if lower bound of CI >= 50."""
|
|
38
|
+
return self.ci_lower >= 50.0
|
|
39
|
+
|
|
40
|
+
def summary(self) -> str:
|
|
41
|
+
"""Return a human-readable summary of the bootstrap f2 result.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
str
|
|
46
|
+
Multi-line summary string.
|
|
47
|
+
"""
|
|
48
|
+
pct = int(self.confidence_level * 100)
|
|
49
|
+
verdict = "SIMILAR" if self.is_similar else "NOT SIMILAR"
|
|
50
|
+
lines = [
|
|
51
|
+
f"Bootstrap f2 Analysis",
|
|
52
|
+
f" Observed f2: {self.f2_observed:.2f}",
|
|
53
|
+
f" {pct}% CI: [{self.ci_lower:.2f}, {self.ci_upper:.2f}]",
|
|
54
|
+
(
|
|
55
|
+
f" Verdict: {verdict} (CI lower bound >= 50)"
|
|
56
|
+
if self.is_similar
|
|
57
|
+
else f" Verdict: {verdict} (CI lower bound < 50)"
|
|
58
|
+
),
|
|
59
|
+
f" Replicates: {self.n_replicates}",
|
|
60
|
+
f" Timepoints: {self.n_timepoints}",
|
|
61
|
+
f" Reference vessels: {self.n_reference_vessels}",
|
|
62
|
+
f" Test vessels: {self.n_test_vessels}",
|
|
63
|
+
"",
|
|
64
|
+
"Note: Bootstrap f2 is suitable when <12 vessels are available.",
|
|
65
|
+
"Regulatory acceptance requires expert interpretation.",
|
|
66
|
+
]
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bootstrap_f2(
|
|
71
|
+
reference: np.ndarray,
|
|
72
|
+
test: np.ndarray,
|
|
73
|
+
*,
|
|
74
|
+
n_replicates: int = 5000,
|
|
75
|
+
confidence_level: float = 0.90,
|
|
76
|
+
seed: int | None = None,
|
|
77
|
+
) -> BootstrapF2Result:
|
|
78
|
+
"""Compute bootstrap f2 confidence interval.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
reference : np.ndarray
|
|
83
|
+
2-D array of shape (n_vessels, n_timepoints). Each row is one vessel.
|
|
84
|
+
test : np.ndarray
|
|
85
|
+
2-D array of shape (n_vessels, n_timepoints). Each row is one vessel.
|
|
86
|
+
n_replicates : int
|
|
87
|
+
Number of bootstrap replicates. Default 5000.
|
|
88
|
+
confidence_level : float
|
|
89
|
+
CI level, e.g. 0.90 for 90% CI. Default 0.90.
|
|
90
|
+
seed : int or None
|
|
91
|
+
Random seed for reproducibility.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
BootstrapF2Result
|
|
96
|
+
Observed f2, CI bounds, and metadata.
|
|
97
|
+
|
|
98
|
+
Raises
|
|
99
|
+
------
|
|
100
|
+
ValueError
|
|
101
|
+
If arrays are not 2-D, have mismatched timepoints, fewer than 3 timepoints,
|
|
102
|
+
or fewer than 2 vessels in either group.
|
|
103
|
+
"""
|
|
104
|
+
reference = np.asarray(reference, dtype=float)
|
|
105
|
+
test = np.asarray(test, dtype=float)
|
|
106
|
+
|
|
107
|
+
if reference.ndim != 2:
|
|
108
|
+
raise ValueError("reference must be a 2-D array (n_vessels, n_timepoints)")
|
|
109
|
+
if test.ndim != 2:
|
|
110
|
+
raise ValueError("test must be a 2-D array (n_vessels, n_timepoints)")
|
|
111
|
+
if reference.shape[1] != test.shape[1]:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"reference and test must have the same number of timepoints, "
|
|
114
|
+
f"got {reference.shape[1]} and {test.shape[1]}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
n_ref, n_tp = reference.shape
|
|
118
|
+
n_tst = test.shape[0]
|
|
119
|
+
|
|
120
|
+
if n_tp < 3:
|
|
121
|
+
raise ValueError("At least 3 timepoints are required for f2 calculation.")
|
|
122
|
+
if n_ref < 2:
|
|
123
|
+
raise ValueError("At least 2 reference vessels are required for bootstrap.")
|
|
124
|
+
if n_tst < 2:
|
|
125
|
+
raise ValueError("At least 2 test vessels are required for bootstrap.")
|
|
126
|
+
|
|
127
|
+
if n_ref >= 12 or n_tst >= 12:
|
|
128
|
+
warnings.warn(
|
|
129
|
+
"Bootstrap f2 is designed for small samples (<12 vessels). "
|
|
130
|
+
"With n>=12, the regulatory single-point f2 is preferred.",
|
|
131
|
+
UserWarning,
|
|
132
|
+
stacklevel=2,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Compute observed f2 from column means
|
|
136
|
+
ref_mean = reference.mean(axis=0)
|
|
137
|
+
tst_mean = test.mean(axis=0)
|
|
138
|
+
f2_observed = _f2(ref_mean.tolist(), tst_mean.tolist())
|
|
139
|
+
|
|
140
|
+
rng = np.random.default_rng(seed)
|
|
141
|
+
f2_boots = np.empty(n_replicates)
|
|
142
|
+
|
|
143
|
+
for i in range(n_replicates):
|
|
144
|
+
ref_boot = reference[rng.integers(0, n_ref, size=n_ref)].mean(axis=0)
|
|
145
|
+
tst_boot = test[rng.integers(0, n_tst, size=n_tst)].mean(axis=0)
|
|
146
|
+
f2_boots[i] = _f2(ref_boot.tolist(), tst_boot.tolist())
|
|
147
|
+
|
|
148
|
+
alpha = 1.0 - confidence_level
|
|
149
|
+
ci_lower = float(np.percentile(f2_boots, 100 * alpha / 2))
|
|
150
|
+
ci_upper = float(np.percentile(f2_boots, 100 * (1 - alpha / 2)))
|
|
151
|
+
|
|
152
|
+
return BootstrapF2Result(
|
|
153
|
+
f2_observed=f2_observed,
|
|
154
|
+
ci_lower=ci_lower,
|
|
155
|
+
ci_upper=ci_upper,
|
|
156
|
+
n_replicates=n_replicates,
|
|
157
|
+
confidence_level=confidence_level,
|
|
158
|
+
n_timepoints=n_tp,
|
|
159
|
+
n_reference_vessels=n_ref,
|
|
160
|
+
n_test_vessels=n_tst,
|
|
161
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""CSV loader for dissolution data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DissolutionCSVConfig(BaseModel):
|
|
12
|
+
"""Column name configuration for dissolution CSV files."""
|
|
13
|
+
|
|
14
|
+
formulation_col: str = "formulation"
|
|
15
|
+
batch_col: str = "batch"
|
|
16
|
+
time_col: str = "time"
|
|
17
|
+
percent_released_col: str = "percent_released"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_dissolution_csv(
|
|
21
|
+
path: str | Path,
|
|
22
|
+
config: DissolutionCSVConfig | None = None,
|
|
23
|
+
) -> pd.DataFrame:
|
|
24
|
+
"""Load and validate a dissolution CSV file.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
path : str | Path
|
|
29
|
+
Path to CSV file.
|
|
30
|
+
config : DissolutionCSVConfig | None, optional
|
|
31
|
+
Column name configuration. Uses defaults if None.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
pd.DataFrame
|
|
36
|
+
Validated DataFrame with standardized columns: formulation (str),
|
|
37
|
+
batch (str), time (float), percent_released (float).
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
FileNotFoundError
|
|
42
|
+
If path does not exist.
|
|
43
|
+
ValueError
|
|
44
|
+
If required columns are missing or data fails validation.
|
|
45
|
+
"""
|
|
46
|
+
path = Path(path)
|
|
47
|
+
if not path.exists():
|
|
48
|
+
raise FileNotFoundError(f"Dissolution CSV not found: {path}")
|
|
49
|
+
|
|
50
|
+
cfg = config or DissolutionCSVConfig()
|
|
51
|
+
|
|
52
|
+
df = pd.read_csv(path)
|
|
53
|
+
|
|
54
|
+
required = {
|
|
55
|
+
cfg.formulation_col,
|
|
56
|
+
cfg.batch_col,
|
|
57
|
+
cfg.time_col,
|
|
58
|
+
cfg.percent_released_col,
|
|
59
|
+
}
|
|
60
|
+
missing = required - set(df.columns)
|
|
61
|
+
if missing:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Required columns missing from '{path.name}': {sorted(missing)}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
form_col = cfg.formulation_col
|
|
67
|
+
batch_col = cfg.batch_col
|
|
68
|
+
time_col = cfg.time_col
|
|
69
|
+
pct_col = cfg.percent_released_col
|
|
70
|
+
|
|
71
|
+
for col in [form_col, batch_col, time_col, pct_col]:
|
|
72
|
+
n_null = df[col].isna().sum()
|
|
73
|
+
if n_null > 0:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Column '{col}' contains {n_null} NaN value(s). "
|
|
76
|
+
"All required columns must be complete."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
df[time_col] = pd.to_numeric(df[time_col], errors="coerce")
|
|
80
|
+
if df[time_col].isna().any():
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Column '{time_col}' contains non-numeric values. "
|
|
83
|
+
"Time must be numeric."
|
|
84
|
+
)
|
|
85
|
+
if (df[time_col] < 0).any():
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Column '{time_col}' contains negative values. "
|
|
88
|
+
"Time must be non-negative."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
df[pct_col] = pd.to_numeric(df[pct_col], errors="coerce")
|
|
92
|
+
if df[pct_col].isna().any():
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Column '{pct_col}' contains non-numeric values. "
|
|
95
|
+
"Percent released must be numeric."
|
|
96
|
+
)
|
|
97
|
+
out_of_range = df[(df[pct_col] < 0) | (df[pct_col] > 100)]
|
|
98
|
+
if not out_of_range.empty:
|
|
99
|
+
bad_vals = out_of_range[pct_col].tolist()
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Column '{pct_col}' contains values outside [0, 100]: {bad_vals}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
formulations = df[form_col].unique()
|
|
105
|
+
empty_formulations = [
|
|
106
|
+
str(f) for f in formulations if df[df[form_col] == f].shape[0] == 0
|
|
107
|
+
]
|
|
108
|
+
if empty_formulations:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"The following formulations have no rows: {empty_formulations}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
result = pd.DataFrame(
|
|
114
|
+
{
|
|
115
|
+
"formulation": df[form_col].astype(str),
|
|
116
|
+
"batch": df[batch_col].astype(str),
|
|
117
|
+
"time": df[time_col].astype(float),
|
|
118
|
+
"percent_released": df[pct_col].astype(float),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_formulation_means(
|
|
125
|
+
df: pd.DataFrame,
|
|
126
|
+
formulation: str,
|
|
127
|
+
time_col: str = "time",
|
|
128
|
+
pct_col: str = "percent_released",
|
|
129
|
+
formulation_col: str = "formulation",
|
|
130
|
+
) -> tuple[list[float], list[float]]:
|
|
131
|
+
"""Return mean dissolution profile for a given formulation, averaged across batches.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
df : pd.DataFrame
|
|
136
|
+
Dissolution DataFrame (as returned by load_dissolution_csv).
|
|
137
|
+
formulation : str
|
|
138
|
+
Formulation label to filter on.
|
|
139
|
+
time_col : str, optional
|
|
140
|
+
Name of the time column. Defaults to "time".
|
|
141
|
+
pct_col : str, optional
|
|
142
|
+
Name of the percent released column. Defaults to "percent_released".
|
|
143
|
+
formulation_col : str, optional
|
|
144
|
+
Name of the formulation column. Defaults to "formulation".
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
tuple[list[float], list[float]]
|
|
149
|
+
A tuple of (time_points, mean_percent_released), each sorted by time.
|
|
150
|
+
"""
|
|
151
|
+
subset = df[df[formulation_col] == formulation]
|
|
152
|
+
means = (
|
|
153
|
+
subset.groupby(time_col, sort=True)[pct_col]
|
|
154
|
+
.mean()
|
|
155
|
+
.reset_index()
|
|
156
|
+
)
|
|
157
|
+
time_points = means[time_col].tolist()
|
|
158
|
+
mean_pct = means[pct_col].tolist()
|
|
159
|
+
return time_points, mean_pct
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Dissolution profile plot — reference vs test mean with error bars."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def dissolution_profile_plot_b64(
|
|
11
|
+
time_points: list[float],
|
|
12
|
+
reference_mean: list[float],
|
|
13
|
+
test_mean: list[float],
|
|
14
|
+
reference_label: str = "Reference",
|
|
15
|
+
test_label: str = "Test",
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Return a base64-encoded PNG of the dissolution profile comparison.
|
|
18
|
+
|
|
19
|
+
Embeds cleanly as <img src="data:image/png;base64,..."> in HTML reports.
|
|
20
|
+
Uses matplotlib with a non-interactive backend (Agg) — safe in headless environments.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
time_points :
|
|
25
|
+
Shared time points used in the comparison.
|
|
26
|
+
reference_mean :
|
|
27
|
+
Mean percent dissolved for the reference formulation at each time point.
|
|
28
|
+
test_mean :
|
|
29
|
+
Mean percent dissolved for the test formulation at each time point.
|
|
30
|
+
reference_label :
|
|
31
|
+
Legend label for the reference profile.
|
|
32
|
+
test_label :
|
|
33
|
+
Legend label for the test profile.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
str
|
|
38
|
+
ASCII base64-encoded PNG image string.
|
|
39
|
+
"""
|
|
40
|
+
import matplotlib
|
|
41
|
+
matplotlib.use("Agg")
|
|
42
|
+
import matplotlib.pyplot as plt
|
|
43
|
+
|
|
44
|
+
fig, ax = plt.subplots(figsize=(7, 4), dpi=110)
|
|
45
|
+
|
|
46
|
+
tp = np.array(time_points)
|
|
47
|
+
ref = np.array(reference_mean)
|
|
48
|
+
tst = np.array(test_mean)
|
|
49
|
+
|
|
50
|
+
ax.plot(tp, ref, "o-", color="#003366", linewidth=2, markersize=6,
|
|
51
|
+
label=reference_label)
|
|
52
|
+
ax.plot(tp, tst, "s--", color="#cc3300", linewidth=2, markersize=6,
|
|
53
|
+
label=test_label)
|
|
54
|
+
|
|
55
|
+
ax.set_xlabel("Time (min)", fontsize=11)
|
|
56
|
+
ax.set_ylabel("Mean % Dissolved", fontsize=11)
|
|
57
|
+
ax.set_title("Dissolution Profile Comparison", fontsize=12, fontweight="bold")
|
|
58
|
+
ax.set_ylim(0, 105)
|
|
59
|
+
ax.axhline(85, color="#888888", linestyle=":", linewidth=1, label="85% threshold")
|
|
60
|
+
ax.legend(fontsize=10)
|
|
61
|
+
ax.grid(True, alpha=0.3)
|
|
62
|
+
fig.tight_layout()
|
|
63
|
+
|
|
64
|
+
buf = io.BytesIO()
|
|
65
|
+
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
66
|
+
plt.close(fig)
|
|
67
|
+
buf.seek(0)
|
|
68
|
+
return base64.b64encode(buf.read()).decode("ascii")
|