Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
da044a9
feat: add NormalizedRow API and ResidualErrorModel
Siel Dec 30, 2025
0c4c36b
feat: Non-compartmental analysis (NCA) (#189)
Siel Jan 11, 2026
4d3109c
Merge branch 'main' into feat/normalized-row-api
Siel Jan 11, 2026
1e0c4ed
chore: cleanup
Siel Jan 11, 2026
f24d525
chore: fmt
Siel Jan 11, 2026
0229425
feat: more validation scenarios
Siel Jan 11, 2026
932150b
feat: Json
Siel Jan 13, 2026
eb99b43
chore: wire up the tests, and update them to the newer API
Siel Jan 13, 2026
49e5fc6
chore: fmt
Siel Jan 13, 2026
8f4b155
Update src/optimize/effect.rs
Siel Jan 14, 2026
07ef9fe
feat: all datasets are now 0 index, Pmetrics is supported but vectors…
Siel Jan 16, 2026
f267be1
suggestions: Suggestions for renamed items (#196)
mhovd Jan 21, 2026
feedeec
Merge branch 'feat/zero-index' into feat/normalized-row-api
mhovd Jan 21, 2026
7abf761
Don't use deprecated method
mhovd Jan 21, 2026
86b82e6
Merge branch 'feat/normalized-row-api' into feat/json
Siel Jan 29, 2026
f364d76
making sure both 0-index and 1-index data are supported
Siel Feb 5, 2026
26246a8
fix: vector size missmatch
Siel Feb 6, 2026
2415f1c
wip: adding missing functionality and re-defining base structs
Siel Feb 12, 2026
4528d8d
total rewrite of the NCA module
Siel Feb 13, 2026
7dcf028
Merge branch 'main' into feat/new-nca
Siel Feb 13, 2026
7ff63ce
chore: fmt
Siel Feb 13, 2026
e7376b2
Remove JSON from this PR
mhovd Feb 15, 2026
c36e797
Delete json_exa.rs
mhovd Feb 15, 2026
7832f67
Move tests (#210)
mhovd Feb 15, 2026
28df978
Delete model-v1.json
mhovd Feb 24, 2026
776fb6b
chore: making private some structs that are not part of the API, remo…
Siel Feb 24, 2026
cb7c110
Merge branch 'feat/new-nca' of github.com:LAPKB/pharmsol into feat/ne…
Siel Feb 24, 2026
cb084ac
fix: propagate errors from auc functions instead of silently returning 0
Siel Feb 24, 2026
759baa7
Update documentation in auc.rs
Siel Feb 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Other

- _(Exa)_ when installing Papir on MacOs, the environment varaibles are not completly shared to the sandbox in which Papir is running, this changes are meant to provide papir a better way to approach finding the rust binary ([#181](https://github.com/LAPKB/pharmsol/pull/181))
- _(Exa)_ when installing Papir on MacOs, the environment variables are not completely shared to the sandbox in which Papir is running, this changes are meant to provide papir a better way to approach finding the rust binary ([#181](https://github.com/LAPKB/pharmsol/pull/181))
- Update diffsol requirement from =0.7.0 to =0.8.0 ([#176](https://github.com/LAPKB/pharmsol/pull/176))
- Update criterion requirement from 0.7.0 to 0.8.0 ([#177](https://github.com/LAPKB/pharmsol/pull/177))
- Update libloading requirement from 0.8.6 to 0.9.0 ([#162](https://github.com/LAPKB/pharmsol/pull/162))
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ harness = false
[[bench]]
name = "analytical_vs_ode"
harness = false

[[bench]]
name = "nca"
harness = false
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,41 @@ let ode = equation::ODE::new(

Analytical solutions provide 20-33× speedups compared to equivalent ODE formulations. See [benchmarks](benches/) for details.

## Non-Compartmental Analysis (NCA)

pharmsol includes a complete NCA module for calculating standard pharmacokinetic parameters.

```rust
use pharmsol::prelude::*;
use pharmsol::nca::NCAOptions;

let subject = Subject::builder("patient_001")
.bolus(0.0, 100.0, 0) // 100 mg oral dose
.observation(0.5, 5.0, 0)
.observation(1.0, 10.0, 0)
.observation(2.0, 8.0, 0)
.observation(4.0, 4.0, 0)
.observation(8.0, 2.0, 0)
.build();

let result = subject.nca(&NCAOptions::default()).expect("NCA failed");

println!("Cmax: {:.2}", result.exposure.cmax);
println!("Tmax: {:.2} h", result.exposure.tmax);
println!("AUClast: {:.2}", result.exposure.auc_last);

if let Some(ref term) = result.terminal {
println!("Half-life: {:.2} h", term.half_life);
}
```

**Supported NCA Parameters:**

- Exposure: Cmax, Tmax, Clast, Tlast, AUClast, AUCinf, tlag
- Terminal: λz, t½, MRT
- Clearance: CL/F, Vz/F, Vss
- IV-specific: C0 (back-extrapolation), Vd
- Steady-state: AUCtau, Cmin, Cavg, fluctuation, swing

# Links

Expand Down
100 changes: 100 additions & 0 deletions benches/nca.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use pharmsol::nca::{NCAOptions, NCA};
use pharmsol::prelude::*;
use std::hint::black_box;

/// Build a typical PK subject with 12 time points (oral dose)
fn typical_oral_subject(id: &str) -> Subject {
Subject::builder(id)
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(0.25, 2.5, 0)
.observation(0.5, 5.0, 0)
.observation(1.0, 8.0, 0)
.observation(2.0, 10.0, 0)
.observation(4.0, 7.5, 0)
.observation(6.0, 5.0, 0)
.observation(8.0, 3.5, 0)
.observation(12.0, 1.5, 0)
.observation(16.0, 0.8, 0)
.observation(24.0, 0.2, 0)
.observation(36.0, 0.05, 0)
.build()
}

/// Build a population of n subjects with slight variation
fn build_population(n: usize) -> Data {
let subjects: Vec<Subject> = (0..n)
.map(|i| {
let scale = 1.0 + (i as f64 % 7.0) * 0.05; // slight variation
Subject::builder(&format!("subj_{}", i))
.bolus(0.0, 100.0, 0)
.observation(0.0, 0.0, 0)
.observation(0.25, 2.5 * scale, 0)
.observation(0.5, 5.0 * scale, 0)
.observation(1.0, 8.0 * scale, 0)
.observation(2.0, 10.0 * scale, 0)
.observation(4.0, 7.5 * scale, 0)
.observation(6.0, 5.0 * scale, 0)
.observation(8.0, 3.5 * scale, 0)
.observation(12.0, 1.5 * scale, 0)
.observation(16.0, 0.8 * scale, 0)
.observation(24.0, 0.2 * scale, 0)
.observation(36.0, 0.05 * scale, 0)
.build()
})
.collect();
Data::new(subjects)
}

fn bench_single_subject_nca(c: &mut Criterion) {
let subject = typical_oral_subject("bench_subj");
let opts = NCAOptions::default();

c.bench_function("nca_single_subject", |b| {
b.iter(|| {
let result = black_box(&subject).nca(black_box(&opts));
let _ = black_box(result);
});
});
}

fn bench_population_nca(c: &mut Criterion) {
let mut group = c.benchmark_group("nca_population");

for size in [10, 100, 500] {
let data = build_population(size);
let opts = NCAOptions::default();

group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, _| {
b.iter(|| {
let results = black_box(&data).nca_all(black_box(&opts));
black_box(results);
});
});
}

group.finish();
}

fn bench_observation_metrics(c: &mut Criterion) {
use pharmsol::data::event::{AUCMethod, BLQRule};

let subject = typical_oral_subject("bench_subj");

c.bench_function("nca_auc_cmax_metrics", |b| {
b.iter(|| {
let auc = black_box(&subject).auc(0, &AUCMethod::Linear, &BLQRule::Exclude);
let cmax = black_box(&subject).cmax(0, &BLQRule::Exclude);
black_box((auc, cmax));
});
});
}

criterion_group!(
benches,
bench_single_subject_nca,
bench_population_nca,
bench_observation_metrics,
);
criterion_main!(benches);
36 changes: 28 additions & 8 deletions examples/exa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ fn main() {
use std::path::PathBuf;

// Create test subject with infusion and observations
// Including missing observations to verify predictions work without observed values
let subject = Subject::builder("1")
.infusion(0.0, 500.0, 0, 0.5)
.observation(0.5, 1.645776, 0)
.missing_observation(0.75, 0) // Missing observation
.observation(1.0, 1.216442, 0)
.missing_observation(1.5, 0) // Missing observation
.observation(2.0, 0.4622729, 0)
.missing_observation(2.5, 0) // Missing observation
.observation(3.0, 0.1697458, 0)
.observation(4.0, 0.06382178, 0)
.missing_observation(5.0, 0) // Missing observation
.observation(6.0, 0.009099384, 0)
.missing_observation(7.0, 0) // Missing observation
.observation(8.0, 0.001017932, 0)
.build();

Expand Down Expand Up @@ -138,22 +144,32 @@ fn main() {
let dynamic_ode_flat = dynamic_ode_preds.flat_predictions();
let dynamic_analytical_flat = dynamic_analytical_preds.flat_predictions();

let static_times = static_ode_preds.flat_times();
let static_obs = static_ode_preds.flat_observations();

println!(
"\n{:<12} {:>15} {:>15} {:>15}",
"Time", "Static ODE", "Dynamic ODE", "Analytical"
"\n{:<12} {:>12} {:>15} {:>15} {:>15}",
"Time", "Obs", "Static ODE", "Dynamic ODE", "Analytical"
);
println!("{}", "-".repeat(60));
println!("{}", "-".repeat(75));

let times = [0.5, 1.0, 2.0, 3.0, 4.0, 6.0, 8.0];
for (i, &time) in times.iter().enumerate() {
for i in 0..static_times.len() {
let obs_str = match static_obs[i] {
Some(v) => format!("{:.4}", v),
None => "MISSING".to_string(),
};
println!(
"{:<12.1} {:>15.6} {:>15.6} {:>15.6}",
time, static_flat[i], dynamic_ode_flat[i], dynamic_analytical_flat[i]
"{:<12.2} {:>12} {:>15.6} {:>15.6} {:>15.6}",
static_times[i],
obs_str,
static_flat[i],
dynamic_ode_flat[i],
dynamic_analytical_flat[i]
);
}

// Verify predictions match
println!("\n{}", "=".repeat(60));
println!("\n{}", "=".repeat(75));
println!("Verification:");

let ode_match = static_flat
Expand Down Expand Up @@ -182,6 +198,10 @@ fn main() {
}
);

// Count zero predictions for missing observations
let zero_count = static_flat.iter().filter(|&&v| v == 0.0).count();
println!(" Zero predictions count: {} (should be 0)", zero_count);

// =========================================================================
// 5. Clean up compiled model files
// =========================================================================
Expand Down
Loading