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 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")