diff --git a/Cargo.toml b/Cargo.toml index 2a6218b8..23e89650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,7 @@ harness = false [[bench]] name = "analytical_vs_ode" harness = false + +[[bench]] +name = "nca" +harness = false diff --git a/README.md b/README.md index 98da3c8d..cfb6acd0 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,42 @@ 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 results = subject.nca(&NCAOptions::default(), 0); +let result = results[0].as_ref().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 diff --git a/benches/nca.rs b/benches/nca.rs new file mode 100644 index 00000000..656c2db5 --- /dev/null +++ b/benches/nca.rs @@ -0,0 +1,127 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use pharmsol::nca::{lambda_z_candidates, 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 = (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_lambda_z_candidates(c: &mut Criterion) { + use pharmsol::data::event::{AUCMethod, BLQRule}; + use pharmsol::data::observation::ObservationProfile; + use pharmsol::nca::LambdaZOptions; + + let subject = typical_oral_subject("bench_subj"); + let occ = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + let lz_opts = LambdaZOptions::default(); + + // Get AUClast for the candidate scoring + let auc_results = subject.auc(0, &AUCMethod::Linear, &BLQRule::Exclude); + let auc_last = auc_results[0].as_ref().copied().unwrap_or(50.0); + + c.bench_function("nca_lambda_z_candidates", |b| { + b.iter(|| { + let candidates = lambda_z_candidates( + black_box(&profile), + black_box(&lz_opts), + black_box(auc_last), + ); + black_box(candidates); + }); + }); +} + +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_lambda_z_candidates, + bench_observation_metrics, +); +criterion_main!(benches); diff --git a/examples/exa.rs b/examples/exa.rs index 0ccf2b5b..8a403a8f 100644 --- a/examples/exa.rs +++ b/examples/exa.rs @@ -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(); @@ -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 @@ -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 // ========================================================================= diff --git a/examples/nca.rs b/examples/nca.rs new file mode 100644 index 00000000..b5747434 --- /dev/null +++ b/examples/nca.rs @@ -0,0 +1,313 @@ +//! NCA (Non-Compartmental Analysis) Example +//! +//! This example demonstrates the NCA capabilities of pharmsol. +//! +//! Run with: `cargo run --example nca` + +use pharmsol::nca::{summarize, BLQRule, NCAOptions, NCAPopulation, RouteParams, NCA}; +use pharmsol::prelude::*; +use pharmsol::Censor; + +fn main() { + println!("=== pharmsol NCA Example ===\n"); + + // Example 1: Basic oral PK analysis + basic_oral_example(); + + // Example 2: IV Bolus analysis + iv_bolus_example(); + + // Example 3: IV Infusion analysis + iv_infusion_example(); + + // Example 4: Steady-state analysis + steady_state_example(); + + // Example 5: BLQ handling + blq_handling_example(); + + // Example 6: Population summary + population_summary_example(); +} + +/// Basic oral PK NCA analysis +fn basic_oral_example() { + println!("--- Basic Oral PK Example ---\n"); + + // Build subject with oral dose using the bolus_ev() alias + let subject = Subject::builder("patient_001") + .bolus_ev(0.0, 100.0) // 100 mg oral dose (depot compartment) + .observation(0.0, 0.0, 0) + .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) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build(); + + let options = NCAOptions::default(); + + // .nca() returns the first occasion result directly + let result = subject.nca(&options).expect("NCA analysis failed"); + + println!("Exposure Parameters:"); + println!(" Cmax: {:.2}", result.exposure.cmax); + println!(" Tmax: {:.2} h", result.exposure.tmax); + println!(" Clast: {:.3}", result.exposure.clast); + println!(" Tlast: {:.1} h", result.exposure.tlast); + println!(" AUClast: {:.2}", result.exposure.auc_last); + + if let Some(ref term) = result.terminal { + println!("\nTerminal Phase:"); + println!(" Lambda-z: {:.4} h⁻¹", term.lambda_z); + println!(" Half-life: {:.2} h", term.half_life); + if let Some(mrt) = term.mrt { + println!(" MRT: {:.2} h", mrt); + } + } + + if let Some(ref cl) = result.clearance { + println!("\nClearance Parameters:"); + println!(" CL/F: {:.2} L/h", cl.cl_f); + println!(" Vz/F: {:.2} L", cl.vz_f); + } + + println!("\nQuality: {:?}\n", result.quality.warnings); +} + +/// IV Bolus analysis with C0 back-extrapolation +fn iv_bolus_example() { + println!("--- IV Bolus Example ---\n"); + + // Build subject with IV bolus using bolus_iv() alias + let subject = Subject::builder("iv_patient") + .bolus_iv(0.0, 500.0) // 500 mg IV bolus (central compartment) + .observation(0.25, 95.0, 0) + .observation(0.5, 82.0, 0) + .observation(1.0, 61.0, 0) + .observation(2.0, 34.0, 0) + .observation(4.0, 10.0, 0) + .observation(8.0, 3.0, 0) + .observation(12.0, 0.9, 0) + .build(); + + let options = NCAOptions::default(); + let result = subject.nca(&options).expect("NCA analysis failed"); + + println!("Exposure:"); + println!(" Cmax: {:.1}", result.exposure.cmax); + println!(" AUClast: {:.1}", result.exposure.auc_last); + + if let Some(RouteParams::IVBolus(ref bolus)) = result.route_params { + println!("\nIV Bolus Parameters:"); + println!(" C0 (back-extrap): {:.1}", bolus.c0); + println!(" Vd: {:.1} L", bolus.vd); + if let Some(vss) = result.vss() { + println!(" Vss: {:.1} L", vss); + } + } + + println!(); +} + +/// IV Infusion analysis +fn iv_infusion_example() { + println!("--- IV Infusion Example ---\n"); + + // Build subject with IV infusion using infusion_iv() alias + let subject = Subject::builder("infusion_patient") + .infusion_iv(0.0, 100.0, 0.5) // 100 mg over 0.5h to central + .observation(0.0, 0.0, 0) + .observation(0.5, 15.0, 0) + .observation(1.0, 12.0, 0) + .observation(2.0, 8.0, 0) + .observation(4.0, 4.0, 0) + .observation(8.0, 1.5, 0) + .observation(12.0, 0.5, 0) + .build(); + + let options = NCAOptions::default(); + let result = subject.nca(&options).expect("NCA analysis failed"); + + println!("Exposure:"); + println!(" Cmax: {:.1}", result.exposure.cmax); + println!(" Tmax: {:.2} h", result.exposure.tmax); + println!(" AUClast: {:.1}", result.exposure.auc_last); + + if let Some(RouteParams::IVInfusion(ref infusion)) = result.route_params { + println!("\nIV Infusion Parameters:"); + println!(" Infusion duration: {:.2} h", infusion.infusion_duration); + if let Some(mrt_iv) = infusion.mrt_iv { + println!(" MRT (corrected): {:.2} h", mrt_iv); + } + } + + println!(); +} + +/// Steady-state analysis +fn steady_state_example() { + println!("--- Steady-State Example ---\n"); + + // Build subject at steady-state (Q12H dosing) + let subject = Subject::builder("ss_patient") + .bolus_ev(0.0, 100.0) // 100 mg oral + .observation(0.0, 5.0, 0) + .observation(1.0, 15.0, 0) + .observation(2.0, 12.0, 0) + .observation(4.0, 8.0, 0) + .observation(6.0, 6.0, 0) + .observation(8.0, 5.5, 0) + .observation(12.0, 5.0, 0) + .build(); + + let options = NCAOptions::default().with_tau(12.0); // 12-hour dosing interval + let result = subject.nca(&options).expect("NCA analysis failed"); + + println!("Exposure:"); + println!(" Cmax: {:.1}", result.exposure.cmax); + println!(" AUClast: {:.1}", result.exposure.auc_last); + + if let Some(ref ss) = result.steady_state { + println!("\nSteady-State Parameters (tau = {} h):", ss.tau); + println!(" AUCtau: {:.1}", ss.auc_tau); + println!(" Cmin: {:.1}", ss.cmin); + println!(" Cmax,ss: {:.1}", ss.cmax_ss); + println!(" Cavg: {:.2}", ss.cavg); + println!(" Fluctuation: {:.1}%", ss.fluctuation); + println!(" Swing: {:.2}", ss.swing); + } + + println!(); +} + +/// BLQ handling demonstration +fn blq_handling_example() { + println!("--- BLQ Handling Example ---\n"); + + // Build subject with BLQ observations marked using Censor::BLOQ + let subject = Subject::builder("blq_patient") + .bolus_ev(0.0, 100.0) + .observation(0.0, 0.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) + .observation(12.0, 0.5, 0) + // The last observation is BLQ - mark it with Censor::BLOQ + // The value (0.02) represents the LOQ threshold + .censored_observation(24.0, 0.02, 0, Censor::BLOQ) + .build(); + + // With BLQ exclusion - BLOQ-marked samples are excluded + let options_exclude = NCAOptions::default().with_blq_rule(BLQRule::Exclude); + let result_exclude = subject.nca(&options_exclude).unwrap(); + + // With BLQ = 0 - BLOQ-marked samples are set to zero + let options_zero = NCAOptions::default().with_blq_rule(BLQRule::Zero); + let result_zero = subject.nca(&options_zero).unwrap(); + + // With LOQ/2 - BLOQ-marked samples are set to LOQ/2 (0.02/2 = 0.01) + let options_loq2 = NCAOptions::default().with_blq_rule(BLQRule::LoqOver2); + let result_loq2 = subject.nca(&options_loq2).unwrap(); + + println!("BLQ Handling Comparison (using Censor::BLOQ marking):"); + println!("\n Exclude BLQ:"); + println!(" Tlast: {:.1} h", result_exclude.exposure.tlast); + println!(" AUClast: {:.2}", result_exclude.exposure.auc_last); + + println!("\n BLQ = 0:"); + println!(" Tlast: {:.1} h", result_zero.exposure.tlast); + println!(" AUClast: {:.2}", result_zero.exposure.auc_last); + + println!("\n BLQ = LOQ/2:"); + println!(" Tlast: {:.1} h", result_loq2.exposure.tlast); + println!(" AUClast: {:.2}", result_loq2.exposure.auc_last); + + println!(); + + // Full result display + println!("--- Full Result Display ---\n"); + println!("{}", result_exclude); +} + +/// Population summary statistics +fn population_summary_example() { + println!("--- Population Summary Example ---\n"); + + // Build a small population dataset + let subjects = vec![ + Subject::builder("subj_01") + .bolus_ev(0.0, 100.0) + .observation(0.5, 4.0, 0) + .observation(1.0, 9.0, 0) + .observation(2.0, 7.0, 0) + .observation(4.0, 3.5, 0) + .observation(8.0, 1.5, 0) + .observation(24.0, 0.2, 0) + .build(), + Subject::builder("subj_02") + .bolus_ev(0.0, 100.0) + .observation(0.5, 5.5, 0) + .observation(1.0, 12.0, 0) + .observation(2.0, 9.0, 0) + .observation(4.0, 5.0, 0) + .observation(8.0, 2.0, 0) + .observation(24.0, 0.3, 0) + .build(), + Subject::builder("subj_03") + .bolus_ev(0.0, 100.0) + .observation(0.5, 3.0, 0) + .observation(1.0, 8.0, 0) + .observation(2.0, 6.5, 0) + .observation(4.0, 3.0, 0) + .observation(8.0, 1.0, 0) + .observation(24.0, 0.1, 0) + .build(), + ]; + + let options = NCAOptions::default(); + + // .nca() returns the first occasion directly + let results: Vec<_> = subjects + .iter() + .filter_map(|s| s.nca(&options).ok()) + .collect(); + + // Compute population summary + let summary = summarize(&results); + println!("Population: {} subjects\n", summary.n_subjects); + + for stats in &summary.parameters { + println!( + " {:<12} mean={:>8.2} CV%={:>6.1} [{:.2} - {:.2}]", + stats.name, stats.mean, stats.cv_pct, stats.min, stats.max + ); + } + + // Demonstrate nca_grouped() for population analysis + println!("\n--- Population Grouped Analysis ---\n"); + let data = pharmsol::Data::new(subjects.clone()); + let grouped = data.nca_grouped(&options); + for subj_result in &grouped { + let n_ok = subj_result.successes().len(); + let n_err = subj_result.errors().len(); + println!( + " {}: {} ok, {} errors", + subj_result.subject_id, n_ok, n_err + ); + } + + // Demonstrate to_row() for CSV-like output + println!("\n--- Individual Results (to_row headers) ---\n"); + if let Some(first) = results.first() { + let row = first.to_row(); + let headers: Vec<&str> = row.iter().map(|(k, _)| *k).collect(); + println!(" Columns: {:?}", &headers[..headers.len().min(8)]); + println!(" ...(and {} more)", headers.len().saturating_sub(8)); + } + + println!(); +} diff --git a/schemas/model-v1.json b/schemas/model-v1.json new file mode 100644 index 00000000..cc798bcc --- /dev/null +++ b/schemas/model-v1.json @@ -0,0 +1,792 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pharmsol.rs/schemas/model-v1.json", + "title": "pharmsol Model Definition", + "description": "JSON Schema for pharmacometric model definitions in pharmsol. Supports analytical, ODE, and SDE model types.", + "type": "object", + + "$defs": { + "parameterName": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "Valid parameter name (starts with letter or underscore)" + }, + + "compartmentName": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "Valid compartment name (starts with letter or underscore)" + }, + + "expression": { + "type": "string", + "minLength": 1, + "description": "A Rust expression (e.g., 'x[0] / V', '-ka * x[0]')" + }, + + "analyticalFunction": { + "type": "string", + "enum": [ + "one_compartment", + "one_compartment_with_absorption", + "two_compartments", + "two_compartments_with_absorption", + "three_compartments", + "three_compartments_with_absorption" + ], + "description": "Built-in analytical solution function name" + }, + + "parameterScale": { + "type": "string", + "enum": ["linear", "log", "logit"], + "default": "log", + "description": "Parameter transformation scale for estimation" + }, + + "parameterDefinition": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/parameterName", + "description": "Parameter symbol/name" + }, + "bounds": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2, + "description": "Lower and upper bounds [min, max]" + }, + "scale": { + "$ref": "#/$defs/parameterScale" + }, + "units": { + "type": "string", + "description": "Parameter units (e.g., 'L/h', '1/h', 'L')" + }, + "description": { + "type": "string", + "description": "Human-readable description" + }, + "typical": { + "type": "number", + "description": "Typical/initial value" + } + }, + "required": ["name"], + "additionalProperties": false + }, + + "derivedParameter": { + "type": "object", + "properties": { + "symbol": { + "$ref": "#/$defs/parameterName", + "description": "Symbol for the derived parameter" + }, + "expression": { + "$ref": "#/$defs/expression", + "description": "Expression to compute the derived parameter" + } + }, + "required": ["symbol", "expression"], + "additionalProperties": false + }, + + "parameterization": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Unique identifier for this parameterization" + }, + "name": { + "type": "string", + "description": "Human-readable name" + }, + "default": { + "type": "boolean", + "default": false, + "description": "Whether this is the default parameterization" + }, + "parameters": { + "type": "array", + "items": { "$ref": "#/$defs/parameterDefinition" }, + "minItems": 1, + "description": "Parameter definitions for this parameterization" + }, + "derived": { + "type": "array", + "items": { "$ref": "#/$defs/derivedParameter" }, + "description": "Parameters derived from the primary parameters" + }, + "nonmem": { + "type": "string", + "description": "NONMEM TRANS equivalent (e.g., 'TRANS1', 'TRANS2')" + } + }, + "required": ["id", "parameters"], + "additionalProperties": false + }, + + "covariateType": { + "type": "string", + "enum": ["continuous", "categorical"], + "default": "continuous" + }, + + "interpolationMethod": { + "type": "string", + "enum": ["linear", "constant", "locf"], + "default": "linear", + "description": "How to interpolate covariate values between time points" + }, + + "covariateDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "description": "Covariate identifier (used in code)" + }, + "name": { + "type": "string", + "description": "Human-readable name" + }, + "type": { + "$ref": "#/$defs/covariateType" + }, + "units": { + "type": "string", + "description": "Units for continuous covariates" + }, + "reference": { + "type": "number", + "description": "Reference value for centering (e.g., 70 for weight)" + }, + "interpolation": { + "$ref": "#/$defs/interpolationMethod" + }, + "levels": { + "type": "array", + "items": { "type": "string" }, + "description": "Possible values for categorical covariates" + } + }, + "required": ["id"], + "additionalProperties": false + }, + + "covariateEffectType": { + "type": "string", + "enum": [ + "allometric", + "linear", + "exponential", + "proportional", + "categorical", + "custom" + ], + "description": "Type of covariate effect relationship" + }, + + "covariateEffect": { + "type": "object", + "properties": { + "on": { + "$ref": "#/$defs/parameterName", + "description": "Parameter affected by this covariate" + }, + "covariate": { + "type": "string", + "description": "Covariate ID" + }, + "type": { + "$ref": "#/$defs/covariateEffectType" + }, + "exponent": { + "type": "number", + "description": "Exponent for allometric scaling (e.g., 0.75 for CL)" + }, + "slope": { + "type": "number", + "description": "Slope for linear/exponential effects" + }, + "reference": { + "type": "number", + "description": "Reference value for centering" + }, + "expression": { + "$ref": "#/$defs/expression", + "description": "Custom expression for type='custom'" + }, + "levels": { + "type": "object", + "additionalProperties": { "type": "number" }, + "description": "Multipliers for each categorical level" + } + }, + "required": ["on", "type"], + "allOf": [ + { + "if": { "properties": { "type": { "const": "allometric" } } }, + "then": { "required": ["covariate", "exponent"] } + }, + { + "if": { "properties": { "type": { "const": "linear" } } }, + "then": { "required": ["covariate", "slope"] } + }, + { + "if": { "properties": { "type": { "const": "custom" } } }, + "then": { "required": ["expression"] } + }, + { + "if": { "properties": { "type": { "const": "categorical" } } }, + "then": { "required": ["covariate", "levels"] } + } + ], + "additionalProperties": false + }, + + "errorModelType": { + "type": "string", + "enum": ["additive", "proportional", "combined", "polynomial"], + "description": "Type of residual error model" + }, + + "errorModel": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/errorModelType" + }, + "additive": { + "type": "number", + "minimum": 0, + "description": "Additive error standard deviation" + }, + "proportional": { + "type": "number", + "minimum": 0, + "description": "Proportional error coefficient (CV)" + }, + "cv": { + "type": "number", + "minimum": 0, + "description": "Coefficient of variation (alias for proportional)" + }, + "sd": { + "type": "number", + "minimum": 0, + "description": "Standard deviation (alias for additive)" + }, + "coefficients": { + "type": "array", + "items": { "type": "number" }, + "minItems": 4, + "maxItems": 4, + "description": "Polynomial coefficients [c0, c1, c2, c3]" + }, + "lambda": { + "type": "number", + "default": 0, + "description": "Lambda parameter for polynomial error" + } + }, + "required": ["type"], + "additionalProperties": false + }, + + "outputDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Output identifier" + }, + "equation": { + "$ref": "#/$defs/expression", + "description": "Output equation expression" + }, + "name": { + "type": "string", + "description": "Human-readable name" + }, + "units": { + "type": "string", + "description": "Output units" + } + }, + "required": ["equation"], + "additionalProperties": false + }, + + "diffeqObject": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/expression" + }, + "description": "Map of compartment name to differential equation expression" + }, + + "lagObject": { + "type": "object", + "additionalProperties": { + "oneOf": [{ "$ref": "#/$defs/expression" }, { "type": "number" }] + }, + "description": "Map of compartment index (as string) to lag time expression or value" + }, + + "faObject": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "$ref": "#/$defs/expression" }, + { "type": "number", "minimum": 0, "maximum": 1 } + ] + }, + "description": "Map of compartment index (as string) to bioavailability expression or value" + }, + + "initObject": { + "type": "object", + "additionalProperties": { + "oneOf": [{ "$ref": "#/$defs/expression" }, { "type": "number" }] + }, + "description": "Map of compartment name or index to initial condition" + }, + + "diffusionObject": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "$ref": "#/$defs/expression" }, + { "type": "number", "minimum": 0 } + ] + }, + "description": "Map of state name to diffusion coefficient" + }, + + "position": { + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" } + }, + "required": ["x", "y"], + "additionalProperties": false + }, + + "layoutObject": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/position" + }, + "description": "Map of compartment/element name to position" + }, + + "complexity": { + "type": "string", + "enum": ["basic", "intermediate", "advanced"], + "description": "Model complexity level" + }, + + "category": { + "type": "string", + "enum": ["pk", "pd", "pkpd", "disease", "other"], + "description": "Model category" + }, + + "displayInfo": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable model name" + }, + "shortName": { + "type": "string", + "description": "Abbreviated name" + }, + "category": { + "$ref": "#/$defs/category" + }, + "subcategory": { + "type": "string", + "description": "Model subcategory" + }, + "complexity": { + "$ref": "#/$defs/complexity" + }, + "icon": { + "type": "string", + "description": "Icon identifier" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Searchable tags" + } + }, + "additionalProperties": false + }, + + "reference": { + "type": "object", + "properties": { + "authors": { "type": "string" }, + "title": { "type": "string" }, + "journal": { "type": "string" }, + "year": { "type": "integer" }, + "doi": { "type": "string" }, + "pmid": { "type": "string" } + }, + "additionalProperties": false + }, + + "documentation": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "One-line summary" + }, + "description": { + "type": "string", + "description": "Detailed description" + }, + "equations": { + "type": "object", + "properties": { + "differential": { "type": "string" }, + "solution": { "type": "string" } + }, + "description": "LaTeX equations for display" + }, + "assumptions": { + "type": "array", + "items": { "type": "string" }, + "description": "Model assumptions" + }, + "whenToUse": { + "type": "array", + "items": { "type": "string" }, + "description": "When to use this model" + }, + "whenNotToUse": { + "type": "array", + "items": { "type": "string" }, + "description": "When NOT to use this model" + }, + "references": { + "type": "array", + "items": { "$ref": "#/$defs/reference" }, + "description": "Literature references" + } + }, + "additionalProperties": false + } + }, + + "properties": { + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$", + "description": "Schema version (e.g., '1.0')" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Unique model identifier (snake_case)" + }, + "type": { + "type": "string", + "enum": ["analytical", "ode", "sde"], + "description": "Model equation type" + }, + "extends": { + "type": "string", + "description": "Library model ID to inherit from" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+", + "description": "Model version (semver)" + }, + "aliases": { + "type": "array", + "items": { "type": "string" }, + "description": "Alternative names (e.g., NONMEM ADVAN codes)" + }, + + "parameters": { + "type": "array", + "items": { "$ref": "#/$defs/parameterName" }, + "minItems": 1, + "uniqueItems": true, + "description": "Parameter names in fetch order" + }, + "compartments": { + "type": "array", + "items": { "$ref": "#/$defs/compartmentName" }, + "uniqueItems": true, + "description": "Compartment names (indexed in order)" + }, + "states": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "description": "State variable names (for SDE)" + }, + + "analytical": { + "$ref": "#/$defs/analyticalFunction", + "description": "Built-in analytical solution function" + }, + "diffeq": { + "oneOf": [ + { "$ref": "#/$defs/expression" }, + { "$ref": "#/$defs/diffeqObject" } + ], + "description": "Differential equations (string or object)" + }, + "drift": { + "oneOf": [ + { "$ref": "#/$defs/expression" }, + { "$ref": "#/$defs/diffeqObject" } + ], + "description": "SDE drift term (deterministic part)" + }, + "diffusion": { + "$ref": "#/$defs/diffusionObject", + "description": "SDE diffusion coefficients" + }, + "secondary": { + "$ref": "#/$defs/expression", + "description": "Secondary equations (for analytical)" + }, + + "output": { + "$ref": "#/$defs/expression", + "description": "Single output equation" + }, + "outputs": { + "type": "array", + "items": { "$ref": "#/$defs/outputDefinition" }, + "minItems": 1, + "description": "Multiple output definitions" + }, + + "init": { + "oneOf": [ + { "$ref": "#/$defs/expression" }, + { "$ref": "#/$defs/initObject" } + ], + "description": "Initial conditions" + }, + "lag": { + "$ref": "#/$defs/lagObject", + "description": "Lag times per input compartment" + }, + "fa": { + "$ref": "#/$defs/faObject", + "description": "Bioavailability per input compartment" + }, + "neqs": { + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "minItems": 2, + "maxItems": 2, + "description": "[num_states, num_outputs]" + }, + "particles": { + "type": "integer", + "minimum": 100, + "default": 1000, + "description": "Number of particles for SDE simulation" + }, + + "parameterization": { + "oneOf": [{ "type": "string" }, { "$ref": "#/$defs/parameterization" }], + "description": "Active parameterization (ID or inline definition)" + }, + "parameterizations": { + "type": "array", + "items": { "$ref": "#/$defs/parameterization" }, + "description": "Available parameterization variants" + }, + + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["lag_time", "bioavailability", "initial_conditions"] + }, + "description": "Enabled optional features" + }, + "covariates": { + "type": "array", + "items": { "$ref": "#/$defs/covariateDefinition" }, + "description": "Covariate definitions" + }, + "covariateEffects": { + "type": "array", + "items": { "$ref": "#/$defs/covariateEffect" }, + "description": "Covariate effect specifications" + }, + "errorModel": { + "$ref": "#/$defs/errorModel", + "description": "Residual error model" + }, + "errorModels": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/errorModel" }, + "description": "Error models per output (keyed by output ID)" + }, + + "display": { + "$ref": "#/$defs/displayInfo", + "description": "UI display information" + }, + "layout": { + "$ref": "#/$defs/layoutObject", + "description": "Visual diagram layout" + }, + "documentation": { + "$ref": "#/$defs/documentation", + "description": "Rich documentation" + } + }, + + "required": ["schema", "id", "type"], + + "allOf": [ + { + "if": { + "properties": { "type": { "const": "analytical" } }, + "required": ["type"] + }, + "then": { + "required": ["analytical"], + "properties": { + "diffeq": false, + "drift": false, + "diffusion": false, + "particles": false + } + } + }, + { + "if": { + "properties": { "type": { "const": "ode" } }, + "required": ["type"] + }, + "then": { + "required": ["diffeq"], + "properties": { + "analytical": false, + "drift": false, + "diffusion": false, + "particles": false + } + } + }, + { + "if": { + "properties": { "type": { "const": "sde" } }, + "required": ["type"] + }, + "then": { + "required": ["drift", "diffusion"], + "properties": { + "analytical": false, + "diffeq": false + } + } + }, + { + "if": { + "not": { "required": ["extends"] } + }, + "then": { + "anyOf": [{ "required": ["output"] }, { "required": ["outputs"] }] + } + }, + { + "if": { + "not": { "required": ["extends"] } + }, + "then": { + "required": ["parameters"] + } + } + ], + + "additionalProperties": false, + + "examples": [ + { + "schema": "1.0", + "id": "pk_1cmt_iv", + "type": "analytical", + "analytical": "one_compartment", + "parameters": ["ke", "V"], + "output": "x[0] / V" + }, + { + "schema": "1.0", + "id": "pk_1cmt_oral", + "type": "analytical", + "analytical": "one_compartment_with_absorption", + "parameters": ["ka", "ke", "V"], + "output": "x[1] / V" + }, + { + "schema": "1.0", + "id": "pk_1cmt_oral_lag", + "type": "analytical", + "analytical": "one_compartment_with_absorption", + "parameters": ["ka", "ke", "V", "tlag"], + "lag": { "0": "tlag" }, + "output": "x[1] / V", + "neqs": [2, 1] + }, + { + "schema": "1.0", + "id": "pk_2cmt_ode", + "type": "ode", + "compartments": ["depot", "central", "peripheral"], + "parameters": ["ka", "ke", "k12", "k21", "V"], + "diffeq": { + "depot": "-ka * x[0]", + "central": "ka * x[0] - ke * x[1] - k12 * x[1] + k21 * x[2] + rateiv[1]", + "peripheral": "k12 * x[1] - k21 * x[2]" + }, + "output": "x[1] / V", + "neqs": [3, 1] + }, + { + "schema": "1.0", + "id": "pk_1cmt_sde", + "type": "sde", + "parameters": ["ke0", "sigma_ke", "V"], + "states": ["amount", "ke"], + "drift": { + "amount": "-ke * x[0]", + "ke": "-0.5 * (ke - ke0)" + }, + "diffusion": { + "ke": "sigma_ke" + }, + "init": { + "ke": "ke0" + }, + "output": "x[0] / V", + "neqs": [2, 1], + "particles": 1000 + } + ] +} diff --git a/src/data/auc.rs b/src/data/auc.rs new file mode 100644 index 00000000..62bb79b9 --- /dev/null +++ b/src/data/auc.rs @@ -0,0 +1,625 @@ +//! Pure AUC (Area Under the Curve) calculation primitives +//! +//! This module provides standalone functions for computing AUC, AUMC, and related +//! quantities on raw `&[f64]` slices. These are the building blocks used by +//! [`ObservationProfile`](crate::data::observation::ObservationProfile), NCA analysis, +//! and any downstream code (e.g., PMcore best-dose) that needs trapezoidal integration. +//! +//! # Design +//! +//! All functions in this module are **pure math** — no dependency on data structures, +//! no BLQ filtering, no error types beyond what the caller can check. They accept +//! raw slices and an [`AUCMethod`], and return `f64`. +//! +//! # Example +//! +//! ```rust +//! use pharmsol::data::auc::{auc, auc_interval, aumc, interpolate_linear}; +//! use pharmsol::prelude::AUCMethod; +//! +//! let times = [0.0, 1.0, 2.0, 4.0, 8.0]; +//! let concs = [0.0, 10.0, 8.0, 4.0, 2.0]; +//! +//! let total = auc(×, &concs, &AUCMethod::Linear); +//! let partial = auc_interval(×, &concs, 1.0, 4.0, &AUCMethod::Linear); +//! let moment = aumc(×, &concs, &AUCMethod::Linear); +//! let c_at_3 = interpolate_linear(×, &concs, 3.0); +//! ``` + +use crate::data::event::AUCMethod; + +// ============================================================================ +// Segment-level helpers (private) +// ============================================================================ + +/// Check if log-linear method should be used for this segment +#[inline] +fn use_log_linear(c1: f64, c2: f64) -> bool { + c2 < c1 && c1 > 0.0 && c2 > 0.0 && ((c1 / c2) - 1.0).abs() >= 1e-10 +} + +/// Linear trapezoidal AUC for a single segment +#[inline] +fn auc_linear(c1: f64, c2: f64, dt: f64) -> f64 { + (c1 + c2) / 2.0 * dt +} + +/// Log-linear AUC for a single segment (assumes c1 > c2 > 0) +#[inline] +fn auc_log(c1: f64, c2: f64, dt: f64) -> f64 { + (c1 - c2) * dt / (c1 / c2).ln() +} + +/// Linear trapezoidal AUMC for a single segment +#[inline] +fn aumc_linear(t1: f64, c1: f64, t2: f64, c2: f64, dt: f64) -> f64 { + (t1 * c1 + t2 * c2) / 2.0 * dt +} + +/// Log-linear AUMC for a single segment (PKNCA formula) +#[inline] +fn aumc_log(t1: f64, c1: f64, t2: f64, c2: f64, dt: f64) -> f64 { + let k = (c1 / c2).ln() / dt; + (t1 * c1 - t2 * c2) / k + (c1 - c2) / (k * k) +} + +// ============================================================================ +// Public segment functions +// ============================================================================ + +/// Calculate AUC for a single segment between two time points +/// +/// For [`AUCMethod::LinLog`], this falls back to linear because segment-level +/// calculation cannot know Tmax context. Use [`auc`] or +/// [`auc_segment_with_tmax`] for proper LinLog handling. +#[inline] +pub fn auc_segment(t1: f64, c1: f64, t2: f64, c2: f64, method: &AUCMethod) -> f64 { + let dt = t2 - t1; + if dt <= 0.0 { + return 0.0; + } + + match method { + AUCMethod::Linear | AUCMethod::LinLog => auc_linear(c1, c2, dt), + AUCMethod::LinUpLogDown => { + if use_log_linear(c1, c2) { + auc_log(c1, c2, dt) + } else { + auc_linear(c1, c2, dt) + } + } + } +} + +/// Calculate AUC for a segment with Tmax context (for LinLog method) +/// +/// This is the fully-aware version: for `LinLog`, it uses linear trapezoidal +/// before/at Tmax, and log-linear for descending portions after Tmax. +#[inline] +pub fn auc_segment_with_tmax( + t1: f64, + c1: f64, + t2: f64, + c2: f64, + tmax: f64, + method: &AUCMethod, +) -> f64 { + let dt = t2 - t1; + if dt <= 0.0 { + return 0.0; + } + + match method { + AUCMethod::Linear => auc_linear(c1, c2, dt), + AUCMethod::LinUpLogDown => { + if use_log_linear(c1, c2) { + auc_log(c1, c2, dt) + } else { + auc_linear(c1, c2, dt) + } + } + AUCMethod::LinLog => { + if t2 <= tmax || !use_log_linear(c1, c2) { + auc_linear(c1, c2, dt) + } else { + auc_log(c1, c2, dt) + } + } + } +} + +/// Calculate AUMC for a segment with Tmax context (for LinLog method) +#[inline] +pub fn aumc_segment_with_tmax( + t1: f64, + c1: f64, + t2: f64, + c2: f64, + tmax: f64, + method: &AUCMethod, +) -> f64 { + let dt = t2 - t1; + if dt <= 0.0 { + return 0.0; + } + + match method { + AUCMethod::Linear => aumc_linear(t1, c1, t2, c2, dt), + AUCMethod::LinUpLogDown => { + if use_log_linear(c1, c2) { + aumc_log(t1, c1, t2, c2, dt) + } else { + aumc_linear(t1, c1, t2, c2, dt) + } + } + AUCMethod::LinLog => { + if t2 <= tmax || !use_log_linear(c1, c2) { + aumc_linear(t1, c1, t2, c2, dt) + } else { + aumc_log(t1, c1, t2, c2, dt) + } + } + } +} + +// ============================================================================ +// Full-profile functions (public API) +// ============================================================================ + +/// Calculate AUC (Area Under the Curve) over an entire profile +/// +/// Computes ∫ C(t) dt from the first to the last time point using the +/// specified trapezoidal method. Tmax is auto-detected for `LinLog`. +/// +/// # Arguments +/// * `times` - Sorted time points +/// * `values` - Concentration values (parallel to `times`) +/// * `method` - Trapezoidal rule variant +/// +/// # Panics +/// Panics if `times.len() != values.len()`. +/// +/// # Example +/// ```rust +/// use pharmsol::data::auc::auc; +/// use pharmsol::prelude::AUCMethod; +/// +/// let times = [0.0, 1.0, 2.0, 4.0]; +/// let concs = [0.0, 10.0, 8.0, 4.0]; +/// let result = auc(×, &concs, &AUCMethod::Linear); +/// // (0+10)/2*1 + (10+8)/2*1 + (8+4)/2*2 = 5 + 9 + 12 = 26 +/// assert!((result - 26.0).abs() < 1e-10); +/// ``` +pub fn auc(times: &[f64], values: &[f64], method: &AUCMethod) -> f64 { + assert_eq!( + times.len(), + values.len(), + "times and values must have equal length" + ); + + if times.len() < 2 { + return 0.0; + } + + // Auto-detect tmax for LinLog + let tmax = tmax_from_arrays(times, values); + + let mut total = 0.0; + for i in 1..times.len() { + total += auc_segment_with_tmax( + times[i - 1], + values[i - 1], + times[i], + values[i], + tmax, + method, + ); + } + total +} + +/// Calculate partial AUC over a specific time interval +/// +/// Computes ∫ C(t) dt from `start` to `end`, using linear interpolation +/// at interval boundaries if they don't coincide with data points. +/// +/// # Arguments +/// * `times` - Sorted time points +/// * `values` - Concentration values (parallel to `times`) +/// * `start` - Start time of interval +/// * `end` - End time of interval +/// * `method` - Trapezoidal rule variant +/// +/// # Example +/// ```rust +/// use pharmsol::data::auc::auc_interval; +/// use pharmsol::prelude::AUCMethod; +/// +/// let times = [0.0, 1.0, 2.0, 4.0, 8.0]; +/// let concs = [0.0, 10.0, 8.0, 4.0, 2.0]; +/// let partial = auc_interval(×, &concs, 1.0, 4.0, &AUCMethod::Linear); +/// // (10+8)/2*1 + (8+4)/2*2 = 9 + 12 = 21 +/// assert!((partial - 21.0).abs() < 1e-10); +/// ``` +pub fn auc_interval( + times: &[f64], + values: &[f64], + start: f64, + end: f64, + method: &AUCMethod, +) -> f64 { + assert_eq!( + times.len(), + values.len(), + "times and values must have equal length" + ); + + if end <= start || times.len() < 2 { + return 0.0; + } + + // Auto-detect tmax for LinLog (same as auc()) + let tmax = tmax_from_arrays(times, values); + + let mut total = 0.0; + + for i in 1..times.len() { + let t1 = times[i - 1]; + let t2 = times[i]; + + // Skip segments entirely outside the interval + if t2 <= start || t1 >= end { + continue; + } + + let seg_start = t1.max(start); + let seg_end = t2.min(end); + + let c1 = if t1 < start { + interpolate_linear(times, values, start) + } else { + values[i - 1] + }; + + let c2 = if t2 > end { + interpolate_linear(times, values, end) + } else { + values[i] + }; + + total += auc_segment_with_tmax(seg_start, c1, seg_end, c2, tmax, method); + } + + total +} + +/// Calculate AUMC (Area Under the first Moment Curve) over an entire profile +/// +/// Computes ∫ t·C(t) dt from the first to the last time point. +/// Used for Mean Residence Time calculation: MRT = AUMC / AUC. +/// +/// # Arguments +/// * `times` - Sorted time points +/// * `values` - Concentration values (parallel to `times`) +/// * `method` - Trapezoidal rule variant +pub fn aumc(times: &[f64], values: &[f64], method: &AUCMethod) -> f64 { + assert_eq!( + times.len(), + values.len(), + "times and values must have equal length" + ); + + if times.len() < 2 { + return 0.0; + } + + let tmax = tmax_from_arrays(times, values); + + let mut total = 0.0; + for i in 1..times.len() { + total += aumc_segment_with_tmax( + times[i - 1], + values[i - 1], + times[i], + values[i], + tmax, + method, + ); + } + total +} + +/// Linear interpolation of a value at a given time +/// +/// Returns the linearly interpolated concentration at `time`. +/// Clamps to the first or last value if `time` is outside the data range. +/// +/// # Arguments +/// * `times` - Sorted time points +/// * `values` - Values (parallel to `times`) +/// * `time` - Time at which to interpolate +/// +/// # Example +/// ```rust +/// use pharmsol::data::auc::interpolate_linear; +/// +/// let times = [0.0, 2.0, 4.0]; +/// let values = [0.0, 10.0, 6.0]; +/// assert!((interpolate_linear(×, &values, 1.0) - 5.0).abs() < 1e-10); +/// assert!((interpolate_linear(×, &values, 3.0) - 8.0).abs() < 1e-10); +/// ``` +pub fn interpolate_linear(times: &[f64], values: &[f64], time: f64) -> f64 { + assert_eq!( + times.len(), + values.len(), + "times and values must have equal length" + ); + + if times.is_empty() { + return 0.0; + } + + if time <= times[0] { + return values[0]; + } + + let last = times.len() - 1; + if time >= times[last] { + return values[last]; + } + + let upper_idx = times.iter().position(|&t| t >= time).unwrap_or(last); + let lower_idx = upper_idx.saturating_sub(1); + + let t1 = times[lower_idx]; + let t2 = times[upper_idx]; + let v1 = values[lower_idx]; + let v2 = values[upper_idx]; + + if (t2 - t1).abs() < 1e-10 { + v1 + } else { + v1 + (v2 - v1) * (time - t1) / (t2 - t1) + } +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/// Find tmax (time of maximum value) from parallel arrays +fn tmax_from_arrays(times: &[f64], values: &[f64]) -> f64 { + values + .iter() + .enumerate() + .fold((0, f64::NEG_INFINITY), |(max_i, max_v), (i, &v)| { + if v > max_v { + (i, v) + } else { + (max_i, max_v) + } + }) + .0 + .min(times.len() - 1) + .pipe(|idx| times[idx]) +} + +/// Helper trait for pipe syntax +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> R) -> R { + f(self) + } +} +impl Pipe for T {} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auc_segment_linear() { + let result = auc_segment(0.0, 10.0, 1.0, 8.0, &AUCMethod::Linear); + assert!((result - 9.0).abs() < 1e-10); // (10 + 8) / 2 * 1 + } + + #[test] + fn test_auc_segment_log_down() { + let result = auc_segment(0.0, 10.0, 1.0, 5.0, &AUCMethod::LinUpLogDown); + let expected = 5.0 / (10.0_f64 / 5.0).ln(); + assert!((result - expected).abs() < 1e-10); + } + + #[test] + fn test_auc_segment_ascending_linuplogdown() { + // Ascending — should use linear even with LinUpLogDown + let result = auc_segment(0.0, 5.0, 1.0, 10.0, &AUCMethod::LinUpLogDown); + let expected = (5.0 + 10.0) / 2.0 * 1.0; + assert!((result - expected).abs() < 1e-10); + } + + #[test] + fn test_auc_segment_zero_dt() { + let result = auc_segment(1.0, 10.0, 1.0, 8.0, &AUCMethod::Linear); + assert_eq!(result, 0.0); + } + + #[test] + fn test_auc_full_profile_linear() { + let times = [0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = [0.0, 10.0, 8.0, 4.0, 2.0, 1.0]; + + let result = auc(×, &concs, &AUCMethod::Linear); + // Manual calculation: + // 0-1: (0 + 10) / 2 * 1 = 5 + // 1-2: (10 + 8) / 2 * 1 = 9 + // 2-4: (8 + 4) / 2 * 2 = 12 + // 4-8: (4 + 2) / 2 * 4 = 12 + // 8-12: (2 + 1) / 2 * 4 = 6 + // Total = 44 + assert!((result - 44.0).abs() < 1e-10); + } + + #[test] + fn test_auc_single_point() { + let times = [1.0]; + let concs = [10.0]; + assert_eq!(auc(×, &concs, &AUCMethod::Linear), 0.0); + } + + #[test] + fn test_auc_empty() { + let times: [f64; 0] = []; + let concs: [f64; 0] = []; + assert_eq!(auc(×, &concs, &AUCMethod::Linear), 0.0); + } + + #[test] + fn test_auc_interval_exact_boundaries() { + let times = [0.0, 1.0, 2.0, 4.0, 8.0]; + let concs = [0.0, 10.0, 8.0, 4.0, 2.0]; + + let result = auc_interval(×, &concs, 1.0, 4.0, &AUCMethod::Linear); + // 1-2: (10+8)/2*1 = 9 + // 2-4: (8+4)/2*2 = 12 + // Total = 21 + assert!((result - 21.0).abs() < 1e-10); + } + + #[test] + fn test_auc_interval_interpolated_boundaries() { + let times = [0.0, 2.0, 4.0]; + let concs = [0.0, 10.0, 6.0]; + + // Interval [1, 3] requires interpolation at both boundaries + let result = auc_interval(×, &concs, 1.0, 3.0, &AUCMethod::Linear); + // C(1) = interpolate(0,0, 2,10, t=1) = 5.0 + // C(3) = interpolate(2,10, 4,6, t=3) = 8.0 + // AUC from 1-2: (5+10)/2*1 = 7.5 + // AUC from 2-3: (10+8)/2*1 = 9.0 + // Total = 16.5 + assert!((result - 16.5).abs() < 1e-10); + } + + #[test] + fn test_auc_interval_outside_range() { + let times = [1.0, 2.0, 4.0]; + let concs = [10.0, 8.0, 4.0]; + + // Entirely before data + assert_eq!( + auc_interval(×, &concs, 0.0, 0.5, &AUCMethod::Linear), + 0.0 + ); + // Entirely after data + assert_eq!( + auc_interval(×, &concs, 5.0, 10.0, &AUCMethod::Linear), + 0.0 + ); + } + + #[test] + fn test_auc_interval_reversed() { + let times = [0.0, 1.0, 2.0]; + let concs = [0.0, 10.0, 8.0]; + // end <= start should return 0 + assert_eq!( + auc_interval(×, &concs, 2.0, 1.0, &AUCMethod::Linear), + 0.0 + ); + } + + #[test] + fn test_aumc_linear() { + let times = [0.0, 1.0, 2.0]; + let concs = [0.0, 10.0, 8.0]; + + let result = aumc(×, &concs, &AUCMethod::Linear); + // Segment 0-1: (0*0 + 1*10)/2 * 1 = 5 + // Segment 1-2: (1*10 + 2*8)/2 * 1 = 13 + // Total = 18 + assert!((result - 18.0).abs() < 1e-10); + } + + #[test] + fn test_interpolate_linear_within() { + let times = [0.0, 2.0, 4.0]; + let values = [0.0, 10.0, 6.0]; + + assert!((interpolate_linear(×, &values, 1.0) - 5.0).abs() < 1e-10); + assert!((interpolate_linear(×, &values, 3.0) - 8.0).abs() < 1e-10); + } + + #[test] + fn test_interpolate_linear_at_boundary() { + let times = [0.0, 2.0, 4.0]; + let values = [0.0, 10.0, 6.0]; + + assert!((interpolate_linear(×, &values, 0.0) - 0.0).abs() < 1e-10); + assert!((interpolate_linear(×, &values, 4.0) - 6.0).abs() < 1e-10); + } + + #[test] + fn test_interpolate_linear_clamped() { + let times = [1.0, 3.0]; + let values = [5.0, 15.0]; + + // Before first point — clamp to first value + assert_eq!(interpolate_linear(×, &values, 0.0), 5.0); + // After last point — clamp to last value + assert_eq!(interpolate_linear(×, &values, 5.0), 15.0); + } + + #[test] + fn test_linlog_uses_linear_before_tmax() { + // tmax at t=1, concs: [0, 10, 8, 4] + + // Before tmax: linear + let seg_before = auc_segment_with_tmax(0.0, 0.0, 1.0, 10.0, 1.0, &AUCMethod::LinLog); + let expected_linear = (0.0 + 10.0) / 2.0 * 1.0; + assert!((seg_before - expected_linear).abs() < 1e-10); + + // After tmax with descending: log-linear + let seg_after = auc_segment_with_tmax(1.0, 10.0, 2.0, 8.0, 1.0, &AUCMethod::LinLog); + // Should NOT be simple linear + let linear_val = (10.0 + 8.0) / 2.0 * 1.0; + // LinLog after tmax with descending should differ + // Actually for c1>c2>0, log gives different result + let log_val = (10.0 - 8.0) * 1.0 / (10.0_f64 / 8.0).ln(); + assert!((seg_after - log_val).abs() < 1e-10); + assert!((seg_after - linear_val).abs() > 1e-5); + } + + #[test] + fn test_auc_matches_known_values() { + // Same profile used in nca::calc tests + let times = [0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = [0.0, 10.0, 8.0, 4.0, 2.0, 1.0]; + + let linear = auc(×, &concs, &AUCMethod::Linear); + assert!((linear - 44.0).abs() < 1e-10); + + let linuplogdown = auc(×, &concs, &AUCMethod::LinUpLogDown); + // LinUpLogDown should give a different (smaller) result for the descending part + assert!(linuplogdown < linear); + assert!(linuplogdown > 0.0); + } + + #[test] + fn test_tmax_from_arrays() { + let times = [0.0, 1.0, 2.0, 4.0]; + let concs = [0.0, 10.0, 8.0, 4.0]; + assert_eq!(tmax_from_arrays(×, &concs), 1.0); + } + + #[test] + fn test_tmax_from_arrays_first_occurrence() { + // When max occurs at multiple points, should take first + let times = [0.0, 1.0, 2.0, 3.0]; + let concs = [5.0, 10.0, 10.0, 5.0]; + assert_eq!(tmax_from_arrays(×, &concs), 1.0); + } +} diff --git a/src/data/builder.rs b/src/data/builder.rs index 299d6274..328cf9e0 100644 --- a/src/data/builder.rs +++ b/src/data/builder.rs @@ -73,6 +73,43 @@ impl SubjectBuilder { self.event(event) } + /// Add an extravascular bolus dose (oral, SC, IM, etc.) + /// + /// Convenience alias for `.bolus(time, amount, 0)` — targets the depot compartment. + /// + /// # Arguments + /// + /// * `time` - Time of the bolus dose + /// * `amount` - Amount of drug administered + pub fn bolus_ev(self, time: f64, amount: f64) -> Self { + self.bolus(time, amount, 0) + } + + /// Add an intravenous bolus dose + /// + /// Convenience alias for `.bolus(time, amount, 1)` — targets the central compartment. + /// + /// # Arguments + /// + /// * `time` - Time of the bolus dose + /// * `amount` - Amount of drug administered + pub fn bolus_iv(self, time: f64, amount: f64) -> Self { + self.bolus(time, amount, 1) + } + + /// Add an intravenous infusion + /// + /// Convenience alias for `.infusion(time, amount, 1, duration)` — targets the central compartment. + /// + /// # Arguments + /// + /// * `time` - Start time of the infusion + /// * `amount` - Total amount of drug to be administered + /// * `duration` - Duration of the infusion in time units + pub fn infusion_iv(self, time: f64, amount: f64, duration: f64) -> Self { + self.infusion(time, amount, 1, duration) + } + /// Add an infusion event /// /// # Arguments @@ -113,7 +150,7 @@ impl SubjectBuilder { /// * `time` - Time of the observation /// * `value` - Observed value (e.g., drug concentration) /// * `outeq` - Output equation number (zero-indexed) corresponding to this - /// observation + /// observation pub fn censored_observation( self, time: f64, @@ -229,21 +266,19 @@ impl SubjectBuilder { observation.errorpoly().unwrap(), observation.censoring(), ) + } else if observation.censored() { + self.censored_observation( + observation.time() + delta * i as f64, + observation.value().unwrap(), + observation.outeq(), + observation.censoring(), + ) } else { - if observation.censored() { - self.censored_observation( - observation.time() + delta * i as f64, - observation.value().unwrap(), - observation.outeq(), - observation.censoring(), - ) - } else { - self.observation( - observation.time() + delta * i as f64, - observation.value().unwrap(), - observation.outeq(), - ) - } + self.observation( + observation.time() + delta * i as f64, + observation.value().unwrap(), + observation.outeq(), + ) } } else { self.missing_observation( diff --git a/src/data/event.rs b/src/data/event.rs index 1b7724f6..9980063d 100644 --- a/src/data/event.rs +++ b/src/data/event.rs @@ -3,6 +3,82 @@ use crate::prelude::simulator::Prediction; use serde::{Deserialize, Serialize}; use std::fmt; +// ============================================================================ +// Shared Analysis Types +// ============================================================================ + +/// Administration route for a dosing event +/// +/// Determined by the type of dose events and their target compartment: +/// - [`Event::Infusion`] → [`Route::IVInfusion`] +/// - [`Event::Bolus`] with `input >= 1` (central compartment) → [`Route::IVBolus`] +/// - [`Event::Bolus`] with `input == 0` (depot compartment) → [`Route::Extravascular`] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Route { + /// Intravenous bolus + IVBolus, + /// Intravenous infusion + IVInfusion, + /// Extravascular (oral, SC, IM, etc.) + #[default] + Extravascular, +} + +/// AUC calculation method +/// +/// Controls how the area under the concentration-time curve is computed. +/// This is a general trapezoidal method applicable to any AUC calculation, +/// not specific to NCA analysis. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AUCMethod { + /// Linear trapezoidal rule + Linear, + /// Linear up / log down (industry standard) + #[default] + LinUpLogDown, + /// Linear before Tmax, log-linear after Tmax (PKNCA "lin-log") + /// + /// Uses linear trapezoidal before and at Tmax, then log-linear for + /// descending portions after Tmax. Falls back to linear if either + /// concentration is zero or non-positive. + LinLog, +} + +/// BLQ (Below Limit of Quantification) handling rule +/// +/// Controls how observations marked with [`Censor::BLOQ`] are handled +/// during analysis. Applicable to NCA, AUC calculations, and any +/// observation-processing pipeline. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub enum BLQRule { + /// Replace BLQ with zero + Zero, + /// Replace BLQ with LOQ/2 + LoqOver2, + /// Exclude BLQ values from analysis + #[default] + Exclude, + /// Position-aware handling (PKNCA default): first=keep(0), middle=drop, last=keep(0) + /// + /// This is the FDA-recommended approach that: + /// - Keeps first BLQ (before tfirst) as 0 to anchor the profile start + /// - Drops middle BLQ (between tfirst and tlast) to avoid deflating AUC + /// - Keeps last BLQ (at/after tlast) as 0 to define profile end + Positional, + /// Tmax-relative handling: different rules before vs after Tmax + /// + /// Contains (before_tmax_rule, after_tmax_rule) where each rule can be: + /// - "keep" = keep as 0 + /// - "drop" = exclude from analysis + /// Default PKNCA: before.tmax=drop, after.tmax=keep + TmaxRelative { + /// Rule for BLQ before Tmax: true=keep as 0, false=drop + before_tmax_keep: bool, + /// Rule for BLQ at or after Tmax: true=keep as 0, false=drop + after_tmax_keep: bool, + }, +} + /// Represents a pharmacokinetic/pharmacodynamic event /// /// Events represent key occurrences in a PK/PD profile, including: @@ -256,10 +332,11 @@ impl Infusion { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Censor { /// No censoring + #[default] None, /// Below the lower limit of quantification BLOQ, diff --git a/src/data/mod.rs b/src/data/mod.rs index bd1690bc..76f126d1 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -29,16 +29,23 @@ //! .build(); //! ``` +pub mod auc; pub mod builder; pub mod covariate; pub mod error_model; pub mod event; +pub mod observation; +pub mod observation_error; pub mod parser; pub mod residual_error; pub mod row; pub mod structs; +pub mod traits; pub use covariate::*; pub use error_model::*; pub use event::*; +pub use observation::ObservationProfile; +pub use observation_error::ObservationError; pub use residual_error::*; pub use structs::{Data, Occasion, Subject}; +pub use traits::{MetricsError, ObservationMetrics}; diff --git a/src/data/observation.rs b/src/data/observation.rs new file mode 100644 index 00000000..7bf734c6 --- /dev/null +++ b/src/data/observation.rs @@ -0,0 +1,714 @@ +//! Observation profile: filtered, validated concentration-time data +//! +//! [`ObservationProfile`] is the single source of truth for working with +//! concentration-time profiles. It owns: +//! +//! - **Struct + construction**: BLQ filtering, validation, index caching +//! - **Basic accessors**: Cmax, Tmax, Cmin, Clast, Tlast +//! - **AUC methods**: delegate to [`crate::data::auc`] primitives +//! +//! # Construction +//! +//! ```rust +//! use pharmsol::data::observation::ObservationProfile; +//! use pharmsol::prelude::*; +//! +//! // From raw arrays (no censoring) +//! let profile = ObservationProfile::from_raw( +//! &[0.0, 1.0, 2.0, 4.0, 8.0], +//! &[0.0, 10.0, 8.0, 4.0, 2.0], +//! ).unwrap(); +//! +//! assert_eq!(profile.cmax(), 10.0); +//! assert_eq!(profile.tmax(), 1.0); +//! assert_eq!(profile.cmin(), 0.0); +//! ``` + +use crate::data::auc; +use crate::data::event::{AUCMethod, BLQRule, Censor}; +use crate::Occasion; + +// ============================================================================ +// Types +// ============================================================================ + +/// Action to take for a BLQ observation based on position +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BlqAction { + Keep, + Drop, +} + +/// A filtered, validated view of observations ready for analysis. +/// +/// Contains time-concentration data after BLQ filtering, with cached +/// indices for Cmax, Cmin, and Tlast for efficient access. +/// +/// # Construction +/// +/// - [`ObservationProfile::from_occasion`] — from an [`Occasion`] (applies BLQ rules) +/// - [`ObservationProfile::from_arrays`] — from raw arrays with censoring flags +/// - [`ObservationProfile::from_raw`] — from raw arrays without censoring (simulated data) +#[derive(Debug, Clone)] +pub struct ObservationProfile { + /// Time points (sorted, ascending) + pub times: Vec, + /// Concentration values (parallel to times) + pub concentrations: Vec, + /// Index of Cmax in the arrays + pub cmax_idx: usize, + /// Index of Cmin in the arrays + pub cmin_idx: usize, + /// Index of Clast (last positive concentration) + pub tlast_idx: usize, +} + +// ============================================================================ +// Error type +// ============================================================================ + +use crate::data::observation_error::ObservationError; + +// ============================================================================ +// Accessors +// ============================================================================ + +impl ObservationProfile { + /// Get Cmax value + #[inline] + pub fn cmax(&self) -> f64 { + self.concentrations[self.cmax_idx] + } + + /// Get Tmax value + #[inline] + pub fn tmax(&self) -> f64 { + self.times[self.cmax_idx] + } + + /// Get Cmin value (minimum concentration) + #[inline] + pub fn cmin(&self) -> f64 { + self.concentrations[self.cmin_idx] + } + + /// Get Clast value (last positive concentration) + #[inline] + pub fn clast(&self) -> f64 { + self.concentrations[self.tlast_idx] + } + + /// Get Tlast value (time of last positive concentration) + #[inline] + pub fn tlast(&self) -> f64 { + self.times[self.tlast_idx] + } + + /// Number of data points + #[inline] + pub fn len(&self) -> usize { + self.times.len() + } + + /// Whether the profile has no data points + #[inline] + pub fn is_empty(&self) -> bool { + self.times.is_empty() + } +} + +impl std::fmt::Display for ObservationProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "ObservationProfile ({} points)", self.len())?; + writeln!(f, " Cmax: {:.4} at t={:.2}", self.cmax(), self.tmax())?; + writeln!(f, " Cmin: {:.4}", self.cmin())?; + writeln!(f, " Clast: {:.4} at t={:.2}", self.clast(), self.tlast())?; + writeln!( + f, + " Time range: [{:.2}, {:.2}]", + self.times.first().copied().unwrap_or(0.0), + self.times.last().copied().unwrap_or(0.0) + )?; + Ok(()) + } +} + +// ============================================================================ +// Construction +// ============================================================================ + +impl ObservationProfile { + /// Create a profile from an [`Occasion`] + /// + /// Extracts observations for the given `outeq`, applies BLQ filtering, + /// and validates the result. + /// + /// # Arguments + /// * `occasion` - The occasion containing events + /// * `outeq` - Output equation index to extract + /// * `blq_rule` - How to handle BLQ observations + /// + /// # Errors + /// Returns error if data is insufficient or invalid + pub fn from_occasion( + occasion: &Occasion, + outeq: usize, + blq_rule: &BLQRule, + ) -> Result { + let (times, concs, censoring) = occasion.get_observations(outeq); + Self::from_arrays(×, &concs, &censoring, blq_rule.clone()) + } + + /// Build a profile from raw arrays with BLQ filtering + /// + /// This is the core construction logic. It validates inputs, applies BLQ rules, + /// and produces a finalized profile. + /// + /// # Arguments + /// * `times` - Sorted time points + /// * `concentrations` - Concentration values (parallel to `times`) + /// * `censoring` - Censoring flags (parallel to `times`) + /// * `blq_rule` - How to handle BLQ observations + /// + /// # Errors + /// Returns error if arrays mismatch, data is insufficient, or all values are BLQ + pub fn from_arrays( + times: &[f64], + concentrations: &[f64], + censoring: &[Censor], + blq_rule: BLQRule, + ) -> Result { + if times.len() != concentrations.len() || times.len() != censoring.len() { + return Err(ObservationError::ArrayLengthMismatch { + description: format!( + "times={}, concentrations={}, censoring={}", + times.len(), + concentrations.len(), + censoring.len() + ), + }); + } + + if times.is_empty() { + return Err(ObservationError::InsufficientData { n: 0, required: 2 }); + } + + // Check time sequence is valid + for i in 1..times.len() { + if times[i] < times[i - 1] { + return Err(ObservationError::InvalidTimeSequence); + } + } + + // For Positional rule, we need tfirst and tlast first + // For TmaxRelative, we need tmax + let (tfirst_idx, tlast_idx) = if matches!(blq_rule, BLQRule::Positional) { + find_tfirst_tlast(concentrations, censoring) + } else { + (None, None) + }; + + let tmax_idx = if matches!(blq_rule, BLQRule::TmaxRelative { .. }) { + find_tmax_idx(concentrations, censoring) + } else { + None + }; + + let mut proc_times = Vec::with_capacity(times.len()); + let mut proc_concs = Vec::with_capacity(concentrations.len()); + + for i in 0..times.len() { + let time = times[i]; + let conc = concentrations[i]; + let censor = censoring[i]; + + let is_blq = matches!(censor, Censor::BLOQ); + + if is_blq { + match blq_rule { + BLQRule::Zero => { + proc_times.push(time); + proc_concs.push(0.0); + } + BLQRule::LoqOver2 => { + proc_times.push(time); + proc_concs.push(conc / 2.0); + } + BLQRule::Exclude => { + // Skip + } + BLQRule::Positional => { + let action = get_positional_action(i, tfirst_idx, tlast_idx); + match action { + BlqAction::Keep => { + proc_times.push(time); + proc_concs.push(0.0); + } + BlqAction::Drop => { + // Skip middle BLQ points + } + } + } + BLQRule::TmaxRelative { + before_tmax_keep, + after_tmax_keep, + } => { + let is_before_tmax = tmax_idx.map(|t| i < t).unwrap_or(true); + let keep = if is_before_tmax { + before_tmax_keep + } else { + after_tmax_keep + }; + if keep { + proc_times.push(time); + proc_concs.push(0.0); + } + } + } + } else { + proc_times.push(time); + proc_concs.push(conc); + } + } + + finalize(proc_times, proc_concs) + } + + /// Create a profile from raw time-concentration arrays without censoring + /// + /// Convenience constructor for simulated data or pre-cleaned data where + /// no BLQ handling is needed. All values are treated as uncensored. + /// + /// # Arguments + /// * `times` - Sorted time points + /// * `values` - Concentration values (parallel to `times`) + /// + /// # Errors + /// Returns error if fewer than 2 points or all values ≤ 0 + /// + /// # Example + /// ```rust + /// use pharmsol::data::observation::ObservationProfile; + /// + /// let profile = ObservationProfile::from_raw( + /// &[0.0, 1.0, 2.0, 4.0], + /// &[0.0, 10.0, 8.0, 4.0], + /// ).unwrap(); + /// assert_eq!(profile.cmax(), 10.0); + /// ``` + pub fn from_raw(times: &[f64], values: &[f64]) -> Result { + if times.len() != values.len() { + return Err(ObservationError::ArrayLengthMismatch { + description: format!("times={}, values={}", times.len(), values.len()), + }); + } + + for i in 1..times.len() { + if times[i] < times[i - 1] { + return Err(ObservationError::InvalidTimeSequence); + } + } + + finalize(times.to_vec(), values.to_vec()) + } + + /// Create a profile from [`SubjectPredictions`](crate::simulator::likelihood::SubjectPredictions) + /// + /// Bridges pharmsol's simulation engine to NCA/observation analysis. + /// Extracts predicted concentrations (not observed values) at each time point + /// for the specified output equation, producing a profile that can be used + /// with NCA or any observation-level metrics. + /// + /// # Arguments + /// * `predictions` - Simulation predictions for a single subject + /// * `outeq` - Output equation index to extract + /// + /// # Errors + /// Returns error if fewer than 2 predictions match the requested outeq + /// + /// # Example + /// ```rust,ignore + /// use pharmsol::prelude::*; + /// + /// let predictions = simulate(equation, &subject, ¶ms); + /// let profile = ObservationProfile::from_predictions(&predictions, 0)?; + /// let auc = profile.auc_last(&AUCMethod::Linear); + /// ``` + pub fn from_predictions( + predictions: &crate::simulator::likelihood::SubjectPredictions, + outeq: usize, + ) -> Result { + let mut times = Vec::new(); + let mut values = Vec::new(); + + for pred in predictions.predictions() { + if pred.outeq() == outeq { + times.push(pred.time()); + values.push(pred.prediction()); + } + } + + if times.is_empty() { + return Err(ObservationError::NoObservations { outeq }); + } + + finalize(times, values) + } +} + +// ============================================================================ +// AUC methods +// ============================================================================ + +impl ObservationProfile { + /// Calculate AUC from time 0 to Tlast + /// + /// Delegates to [`crate::data::auc::auc`] over `times[..=tlast_idx]`. + pub fn auc_last(&self, method: &AUCMethod) -> f64 { + let end = self.tlast_idx + 1; + auc::auc(&self.times[..end], &self.concentrations[..end], method) + } + + /// Calculate AUC over a specific time interval + /// + /// Delegates to [`crate::data::auc::auc_interval`]. + pub fn auc_interval(&self, start: f64, end: f64, method: &AUCMethod) -> f64 { + auc::auc_interval(&self.times, &self.concentrations, start, end, method) + } + + /// Calculate AUMC from time 0 to Tlast + /// + /// Delegates to [`crate::data::auc::aumc`] over `times[..=tlast_idx]`. + pub fn aumc_last(&self, method: &AUCMethod) -> f64 { + let end = self.tlast_idx + 1; + auc::aumc(&self.times[..end], &self.concentrations[..end], method) + } + + /// Linear interpolation of concentration at a given time + /// + /// Delegates to [`crate::data::auc::interpolate_linear`]. + pub fn interpolate(&self, time: f64) -> f64 { + auc::interpolate_linear(&self.times, &self.concentrations, time) + } +} + +// ============================================================================ +// Helper functions (private) +// ============================================================================ + +/// Find tfirst and tlast indices for positional BLQ handling +fn find_tfirst_tlast( + concentrations: &[f64], + censoring: &[Censor], +) -> (Option, Option) { + let mut tfirst_idx = None; + let mut tlast_idx = None; + + for i in 0..concentrations.len() { + let is_blq = matches!(censoring[i], Censor::BLOQ); + if !is_blq && concentrations[i] > 0.0 { + if tfirst_idx.is_none() { + tfirst_idx = Some(i); + } + tlast_idx = Some(i); + } + } + + (tfirst_idx, tlast_idx) +} + +/// Find index of Tmax (first maximum concentration) among non-BLQ points +fn find_tmax_idx(concentrations: &[f64], censoring: &[Censor]) -> Option { + let mut max_conc = f64::NEG_INFINITY; + let mut tmax_idx = None; + + for i in 0..concentrations.len() { + let is_blq = matches!(censoring[i], Censor::BLOQ); + if !is_blq && concentrations[i] > max_conc { + max_conc = concentrations[i]; + tmax_idx = Some(i); + } + } + + tmax_idx +} + +/// Determine action for a BLQ observation based on its position +fn get_positional_action( + idx: usize, + tfirst_idx: Option, + tlast_idx: Option, +) -> BlqAction { + match (tfirst_idx, tlast_idx) { + (Some(tfirst), Some(tlast)) => { + if idx <= tfirst { + BlqAction::Keep + } else if idx >= tlast { + BlqAction::Keep + } else { + BlqAction::Drop + } + } + _ => BlqAction::Keep, + } +} + +/// Finalize profile construction by finding Cmax/Cmin/Tlast indices +fn finalize( + proc_times: Vec, + proc_concs: Vec, +) -> Result { + if proc_times.len() < 2 { + return Err(ObservationError::InsufficientData { + n: proc_times.len(), + required: 2, + }); + } + + // Check if all values are zero + if proc_concs.iter().all(|&c| c <= 0.0) { + return Err(ObservationError::AllBelowLOQ); + } + + // Find Cmax index (first occurrence in case of ties, matching PKNCA) + let cmax_idx = proc_concs + .iter() + .enumerate() + .fold((0, f64::NEG_INFINITY), |(max_i, max_c), (i, &c)| { + if c > max_c { + (i, c) + } else { + (max_i, max_c) + } + }) + .0; + + // Find Cmin index (first occurrence of minimum) + let cmin_idx = proc_concs + .iter() + .enumerate() + .fold((0, f64::INFINITY), |(min_i, min_c), (i, &c)| { + if c < min_c { + (i, c) + } else { + (min_i, min_c) + } + }) + .0; + + // Find Tlast index (last positive concentration) + let tlast_idx = proc_concs + .iter() + .rposition(|&c| c > 0.0) + .unwrap_or(proc_concs.len() - 1); + + Ok(ObservationProfile { + times: proc_times, + concentrations: proc_concs, + cmax_idx, + cmin_idx, + tlast_idx, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::builder::SubjectBuilderExt; + use crate::Subject; + + #[test] + fn test_from_occasion() { + let subject = Subject::builder("pt1") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.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 occasion = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occasion, 0, &BLQRule::Exclude).unwrap(); + + assert_eq!(profile.times.len(), 5); + assert_eq!(profile.cmax(), 10.0); + assert_eq!(profile.tmax(), 1.0); + assert_eq!(profile.clast(), 2.0); + assert_eq!(profile.tlast(), 8.0); + } + + #[test] + fn test_from_raw() { + let profile = + ObservationProfile::from_raw(&[0.0, 1.0, 2.0, 4.0, 8.0], &[0.0, 10.0, 8.0, 4.0, 2.0]) + .unwrap(); + + assert_eq!(profile.cmax(), 10.0); + assert_eq!(profile.tmax(), 1.0); + assert_eq!(profile.cmin(), 0.0); + assert_eq!(profile.clast(), 2.0); + assert_eq!(profile.tlast(), 8.0); + } + + #[test] + fn test_from_raw_insufficient() { + let result = ObservationProfile::from_raw(&[0.0], &[10.0]); + assert!(result.is_err()); + } + + #[test] + fn test_from_raw_all_zero() { + let result = ObservationProfile::from_raw(&[0.0, 1.0], &[0.0, 0.0]); + assert!(matches!(result, Err(ObservationError::AllBelowLOQ))); + } + + #[test] + fn test_from_raw_bad_time_sequence() { + let result = ObservationProfile::from_raw(&[2.0, 1.0], &[10.0, 5.0]); + assert!(matches!(result, Err(ObservationError::InvalidTimeSequence))); + } + + #[test] + fn test_cmin() { + let profile = + ObservationProfile::from_raw(&[0.0, 1.0, 2.0, 4.0, 8.0], &[2.0, 10.0, 8.0, 4.0, 1.0]) + .unwrap(); + + assert_eq!(profile.cmin(), 1.0); + } + + #[test] + fn test_blq_handling() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0]; + let concs = vec![0.1, 10.0, 8.0, 4.0, 0.1]; + let censoring = vec![ + Censor::BLOQ, + Censor::None, + Censor::None, + Censor::None, + Censor::BLOQ, + ]; + + let profile = + ObservationProfile::from_arrays(×, &concs, &censoring, BLQRule::Exclude).unwrap(); + assert_eq!(profile.times.len(), 3); + + let profile = + ObservationProfile::from_arrays(×, &concs, &censoring, BLQRule::Zero).unwrap(); + assert_eq!(profile.times.len(), 5); + assert_eq!(profile.concentrations[0], 0.0); + assert_eq!(profile.concentrations[4], 0.0); + + let profile = + ObservationProfile::from_arrays(×, &concs, &censoring, BLQRule::LoqOver2).unwrap(); + assert_eq!(profile.times.len(), 5); + assert_eq!(profile.concentrations[0], 0.05); + assert_eq!(profile.concentrations[4], 0.05); + } + + #[test] + fn test_insufficient_data() { + let times = vec![0.0]; + let concs = vec![10.0]; + let censoring = vec![Censor::None]; + + let result = ObservationProfile::from_arrays(×, &concs, &censoring, BLQRule::Exclude); + assert!(result.is_err()); + } + + #[test] + fn test_all_blq() { + let times = vec![0.0, 1.0, 2.0]; + let concs = vec![0.1, 0.1, 0.1]; + let censoring = vec![Censor::BLOQ, Censor::BLOQ, Censor::BLOQ]; + + let result = ObservationProfile::from_arrays(×, &concs, &censoring, BLQRule::Exclude); + assert!(matches!( + result, + Err(ObservationError::InsufficientData { .. }) + )); + } + + #[test] + fn test_positional_blq() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = vec![0.1, 10.0, 0.1, 4.0, 2.0, 0.1]; + let censoring = vec![ + Censor::BLOQ, + Censor::None, + Censor::BLOQ, + Censor::None, + Censor::None, + Censor::BLOQ, + ]; + + let profile = + ObservationProfile::from_arrays(×, &concs, &censoring, BLQRule::Positional) + .unwrap(); + + assert_eq!(profile.times.len(), 5); + assert_eq!(profile.times[0], 0.0); + assert_eq!(profile.times[1], 1.0); + assert_eq!(profile.times[2], 4.0); + assert_eq!(profile.times[3], 8.0); + assert_eq!(profile.times[4], 12.0); + assert_eq!(profile.concentrations[0], 0.0); + assert_eq!(profile.concentrations[4], 0.0); + } + + #[test] + fn test_auc_last_method() { + let subject = Subject::builder("pt1") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.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) + .observation(12.0, 1.0, 0) + .build(); + + let occasion = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occasion, 0, &BLQRule::Exclude).unwrap(); + + let auc_val = profile.auc_last(&AUCMethod::Linear); + assert!((auc_val - 44.0).abs() < 1e-10); + } + + #[test] + fn test_auc_last_delegates_to_data_auc() { + // Same data, verify ObservationProfile.auc_last matches data::auc::auc directly + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0]; + let concs = vec![0.0, 10.0, 8.0, 4.0, 2.0]; + + let profile = ObservationProfile::from_raw(×, &concs).unwrap(); + let method = AUCMethod::Linear; + + let profile_auc = profile.auc_last(&method); + let direct_auc = auc::auc(×, &concs, &method); + + assert!((profile_auc - direct_auc).abs() < 1e-10); + } + + #[test] + fn test_interpolate_delegates() { + let profile = ObservationProfile::from_raw(&[0.0, 2.0, 4.0], &[0.0, 10.0, 6.0]).unwrap(); + + assert!((profile.interpolate(1.0) - 5.0).abs() < 1e-10); + assert!((profile.interpolate(3.0) - 8.0).abs() < 1e-10); + } + + #[test] + fn test_display() { + let profile = + ObservationProfile::from_raw(&[0.0, 1.0, 2.0, 4.0, 8.0], &[0.0, 10.0, 8.0, 4.0, 2.0]) + .unwrap(); + + let display = format!("{}", profile); + assert!(display.contains("ObservationProfile (5 points)")); + assert!(display.contains("Cmax")); + assert!(display.contains("Cmin")); + assert!(display.contains("Clast")); + } +} diff --git a/src/data/observation_error.rs b/src/data/observation_error.rs new file mode 100644 index 00000000..37cd61a7 --- /dev/null +++ b/src/data/observation_error.rs @@ -0,0 +1,49 @@ +//! Error types for observation data processing +//! +//! [`ObservationError`] covers errors that arise during observation extraction, +//! BLQ filtering, and profile construction. These are data-level errors that +//! don't depend on NCA analysis — they can occur whenever working with +//! concentration-time data. +//! +//! NCA code can propagate these via the [`From`] impl on `NCAError`. + +use thiserror::Error; + +/// Errors arising from observation data processing +/// +/// These represent problems with the input data itself, not with NCA analysis. +/// Used by [`ObservationProfile`](crate::data::observation::ObservationProfile) +/// construction methods. +#[derive(Error, Debug, Clone)] +pub enum ObservationError { + /// Insufficient data points for the requested operation + #[error("Insufficient data: {n} points, need at least {required}")] + InsufficientData { + /// Number of points available + n: usize, + /// Minimum number required + required: usize, + }, + + /// Time values are not monotonically increasing + #[error("Invalid time sequence: times must be monotonically increasing")] + InvalidTimeSequence, + + /// All values are zero or below the limit of quantification + #[error("All values are zero or below quantification limit")] + AllBelowLOQ, + + /// No observations found for the requested output equation + #[error("No observations found for outeq {outeq}")] + NoObservations { + /// The output equation index that had no observations + outeq: usize, + }, + + /// Array length mismatch between parallel input arrays + #[error("Array length mismatch: {description}")] + ArrayLengthMismatch { + /// Description of which arrays mismatched and their lengths + description: String, + }, +} diff --git a/src/data/structs.rs b/src/data/structs.rs index b90c82c1..a5df8550 100644 --- a/src/data/structs.rs +++ b/src/data/structs.rs @@ -285,6 +285,21 @@ impl Data { outeq_values.dedup(); outeq_values } + + /// Total dose per subject + pub fn total_dose(&self) -> Vec { + self.subjects.iter().map(|s| s.total_dose()).collect() + } + + /// Route per subject (detected from first dosed occasion) + pub fn route(&self) -> Vec { + self.subjects.iter().map(|s| s.route()).collect() + } + + /// Dose events per subject + pub fn doses(&self) -> Vec> { + self.subjects.iter().map(|s| s.doses()).collect() + } } impl IntoIterator for Data { @@ -469,6 +484,73 @@ impl Subject { self.id.hash(&mut hasher); hasher.finish() } + + /// Extract time-concentration data for a specific output equation + /// + /// Returns vectors of (times, concentrations, censoring) for the specified outeq + /// across all occasions. + /// + /// # Arguments + /// + /// * `outeq` - Output equation index to extract + /// + /// # Returns + /// + /// Tuple of (times, concentrations, censoring) vectors + pub fn get_observations(&self, outeq: usize) -> (Vec, Vec, Vec) { + let mut times = Vec::new(); + let mut concs = Vec::new(); + let mut censoring = Vec::new(); + + for occasion in &self.occasions { + let (t, c, cens) = occasion.get_observations(outeq); + times.extend(t); + concs.extend(c); + censoring.extend(cens); + } + + (times, concs, censoring) + } + + // ======================================================================== + // Dose Introspection (delegates to occasions) + // ======================================================================== + + /// Total dose administered across all occasions + pub fn total_dose(&self) -> f64 { + self.occasions.iter().map(|o| o.total_dose()).sum() + } + + /// Route detected from the first occasion that has doses + /// + /// In multi-occasion subjects, returns the route from the first + /// occasion containing dose events. + pub fn route(&self) -> Route { + self.occasions + .iter() + .find(|o| o.total_dose() > 0.0) + .map(|o| o.route()) + .unwrap_or_default() + } + + /// All dose events across all occasions as (time, amount, input) tuples + pub fn doses(&self) -> Vec<(f64, f64, usize)> { + self.occasions.iter().flat_map(|o| o.doses()).collect() + } + + /// Whether any occasion contains an infusion event + pub fn has_infusion(&self) -> bool { + self.occasions.iter().any(|o| o.has_infusion()) + } + + /// Duration of the first infusion across all occasions, if any + pub fn infusion_duration(&self) -> Option { + self.occasions.iter().find_map(|o| o.infusion_duration()) + } + + // ======================================================================== + // Filtered Observations + // ======================================================================== } impl IntoIterator for Subject { @@ -793,6 +875,184 @@ impl Occasion { pub fn is_empty(&self) -> bool { self.events.is_empty() } + + // ======================================================================== + // Dose Introspection + // ======================================================================== + + /// Total dose administered in this occasion + /// + /// Sums the amounts of all [`Event::Bolus`] and [`Event::Infusion`] events. + /// Returns 0.0 if there are no dose events. + /// + /// # Example + /// + /// ```rust + /// use pharmsol::*; + /// + /// let subject = Subject::builder("pt1") + /// .bolus(0.0, 50.0, 0) + /// .bolus(0.0, 50.0, 0) + /// .observation(1.0, 10.0, 0) + /// .build(); + /// + /// let occasion = &subject.occasions()[0]; + /// assert_eq!(occasion.total_dose(), 100.0); + /// ``` + pub fn total_dose(&self) -> f64 { + self.events.iter().fold(0.0, |acc, e| match e { + Event::Bolus(b) => acc + b.amount(), + Event::Infusion(inf) => acc + inf.amount(), + _ => acc, + }) + } + + /// Administration route detected from dose events + /// + /// Route is determined by the following rules: + /// - If any infusion is present → [`Route::IVInfusion`] + /// - If all boluses target depot compartment (`input == 0`) → [`Route::Extravascular`] + /// - If any bolus targets central compartment (`input >= 1`) → [`Route::IVBolus`] + /// - If no doses → [`Route::Extravascular`] (default) + /// + /// # Input convention + /// + /// The `input` field on [`Bolus`] and [`Infusion`] events encodes the target compartment: + /// - `input == 0`: Depot compartment (extravascular absorption — oral, SC, IM, etc.) + /// - `input >= 1`: Central compartment (intravenous) + pub fn route(&self) -> Route { + let mut has_infusion = false; + let mut has_extravascular = false; + let mut has_dose = false; + + for event in &self.events { + match event { + Event::Infusion(_) => { + has_infusion = true; + has_dose = true; + } + Event::Bolus(b) => { + has_dose = true; + if b.input() == 0 { + has_extravascular = true; + } + } + _ => {} + } + } + + if !has_dose { + return Route::Extravascular; // default + } + + if has_infusion { + Route::IVInfusion + } else if has_extravascular { + Route::Extravascular + } else { + Route::IVBolus + } + } + + /// Whether this occasion contains any infusion events + pub fn has_infusion(&self) -> bool { + self.events.iter().any(|e| matches!(e, Event::Infusion(_))) + } + + /// All distinct administration routes detected from dose events + /// + /// Used by NCA to detect mixed-route occasions. Returns one entry per + /// unique [`Route`] variant present (IVBolus, IVInfusion, Extravascular). + pub fn routes(&self) -> Vec { + let mut has_infusion = false; + let mut has_extravascular = false; + let mut has_iv_bolus = false; + + for event in &self.events { + match event { + Event::Infusion(_) => has_infusion = true, + Event::Bolus(b) => { + if b.input() == 0 { + has_extravascular = true; + } else { + has_iv_bolus = true; + } + } + _ => {} + } + } + + let mut routes = Vec::new(); + if has_infusion { + routes.push(Route::IVInfusion); + } + if has_iv_bolus { + routes.push(Route::IVBolus); + } + if has_extravascular { + routes.push(Route::Extravascular); + } + routes + } + + /// Duration of the (first) infusion, if any + /// + /// Returns `None` if there are no infusion events. + /// If multiple infusions exist, returns the duration of the first. + pub fn infusion_duration(&self) -> Option { + self.events.iter().find_map(|e| match e { + Event::Infusion(inf) => Some(inf.duration()), + _ => None, + }) + } + + /// All dose events as (time, amount, input) tuples + /// + /// Returns a vector of all bolus and infusion doses with their timing, + /// amount, and target compartment. Useful for multi-dose analysis. + pub fn doses(&self) -> Vec<(f64, f64, usize)> { + self.events + .iter() + .filter_map(|e| match e { + Event::Bolus(b) => Some((b.time(), b.amount(), b.input())), + Event::Infusion(inf) => Some((inf.time(), inf.amount(), inf.input())), + _ => None, + }) + .collect() + } + + // ======================================================================== + // Observation Extraction + // ======================================================================== + + /// Extract time-concentration data for a specific output equation + /// + /// # Arguments + /// + /// * `outeq` - Output equation index to extract + /// + /// # Returns + /// + /// Tuple of (times, concentrations, censoring) vectors + pub fn get_observations(&self, outeq: usize) -> (Vec, Vec, Vec) { + let mut times = Vec::new(); + let mut concs = Vec::new(); + let mut censoring = Vec::new(); + + for event in &self.events { + if let Event::Observation(obs) = event { + if obs.outeq() == outeq { + if let Some(value) = obs.value() { + times.push(obs.time()); + concs.push(value); + censoring.push(obs.censoring()); + } + } + } + } + + (times, concs, censoring) + } } impl IntoIterator for Occasion { diff --git a/src/data/traits.rs b/src/data/traits.rs new file mode 100644 index 00000000..a02eb385 --- /dev/null +++ b/src/data/traits.rs @@ -0,0 +1,531 @@ +//! Extension traits for observation-level pharmacokinetic metrics +//! +//! These traits provide convenient access to AUC, Cmax, Tmax, and other +//! observation-derived metrics on [`Data`], [`Subject`], and [`Occasion`]. +//! These are generic observation-level computations, not NCA-specific — +//! they belong in the data layer because they operate on raw observed data +//! and are useful for any downstream analysis (NCA, BestDose, model diagnostics, etc.). +//! +//! # Example +//! +//! ```rust,ignore +//! use pharmsol::prelude::*; +//! +//! let subject = Subject::builder("pt1") +//! .bolus(0.0, 100.0, 0) +//! .observation(1.0, 10.0, 0) +//! .observation(2.0, 8.0, 0) +//! .observation(4.0, 4.0, 0) +//! .build(); +//! +//! let auc = subject.auc(0, &AUCMethod::Linear, &BLQRule::Exclude); +//! let cmax = subject.cmax(0, &BLQRule::Exclude); +//! let cmax_val = subject.cmax_first(0, &BLQRule::Exclude).unwrap(); +//! ``` + +use crate::data::event::{AUCMethod, BLQRule}; +use crate::data::observation::ObservationProfile; +use crate::data::observation_error::ObservationError; +use crate::{Data, Occasion, Subject}; +use rayon::prelude::*; + +/// Error type for observation metric computations +/// +/// Wraps [`ObservationError`] with optional context about which subject, +/// occasion, or output equation failed. This provides better error messages +/// than bare `ObservationError`. +#[derive(Debug, Clone, thiserror::Error)] +pub enum MetricsError { + /// An error from observation data processing + #[error(transparent)] + Observation(#[from] ObservationError), + + /// Output equation not found in subject data + #[error("Output equation {outeq} not found in subject{}", subject_id.as_ref().map(|id| format!(" '{}'", id)).unwrap_or_default())] + OutputEquationNotFound { + /// The requested output equation index + outeq: usize, + /// Optional subject identifier for context + subject_id: Option, + }, +} + +/// Extension trait for observation-level pharmacokinetic metrics +/// +/// Provides convenient access to AUC, Cmax, Tmax, etc. without running +/// full NCA analysis. Each method returns one result per occasion. +/// +/// For single-occasion convenience, use the `_first()` variants which +/// return a single `Result` instead of `Vec>`. +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::prelude::*; +/// +/// let subject = Subject::builder("pt1") +/// .bolus(0.0, 100.0, 0) +/// .observation(1.0, 10.0, 0) +/// .observation(2.0, 8.0, 0) +/// .observation(4.0, 4.0, 0) +/// .build(); +/// +/// // Per-occasion results +/// let auc = subject.auc(0, &AUCMethod::Linear, &BLQRule::Exclude); +/// +/// // Single-occasion convenience +/// let cmax = subject.cmax_first(0, &BLQRule::Exclude).unwrap(); +/// ``` +pub trait ObservationMetrics { + /// Calculate AUC from time 0 to Tlast + fn auc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec>; + + /// Calculate partial AUC over a time interval + fn auc_interval( + &self, + outeq: usize, + start: f64, + end: f64, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec>; + + /// Get Cmax (maximum concentration) + fn cmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec>; + + /// Get Tmax (time of maximum concentration) + fn tmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec>; + + /// Get Clast (last quantifiable concentration) + fn clast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec>; + + /// Get Tlast (time of last quantifiable concentration) + fn tlast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec>; + + /// Calculate AUMC (Area Under the first Moment Curve) + fn aumc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec>; + + /// Get filtered observation profiles + fn filtered_observations( + &self, + outeq: usize, + blq_rule: &BLQRule, + ) -> Vec>; + + // ======================================================================== + // Convenience methods for the single-occasion common case + // ======================================================================== + + /// Calculate AUC for the first occasion + /// + /// Convenience for the common single-occasion case. Avoids `[0].unwrap()`. + fn auc_first( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Result { + self.auc(outeq, method, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Get Cmax for the first occasion + fn cmax_first(&self, outeq: usize, blq_rule: &BLQRule) -> Result { + self.cmax(outeq, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Get Tmax for the first occasion + fn tmax_first(&self, outeq: usize, blq_rule: &BLQRule) -> Result { + self.tmax(outeq, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Get Clast for the first occasion + fn clast_first(&self, outeq: usize, blq_rule: &BLQRule) -> Result { + self.clast(outeq, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Get Tlast for the first occasion + fn tlast_first(&self, outeq: usize, blq_rule: &BLQRule) -> Result { + self.tlast(outeq, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Calculate AUMC for the first occasion + fn aumc_first( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Result { + self.aumc(outeq, method, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Calculate partial AUC for the first occasion + fn auc_interval_first( + &self, + outeq: usize, + start: f64, + end: f64, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Result { + self.auc_interval(outeq, start, end, method, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(MetricsError::Observation( + ObservationError::InsufficientData { n: 0, required: 2 }, + ))) + } + + /// Get filtered observation profile for the first occasion + fn filtered_observations_first( + &self, + outeq: usize, + blq_rule: &BLQRule, + ) -> Result { + self.filtered_observations(outeq, blq_rule) + .into_iter() + .next() + .unwrap_or(Err(ObservationError::InsufficientData { + n: 0, + required: 2, + })) + } +} + +// ============================================================================ +// Occasion implementations (core logic) +// ============================================================================ + +impl ObservationMetrics for Occasion { + fn auc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + vec![auc_occasion(self, outeq, method, blq_rule)] + } + + fn auc_interval( + &self, + outeq: usize, + start: f64, + end: f64, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + vec![auc_interval_occasion( + self, outeq, start, end, method, blq_rule, + )] + } + + fn cmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + vec![cmax_occasion(self, outeq, blq_rule)] + } + + fn tmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + vec![tmax_occasion(self, outeq, blq_rule)] + } + + fn clast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + vec![clast_occasion(self, outeq, blq_rule)] + } + + fn tlast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + vec![tlast_occasion(self, outeq, blq_rule)] + } + + fn aumc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + vec![aumc_occasion(self, outeq, method, blq_rule)] + } + + fn filtered_observations( + &self, + outeq: usize, + blq_rule: &BLQRule, + ) -> Vec> { + vec![ObservationProfile::from_occasion(self, outeq, blq_rule)] + } +} + +// ============================================================================ +// Subject implementations (iterate occasions) +// ============================================================================ + +impl ObservationMetrics for Subject { + fn auc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + self.occasions() + .par_iter() + .map(|o| auc_occasion(o, outeq, method, blq_rule)) + .collect() + } + + fn auc_interval( + &self, + outeq: usize, + start: f64, + end: f64, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + self.occasions() + .par_iter() + .map(|o| auc_interval_occasion(o, outeq, start, end, method, blq_rule)) + .collect() + } + + fn cmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.occasions() + .par_iter() + .map(|o| cmax_occasion(o, outeq, blq_rule)) + .collect() + } + + fn tmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.occasions() + .par_iter() + .map(|o| tmax_occasion(o, outeq, blq_rule)) + .collect() + } + + fn clast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.occasions() + .par_iter() + .map(|o| clast_occasion(o, outeq, blq_rule)) + .collect() + } + + fn tlast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.occasions() + .par_iter() + .map(|o| tlast_occasion(o, outeq, blq_rule)) + .collect() + } + + fn aumc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + self.occasions() + .par_iter() + .map(|o| aumc_occasion(o, outeq, method, blq_rule)) + .collect() + } + + fn filtered_observations( + &self, + outeq: usize, + blq_rule: &BLQRule, + ) -> Vec> { + self.occasions() + .par_iter() + .map(|o| ObservationProfile::from_occasion(o, outeq, blq_rule)) + .collect() + } +} + +// ============================================================================ +// Data implementations (iterate subjects, flatten) +// ============================================================================ + +impl ObservationMetrics for Data { + fn auc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.auc(outeq, method, blq_rule)) + .collect() + } + + fn auc_interval( + &self, + outeq: usize, + start: f64, + end: f64, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.auc_interval(outeq, start, end, method, blq_rule)) + .collect() + } + + fn cmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.cmax(outeq, blq_rule)) + .collect() + } + + fn tmax(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.tmax(outeq, blq_rule)) + .collect() + } + + fn clast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.clast(outeq, blq_rule)) + .collect() + } + + fn tlast(&self, outeq: usize, blq_rule: &BLQRule) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.tlast(outeq, blq_rule)) + .collect() + } + + fn aumc( + &self, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, + ) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.aumc(outeq, method, blq_rule)) + .collect() + } + + fn filtered_observations( + &self, + outeq: usize, + blq_rule: &BLQRule, + ) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|s| s.filtered_observations(outeq, blq_rule)) + .collect() + } +} + +// ============================================================================ +// Private helper functions for Occasion-level implementations +// ============================================================================ + +fn auc_occasion( + occasion: &Occasion, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.auc_last(method)) +} + +fn auc_interval_occasion( + occasion: &Occasion, + outeq: usize, + start: f64, + end: f64, + method: &AUCMethod, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.auc_interval(start, end, method)) +} + +fn cmax_occasion( + occasion: &Occasion, + outeq: usize, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.cmax()) +} + +fn tmax_occasion( + occasion: &Occasion, + outeq: usize, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.tmax()) +} + +fn clast_occasion( + occasion: &Occasion, + outeq: usize, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.clast()) +} + +fn tlast_occasion( + occasion: &Occasion, + outeq: usize, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.tlast()) +} + +fn aumc_occasion( + occasion: &Occasion, + outeq: usize, + method: &AUCMethod, + blq_rule: &BLQRule, +) -> Result { + let profile = ObservationProfile::from_occasion(occasion, outeq, blq_rule)?; + Ok(profile.aumc_last(method)) +} diff --git a/src/lib.rs b/src/lib.rs index aaa693f1..d39083ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod data; pub mod error; #[cfg(feature = "exa")] pub mod exa; +pub mod nca; pub mod optimize; pub mod simulator; @@ -51,9 +52,19 @@ pub mod prelude { pub use crate::data::{ builder::SubjectBuilderExt, error_model::{AssayErrorModel, AssayErrorModels, ErrorPoly}, + event::{AUCMethod, BLQRule, Route}, + observation::ObservationProfile, Covariates, Data, Event, Interpolation, Occasion, Subject, }; + // NCA extension traits (provides .nca(), .nca_all(), etc. on data types) + pub use crate::data::traits::{MetricsError, ObservationMetrics}; + pub use crate::nca::NCA; + pub use crate::nca::{NCAOptions, NCAPopulation, SubjectNCAResult}; + + // AUC primitives for direct use on raw arrays + pub use crate::data::auc::{auc, auc_interval, aumc, interpolate_linear}; + // Simulator submodule for internal use and advanced users pub mod simulator { pub use crate::simulator::{ diff --git a/src/nca/analyze.rs b/src/nca/analyze.rs new file mode 100644 index 00000000..324549d1 --- /dev/null +++ b/src/nca/analyze.rs @@ -0,0 +1,671 @@ +//! Main NCA analysis orchestrator +//! +//! This module contains the core analysis function that computes all NCA parameters +//! from a validated profile and options. +//! +//! # Planned features +//! +//! - **Ka estimation**: Absorption rate constant estimation for extravascular routes +//! is not yet implemented. + +use super::calc; +use super::error::NCAError; +use super::types::*; +use crate::data::event::{AUCMethod, Route}; +use crate::data::observation::ObservationProfile as Profile; +use crate::data::observation_error::ObservationError; + +// ============================================================================ +// Precomputed values (computed once, threaded through) +// ============================================================================ + +/// Values computed once at the start of analysis to avoid redundant calculation +struct Precomputed { + auc_last: f64, + aumc_last: f64, + cmax: f64, + tmax: f64, + clast: f64, + tlast: f64, +} + +impl Precomputed { + fn from_profile(profile: &Profile, method: AUCMethod) -> Self { + Self { + auc_last: profile.auc_last(&method), + aumc_last: profile.aumc_last(&method), + cmax: profile.cmax(), + tmax: profile.tmax(), + clast: profile.clast(), + tlast: profile.tlast(), + } + } + + fn auc_inf(&self, clast: f64, lambda_z: f64) -> f64 { + calc::auc_inf(self.auc_last, clast, lambda_z) + } + + fn aumc_inf(&self, clast: f64, lambda_z: f64) -> f64 { + calc::aumc_inf(self.aumc_last, clast, self.tlast, lambda_z) + } +} + +// ============================================================================ +// Main Analysis Function +// ============================================================================ + +/// Perform complete NCA analysis on a profile +/// +/// This is the primary entry point for NCA analysis. +/// +/// # Arguments +/// * `profile` - Validated concentration-time profile +/// * `dose_amount` - Total dose amount (None if no dosing data) +/// * `route` - Administration route +/// * `infusion_duration` - Infusion duration (None for bolus/extravascular) +/// * `options` - Analysis configuration +/// * `raw_tlag` - Tlag computed from raw (unfiltered) data, or None +/// * `subject_id` - Subject identifier (None for ad-hoc profiles) +/// * `occasion` - Occasion index (None for ad-hoc profiles) +pub(crate) fn analyze( + profile: &Profile, + dose_amount: Option, + route: Route, + infusion_duration: Option, + options: &NCAOptions, + raw_tlag: Option, + subject_id: Option<&str>, + occasion: Option, +) -> Result { + if profile.times.is_empty() { + return Err(ObservationError::InsufficientData { n: 0, required: 2 }.into()); + } + + // Compute AUC/AUMC once, use everywhere + let pre = Precomputed::from_profile(profile, options.auc_method); + + // Core exposure parameters (always calculated) + let mut exposure = compute_exposure(&pre, profile, options, raw_tlag)?; + + // Terminal phase parameters (if lambda-z can be estimated) + let (terminal, lambda_z_result) = compute_terminal(&pre, profile, options); + + // Update exposure with both AUCinf variants if we have terminal phase + if let Some(ref lz) = lambda_z_result { + // AUCinf using observed Clast + let auc_inf_obs = pre.auc_inf(pre.clast, lz.lambda_z); + exposure.auc_inf_obs = Some(auc_inf_obs); + exposure.auc_pct_extrap_obs = Some(calc::auc_extrap_pct(pre.auc_last, auc_inf_obs)); + + // AUCinf using predicted Clast (from λz regression) + let auc_inf_pred = pre.auc_inf(lz.clast_pred, lz.lambda_z); + exposure.auc_inf_pred = Some(auc_inf_pred); + exposure.auc_pct_extrap_pred = Some(calc::auc_extrap_pct(pre.auc_last, auc_inf_pred)); + + if exposure.aumc_last.is_some() { + // AUMC∞ uses observed Clast by convention + exposure.aumc_inf = Some(pre.aumc_inf(pre.clast, lz.lambda_z)); + } + } + + // Clearance parameters (if we have dose and terminal phase) + // Uses auc_inf_obs by convention (standard practice) + let clearance = dose_amount + .and_then(|d| lambda_z_result.as_ref().map(|lz| (d, lz))) + .map(|(d, lz)| compute_clearance(d, exposure.auc_inf_obs, lz.lambda_z, route, &pre)); + + // Route-specific parameters (uses observed Clast for extrapolation) + let route_params = compute_route_specific( + &pre, + profile, + dose_amount, + route, + infusion_duration, + lambda_z_result.as_ref(), + pre.clast, + options, + ); + + // Steady-state parameters (if tau specified) + let steady_state = options + .tau + .map(|tau| compute_steady_state(&pre, profile, tau, options)); + + // Dose-normalized parameters + if let Some(d) = dose_amount { + if d > 0.0 { + exposure.cmax_dn = Some(exposure.cmax / d); + exposure.auc_last_dn = Some(exposure.auc_last / d); + if let Some(auc_inf_obs) = exposure.auc_inf_obs { + exposure.auc_inf_dn = Some(auc_inf_obs / d); + } + } + } + + // Multi-dose interval parameters (if dose_times specified) + let multi_dose = options.dose_times.as_ref().and_then(|times| { + if times.is_empty() { + return None; + } + let mut sorted_times = times.clone(); + sorted_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let last_obs_time = *profile.times.last()?; + let n = sorted_times.len(); + + let mut auc_intervals = Vec::with_capacity(n); + let mut cmax_intervals = Vec::with_capacity(n); + let mut tmax_intervals = Vec::with_capacity(n); + + for i in 0..n { + let start = sorted_times[i]; + let end = if i + 1 < n { + sorted_times[i + 1] + } else { + last_obs_time + }; + + // AUC over interval + auc_intervals.push(profile.auc_interval(start, end, &options.auc_method)); + + // Cmax/Tmax within [start, end] + let (cmax, tmax) = cmax_tmax_in_window(profile, start, end); + cmax_intervals.push(cmax); + tmax_intervals.push(tmax); + } + + Some(MultiDoseParams { + dose_times: sorted_times, + auc_intervals, + cmax_intervals, + tmax_intervals, + }) + }); + + // Build quality summary + let quality = build_quality( + &exposure, + terminal.as_ref(), + lambda_z_result.as_ref(), + options, + ); + + Ok(NCAResult { + subject_id: subject_id.map(|s| s.to_string()), + occasion, + dose_amount, + route: Some(route), + infusion_duration, + exposure, + terminal, + clearance, + route_params, + steady_state, + multi_dose, + quality, + }) +} + +/// Compute core exposure parameters +fn compute_exposure( + pre: &Precomputed, + profile: &Profile, + options: &NCAOptions, + raw_tlag: Option, +) -> Result { + // Calculate partial AUC if interval specified + let auc_partial = options + .auc_interval + .map(|(start, end)| profile.auc_interval(start, end, &options.auc_method)); + + // Find first measurable (positive) concentration time + let tfirst = profile + .times + .iter() + .zip(profile.concentrations.iter()) + .find(|(_, &c)| c > 0.0) + .map(|(&t, _)| t); + + // Time above concentration threshold (if specified) + let time_above_mic = options.concentration_threshold.map(|threshold| { + calc::time_above_concentration(&profile.times, &profile.concentrations, threshold) + }); + + Ok(ExposureParams { + cmax: pre.cmax, + tmax: pre.tmax, + clast: pre.clast, + tlast: pre.tlast, + tfirst, + auc_last: pre.auc_last, + auc_inf_obs: None, // filled in by caller if terminal phase estimated + auc_inf_pred: None, + auc_pct_extrap_obs: None, + auc_pct_extrap_pred: None, + auc_partial, + aumc_last: Some(pre.aumc_last), + aumc_inf: None, + tlag: raw_tlag, + cmax_dn: None, // filled in by caller if dose available + auc_last_dn: None, + auc_inf_dn: None, + time_above_mic, + }) +} + +/// Compute terminal phase parameters +fn compute_terminal( + pre: &Precomputed, + profile: &Profile, + options: &NCAOptions, +) -> (Option, Option) { + let lz_result = calc::lambda_z(profile, &options.lambda_z); + + let terminal = lz_result.as_ref().map(|lz| { + let half_life = calc::half_life(lz.lambda_z); + + // MRT uses observed Clast by convention + let auc_inf = pre.auc_inf(pre.clast, lz.lambda_z); + let aumc_inf = pre.aumc_inf(pre.clast, lz.lambda_z); + let mrt = calc::mrt(aumc_inf, auc_inf); + + // Derived terminal parameters + let effective_half_life = if mrt.is_finite() && mrt > 0.0 { + Some(calc::effective_half_life(mrt)) + } else { + None + }; + let kel = if mrt.is_finite() && mrt > 0.0 { + Some(calc::kel(mrt)) + } else { + None + }; + + TerminalParams { + lambda_z: lz.lambda_z, + half_life, + mrt: Some(mrt), + effective_half_life, + kel, + regression: Some(lz.clone().into()), + } + }); + + (terminal, lz_result) +} + +/// Compute clearance parameters +fn compute_clearance( + dose: f64, + auc_inf: Option, + lambda_z: f64, + route: Route, + pre: &Precomputed, +) -> ClearanceParams { + let auc = auc_inf.unwrap_or(f64::NAN); + let cl = calc::clearance(dose, auc); + let vz = calc::vz(dose, lambda_z, auc); + + // Vss is computed for IV routes: Vss = Dose * AUMC_inf / AUC_inf^2 + let vss = match route { + Route::IVBolus | Route::IVInfusion => { + let auc_inf_val = pre.auc_inf(pre.clast, lambda_z); + let aumc_inf_val = pre.aumc_inf(pre.clast, lambda_z); + Some(calc::vss(dose, aumc_inf_val, auc_inf_val)) + } + Route::Extravascular => None, + }; + + ClearanceParams { + cl_f: cl, + vz_f: vz, + vss, + } +} + +/// Compute route-specific parameters (IV only — extravascular tlag is in exposure) +fn compute_route_specific( + pre: &Precomputed, + profile: &Profile, + dose_amount: Option, + route: Route, + infusion_duration: Option, + lz_result: Option<&calc::LambdaZResult>, + eff_clast: f64, + options: &NCAOptions, +) -> Option { + match route { + Route::IVBolus => { + let lambda_z = lz_result.map(|lz| lz.lambda_z).unwrap_or(f64::NAN); + let (c0, c0_method) = calc::c0(profile, &options.c0_methods, lambda_z); + + let vd = dose_amount + .map(|d| calc::vd_bolus(d, c0)) + .unwrap_or(f64::NAN); + + Some(RouteParams::IVBolus(IVBolusParams { c0, vd, c0_method })) + } + Route::IVInfusion => { + let duration = infusion_duration.unwrap_or(0.0); + + // MRT adjusted for infusion + let mrt_iv = lz_result.map(|lz| { + let auc_inf = pre.auc_inf(eff_clast, lz.lambda_z); + let aumc_inf = pre.aumc_inf(eff_clast, lz.lambda_z); + let mrt_uncorrected = calc::mrt(aumc_inf, auc_inf); + calc::mrt_infusion(mrt_uncorrected, duration) + }); + + // Concentration at end of infusion (interpolate at dose end time) + let ceoi = if duration > 0.0 { + Some(profile.interpolate(duration)) + } else { + None + }; + + Some(RouteParams::IVInfusion(IVInfusionParams { + infusion_duration: duration, + mrt_iv, + ceoi, + })) + } + Route::Extravascular => Some(RouteParams::Extravascular), + } +} + +/// Compute steady-state parameters +fn compute_steady_state( + pre: &Precomputed, + profile: &Profile, + tau: f64, + options: &NCAOptions, +) -> SteadyStateParams { + let cmin = calc::cmin(profile); + let auc_tau = profile.auc_interval(0.0, tau, &options.auc_method); + let cavg = calc::cavg(auc_tau, tau); + let fluctuation = calc::fluctuation(pre.cmax, cmin, cavg); + let swing = calc::swing(pre.cmax, cmin); + let ptr = calc::peak_trough_ratio(pre.cmax, cmin); + + SteadyStateParams { + tau, + auc_tau, + cmin, + cmax_ss: pre.cmax, + cavg, + fluctuation, + swing, + peak_trough_ratio: ptr, + accumulation: None, // Would need single-dose reference + } +} + +/// Build quality assessment +fn build_quality( + exposure: &ExposureParams, + terminal: Option<&TerminalParams>, + lz_result: Option<&calc::LambdaZResult>, + options: &NCAOptions, +) -> Quality { + let mut warnings = Vec::new(); + + // Check for issues + if exposure.cmax <= 0.0 { + warnings.push(Warning::LowCmax); + } + + // Check extrapolation percentage (uses observed variant) + if let (Some(auc_inf_obs), Some(lz)) = (exposure.auc_inf_obs, lz_result) { + let pct_extrap = calc::auc_extrap_pct(exposure.auc_last, auc_inf_obs); + if pct_extrap > options.max_auc_extrap_pct { + warnings.push(Warning::HighExtrapolation { + pct: pct_extrap, + threshold: options.max_auc_extrap_pct, + }); + } + + // Check span ratio + if let Some(stats) = terminal.and_then(|t| t.regression.as_ref()) { + if stats.span_ratio < options.lambda_z.min_span_ratio { + warnings.push(Warning::ShortTerminalPhase { + span_ratio: stats.span_ratio, + threshold: options.lambda_z.min_span_ratio, + }); + } + } + + // Check R² + if lz.r_squared < options.lambda_z.min_r_squared { + warnings.push(Warning::PoorFit { + r_squared: lz.r_squared, + threshold: options.lambda_z.min_r_squared, + }); + } + } else { + warnings.push(Warning::LambdaZNotEstimable); + } + + Quality { warnings } +} + +/// Cmax and Tmax within a time window [start, end] (inclusive) +fn cmax_tmax_in_window(profile: &Profile, start: f64, end: f64) -> (f64, f64) { + let mut cmax = f64::NEG_INFINITY; + let mut tmax = start; + for (i, &t) in profile.times.iter().enumerate() { + if t >= start && t <= end { + let c = profile.concentrations[i]; + if c > cmax { + cmax = c; + tmax = t; + } + } + } + if cmax == f64::NEG_INFINITY { + // No observations in window + (0.0, start) + } else { + (cmax, tmax) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::builder::SubjectBuilderExt; + use crate::data::event::BLQRule; + use crate::Subject; + + fn test_profile() -> Profile { + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .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) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build(); + let occ = &subject.occasions()[0]; + Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap() + } + + #[test] + fn test_analyze_basic() { + let profile = test_profile(); + let options = NCAOptions::default(); + + let result = analyze( + &profile, + None, + Route::Extravascular, + None, + &options, + None, + None, + None, + ) + .unwrap(); + + assert_eq!(result.exposure.cmax, 10.0); + assert_eq!(result.exposure.tmax, 1.0); + assert!(result.exposure.auc_last > 0.0); + // No clearance without dose + assert!(result.clearance.is_none()); + } + + #[test] + fn test_analyze_with_dose() { + let profile = test_profile(); + let options = NCAOptions::default(); + + let result = analyze( + &profile, + Some(100.0), + Route::Extravascular, + None, + &options, + None, + None, + None, + ) + .unwrap(); + + // Should have clearance if terminal phase estimated + if result.terminal.is_some() { + assert!(result.clearance.is_some()); + } + // Exposure params are always present + assert!(result.exposure.auc_last > 0.0); + } + + #[test] + fn test_analyze_iv_bolus() { + let profile = test_profile(); + let options = NCAOptions::default(); + + let result = analyze( + &profile, + Some(100.0), + Route::IVBolus, + None, + &options, + None, + None, + None, + ) + .unwrap(); + + assert!(matches!(result.route_params, Some(RouteParams::IVBolus(_)))); + } + + #[test] + fn test_analyze_iv_infusion() { + let profile = test_profile(); + let options = NCAOptions::default(); + + let result = analyze( + &profile, + Some(100.0), + Route::IVInfusion, + Some(1.0), + &options, + None, + None, + None, + ) + .unwrap(); + + assert!(matches!( + result.route_params, + Some(RouteParams::IVInfusion(_)) + )); + if let Some(RouteParams::IVInfusion(ref inf)) = result.route_params { + assert_eq!(inf.infusion_duration, 1.0); + } + } + + #[test] + fn test_analyze_steady_state() { + let profile = test_profile(); + let options = NCAOptions::default().with_tau(12.0); + + let result = analyze( + &profile, + Some(100.0), + Route::Extravascular, + None, + &options, + None, + None, + None, + ) + .unwrap(); + + assert!(result.steady_state.is_some()); + let ss = result.steady_state.unwrap(); + assert_eq!(ss.tau, 12.0); + assert!(ss.auc_tau > 0.0); + } + + #[test] + fn test_analyze_multi_dose() { + let profile = test_profile(); // times: 0,1,2,4,8,12,24 concs: 0,10,8,6,3,1.5,0.5 + let options = NCAOptions::default().with_dose_times(vec![0.0, 8.0]); + + let result = analyze( + &profile, + Some(100.0), + Route::Extravascular, + None, + &options, + None, + None, + None, + ) + .unwrap(); + + assert!(result.multi_dose.is_some()); + let md = result.multi_dose.unwrap(); + assert_eq!(md.dose_times.len(), 2); + assert_eq!(md.auc_intervals.len(), 2); + assert_eq!(md.cmax_intervals.len(), 2); + assert_eq!(md.tmax_intervals.len(), 2); + + // First interval [0, 8]: Cmax should be 10 at t=1 + assert_eq!(md.cmax_intervals[0], 10.0); + assert_eq!(md.tmax_intervals[0], 1.0); + + // Second interval [8, 24]: Cmax should be 2.0 at t=8 + assert_eq!(md.cmax_intervals[1], 2.0); + assert_eq!(md.tmax_intervals[1], 8.0); + + // AUC intervals should be positive and sum ≈ AUC_last + assert!(md.auc_intervals[0] > 0.0); + assert!(md.auc_intervals[1] > 0.0); + let auc_sum: f64 = md.auc_intervals.iter().sum(); + assert!((auc_sum - result.exposure.auc_last).abs() / result.exposure.auc_last < 0.01); + } + + #[test] + fn test_analyze_no_multi_dose_by_default() { + let profile = test_profile(); + let options = NCAOptions::default(); + + let result = analyze( + &profile, + Some(100.0), + Route::Extravascular, + None, + &options, + None, + None, + None, + ) + .unwrap(); + + assert!(result.multi_dose.is_none()); + } +} diff --git a/src/nca/bioavailability.rs b/src/nca/bioavailability.rs new file mode 100644 index 00000000..2087a7ec --- /dev/null +++ b/src/nca/bioavailability.rs @@ -0,0 +1,473 @@ +//! Bioavailability and cross-comparison NCA functions +//! +//! Computes bioavailability (F) from crossover study designs where the same +//! subject receives both test and reference formulations (or IV vs oral). +//! +//! F = (AUC_test / Dose_test) / (AUC_ref / Dose_ref) +//! +//! For population-level bioequivalence assessment, [`bioequivalence()`] computes +//! the geometric mean ratio (GMR) and confidence interval from paired results. + +use super::types::NCAResult; + +/// Result of a bioavailability comparison +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BioavailabilityResult { + /// Bioavailability ratio (F) based on AUCinf + pub f_auc_inf: Option, + /// Bioavailability ratio (F) based on AUClast + pub f_auc_last: f64, + /// Test AUCinf (dose-normalized) + pub test_auc_inf_dn: Option, + /// Reference AUCinf (dose-normalized) + pub ref_auc_inf_dn: Option, + /// Test AUClast (dose-normalized) + pub test_auc_last_dn: f64, + /// Reference AUClast (dose-normalized) + pub ref_auc_last_dn: f64, +} + +/// Calculate bioavailability (F) from two NCA results (e.g., test vs reference) +/// +/// This is typically used in crossover bioequivalence studies: +/// - **F from AUCinf**: `(AUCinf_test / Dose_test) / (AUCinf_ref / Dose_ref)` +/// - **F from AUClast**: `(AUClast_test / Dose_test) / (AUClast_ref / Dose_ref)` +/// +/// Both results must have dose information for meaningful computation. +/// +/// # Arguments +/// * `test` - NCA result for the test formulation (or extravascular administration) +/// * `reference` - NCA result for the reference formulation (or IV administration) +/// +/// # Returns +/// `None` if either result lacks dose information (dose = 0 or missing) +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::nca::{bioavailability, NCAOptions, NCA}; +/// +/// let oral_result = oral_subject.nca(&NCAOptions::default())?; +/// let iv_result = iv_subject.nca(&NCAOptions::default())?; +/// +/// if let Some(f) = bioavailability(&oral_result, &iv_result) { +/// println!("Absolute bioavailability: {:.1}%", f.f_auc_inf.unwrap_or(f.f_auc_last) * 100.0); +/// } +/// ``` +pub fn bioavailability(test: &NCAResult, reference: &NCAResult) -> Option { + let test_dose = test.dose_amount.filter(|&d| d > 0.0)?; + let ref_dose = reference.dose_amount.filter(|&d| d > 0.0)?; + + let test_auc_last_dn = test.exposure.auc_last / test_dose; + let ref_auc_last_dn = reference.exposure.auc_last / ref_dose; + + let f_auc_last = if ref_auc_last_dn > 0.0 { + test_auc_last_dn / ref_auc_last_dn + } else { + f64::NAN + }; + + let (f_auc_inf, test_auc_inf_dn, ref_auc_inf_dn) = + match (test.exposure.auc_inf_obs, reference.exposure.auc_inf_obs) { + (Some(test_auc_inf), Some(ref_auc_inf)) => { + let test_dn = test_auc_inf / test_dose; + let ref_dn = ref_auc_inf / ref_dose; + let f = if ref_dn > 0.0 { + test_dn / ref_dn + } else { + f64::NAN + }; + (Some(f), Some(test_dn), Some(ref_dn)) + } + _ => (None, None, None), + }; + + Some(BioavailabilityResult { + f_auc_inf, + f_auc_last, + test_auc_inf_dn, + ref_auc_inf_dn, + test_auc_last_dn, + ref_auc_last_dn, + }) +} + +/// Population-level bioequivalence assessment result +/// +/// Contains geometric mean ratios and confidence intervals for both +/// AUClast and AUCinf endpoints. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BioequivalenceResult { + /// Number of evaluable pairs + pub n: usize, + /// Geometric mean ratio (AUClast, dose-normalized) + pub gmr_auc_last: f64, + /// Lower bound of CI for AUClast GMR + pub ci_lower_auc_last: f64, + /// Upper bound of CI for AUClast GMR + pub ci_upper_auc_last: f64, + /// Geometric mean ratio (AUCinf, dose-normalized) — None if not all pairs have AUCinf + pub gmr_auc_inf: Option, + /// Lower bound of CI for AUCinf GMR + pub ci_lower_auc_inf: Option, + /// Upper bound of CI for AUCinf GMR + pub ci_upper_auc_inf: Option, + /// Confidence level used (e.g. 0.90) + pub ci_level: f64, + /// Individual F values per pair (AUClast) + pub individual_f: Vec, +} + +/// Compute population-level bioequivalence from paired NCA results +/// +/// Takes a slice of `(test, reference)` NCA result pairs — typically one pair +/// per subject from a crossover design. Computes: +/// - Per-pair F values via [`bioavailability()`] +/// - Geometric mean ratio: `exp(mean(ln(F_i)))` +/// - Confidence interval: `exp(mean ± t_{α/2,n-1} × SE)` on log scale +/// +/// # Arguments +/// * `pairs` - Slice of (test, reference) NCA result pairs +/// * `ci_level` - Confidence level, e.g. 0.90 for 90% CI (standard for BE) +/// +/// # Returns +/// `None` if fewer than 2 evaluable pairs or all F values are non-positive +/// +/// # Example +/// ```rust,ignore +/// use pharmsol::nca::bioavailability::{bioequivalence, BioequivalenceResult}; +/// +/// let pairs: Vec<(NCAResult, NCAResult)> = subjects.iter() +/// .map(|s| (s.test_result.clone(), s.ref_result.clone())) +/// .collect(); +/// +/// if let Some(be) = bioequivalence(&pairs, 0.90) { +/// println!("GMR: {:.4}, 90% CI: [{:.4}, {:.4}]", +/// be.gmr_auc_last, be.ci_lower_auc_last, be.ci_upper_auc_last); +/// } +/// ``` +pub fn bioequivalence( + pairs: &[(NCAResult, NCAResult)], + ci_level: f64, +) -> Option { + // Compute individual F values + let f_values: Vec = pairs + .iter() + .filter_map(|(test, reference)| bioavailability(test, reference).map(|r| r.f_auc_last)) + .filter(|f| f.is_finite() && *f > 0.0) + .collect(); + + let n = f_values.len(); + if n < 2 { + return None; + } + + // Log-transform for GMR calculation + let ln_f: Vec = f_values.iter().map(|f| f.ln()).collect(); + let mean_ln = ln_f.iter().sum::() / n as f64; + let var_ln = ln_f.iter().map(|x| (x - mean_ln).powi(2)).sum::() / (n - 1) as f64; + let se_ln = (var_ln / n as f64).sqrt(); + + // t critical value approximation (two-tailed) + let alpha = 1.0 - ci_level; + let t_crit = t_quantile(1.0 - alpha / 2.0, (n - 1) as f64); + + let gmr_auc_last = mean_ln.exp(); + let ci_lower_auc_last = (mean_ln - t_crit * se_ln).exp(); + let ci_upper_auc_last = (mean_ln + t_crit * se_ln).exp(); + + // Same for AUCinf if all pairs have it + let f_inf_values: Vec = pairs + .iter() + .filter_map(|(test, reference)| bioavailability(test, reference).and_then(|r| r.f_auc_inf)) + .filter(|f| f.is_finite() && *f > 0.0) + .collect(); + + let (gmr_auc_inf, ci_lower_auc_inf, ci_upper_auc_inf) = if f_inf_values.len() >= 2 { + let n_inf = f_inf_values.len(); + let ln_f_inf: Vec = f_inf_values.iter().map(|f| f.ln()).collect(); + let mean_ln_inf = ln_f_inf.iter().sum::() / n_inf as f64; + let var_ln_inf = ln_f_inf + .iter() + .map(|x| (x - mean_ln_inf).powi(2)) + .sum::() + / (n_inf - 1) as f64; + let se_ln_inf = (var_ln_inf / n_inf as f64).sqrt(); + let t_crit_inf = t_quantile(1.0 - alpha / 2.0, (n_inf - 1) as f64); + + ( + Some(mean_ln_inf.exp()), + Some((mean_ln_inf - t_crit_inf * se_ln_inf).exp()), + Some((mean_ln_inf + t_crit_inf * se_ln_inf).exp()), + ) + } else { + (None, None, None) + }; + + Some(BioequivalenceResult { + n, + gmr_auc_last, + ci_lower_auc_last, + ci_upper_auc_last, + gmr_auc_inf, + ci_lower_auc_inf, + ci_upper_auc_inf, + ci_level, + individual_f: f_values, + }) +} + +/// Approximate t-distribution quantile using the Abramowitz & Stegun formula +/// Student's t-distribution quantile via `statrs` +fn t_quantile(p: f64, df: f64) -> f64 { + use statrs::distribution::{ContinuousCDF, StudentsT}; + StudentsT::new(0.0, 1.0, df).unwrap().inverse_cdf(p) +} + +/// Compute metabolite-to-parent ratios from paired NCA results +/// +/// Returns a HashMap with ratio names → values: +/// - `"auc_last_ratio"`: AUClast(metabolite) / AUClast(parent) +/// - `"auc_inf_ratio"`: AUCinf(metabolite) / AUCinf(parent) (if both available) +/// - `"cmax_ratio"`: Cmax(metabolite) / Cmax(parent) +/// +/// # Arguments +/// * `parent` - NCA result for the parent compound +/// * `metabolite` - NCA result for the metabolite +/// +/// # Example +/// ```rust,ignore +/// use pharmsol::nca::{metabolite_parent_ratio, NCAOptions, NCA}; +/// +/// let parent_result = subject.nca(&NCAOptions::default())?; +/// let metabolite_result = subject.nca(&NCAOptions::default().with_outeq(1))?; +/// let ratios = metabolite_parent_ratio(&parent_result, &metabolite_result); +/// println!("AUC ratio: {:.2}", ratios["auc_last_ratio"]); +/// ``` +pub fn metabolite_parent_ratio( + parent: &NCAResult, + metabolite: &NCAResult, +) -> std::collections::HashMap<&'static str, f64> { + let mut ratios = std::collections::HashMap::new(); + + // AUClast ratio + if parent.exposure.auc_last > 0.0 { + ratios.insert( + "auc_last_ratio", + metabolite.exposure.auc_last / parent.exposure.auc_last, + ); + } + + // AUCinf ratio (if both available) + if let (Some(m_inf), Some(p_inf)) = + (metabolite.exposure.auc_inf_obs, parent.exposure.auc_inf_obs) + { + if p_inf > 0.0 { + ratios.insert("auc_inf_ratio", m_inf / p_inf); + } + } + + // Cmax ratio + if parent.exposure.cmax > 0.0 { + ratios.insert( + "cmax_ratio", + metabolite.exposure.cmax / parent.exposure.cmax, + ); + } + + ratios +} + +/// Compare two NCA results and return ratios (test/reference) for key parameters +/// +/// Returns a HashMap with parameter names → ratio values. Uses `to_params()` +/// internally and computes test/reference for every parameter present in both. +/// +/// # Arguments +/// * `test` - NCA result for the test condition +/// * `reference` - NCA result for the reference condition +/// +/// # Example +/// ```rust,ignore +/// use pharmsol::nca::{compare, NCAOptions, NCA}; +/// +/// let ratios = compare(&test_result, &reference_result); +/// println!("AUC ratio: {:.3}", ratios["auc_last"]); +/// println!("Cmax ratio: {:.3}", ratios["cmax"]); +/// ``` +pub fn compare( + test: &NCAResult, + reference: &NCAResult, +) -> std::collections::HashMap<&'static str, f64> { + let test_params = test.to_params(); + let ref_params = reference.to_params(); + let mut ratios = std::collections::HashMap::new(); + + for (&name, &ref_val) in &ref_params { + if ref_val.abs() > f64::EPSILON { + if let Some(&test_val) = test_params.get(name) { + ratios.insert(name, test_val / ref_val); + } + } + } + + ratios +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::builder::SubjectBuilderExt; + use crate::nca::{NCAOptions, NCA}; + use crate::Subject; + + #[test] + fn test_bioavailability_basic() { + // Oral: lower exposure, same dose + let oral = Subject::builder("oral") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .observation(1.0, 5.0, 0) + .observation(2.0, 8.0, 0) + .observation(4.0, 4.0, 0) + .observation(8.0, 2.0, 0) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build(); + + // IV: higher exposure, same dose + let iv = Subject::builder("iv") + .bolus(0.0, 100.0, 0) + .observation(0.0, 20.0, 0) + .observation(1.0, 15.0, 0) + .observation(2.0, 10.0, 0) + .observation(4.0, 5.0, 0) + .observation(8.0, 2.5, 0) + .observation(12.0, 1.25, 0) + .observation(24.0, 0.3, 0) + .build(); + + let opts = NCAOptions::default(); + let oral_result = oral.nca(&opts).unwrap(); + let iv_result = iv.nca(&opts).unwrap(); + + let f = bioavailability(&oral_result, &iv_result).unwrap(); + assert!( + f.f_auc_last > 0.0 && f.f_auc_last < 1.0, + "F should be < 1 (lower oral exposure)" + ); + // F from AUClast is AUClast_oral / AUClast_iv (same dose) + let expected = oral_result.exposure.auc_last / iv_result.exposure.auc_last; + assert!((f.f_auc_last - expected).abs() < 1e-10); + } + + #[test] + fn test_bioavailability_no_dose() { + let subject = Subject::builder("no_dose") + .observation(1.0, 10.0, 0) + .observation(2.0, 8.0, 0) + .build(); + + let opts = NCAOptions::default(); + let result = subject.nca(&opts).unwrap(); + + assert!(bioavailability(&result, &result).is_none()); + } + + #[test] + fn test_t_quantile_accuracy() { + // Known t-distribution quantiles at p=0.975 + // (two-sided 95% critical values) + let cases = [ + (5.0, 2.5706), + (10.0, 2.2281), + (30.0, 2.0423), + (120.0, 1.9799), + ]; + for (df, expected) in cases { + let got = t_quantile(0.975, df); + assert!( + (got - expected).abs() < 0.001, + "t(0.975, df={df}): got {got:.4}, expected {expected:.4}" + ); + } + } + + #[test] + fn test_metabolite_parent_ratio() { + // Parent: higher exposure + let parent = Subject::builder("parent") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .observation(1.0, 20.0, 0) + .observation(2.0, 15.0, 0) + .observation(4.0, 8.0, 0) + .observation(8.0, 4.0, 0) + .observation(12.0, 2.0, 0) + .observation(24.0, 0.5, 0) + .build(); + + // Metabolite: lower exposure + let metabolite = Subject::builder("metabolite") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .observation(1.0, 5.0, 0) + .observation(2.0, 8.0, 0) + .observation(4.0, 4.0, 0) + .observation(8.0, 2.0, 0) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build(); + + let opts = NCAOptions::default(); + let p = parent.nca(&opts).unwrap(); + let m = metabolite.nca(&opts).unwrap(); + + let ratios = metabolite_parent_ratio(&p, &m); + assert!(ratios.contains_key("auc_last_ratio")); + assert!(ratios.contains_key("cmax_ratio")); + // Metabolite has lower Cmax, so ratio < 1 + assert!(*ratios.get("cmax_ratio").unwrap() < 1.0); + assert!(*ratios.get("auc_last_ratio").unwrap() < 1.0); + } + + #[test] + fn test_compare() { + let test_subj = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.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) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build(); + + let ref_subj = Subject::builder("ref") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.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) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build(); + + let opts = NCAOptions::default(); + let test_r = test_subj.nca(&opts).unwrap(); + let ref_r = ref_subj.nca(&opts).unwrap(); + + let ratios = compare(&test_r, &ref_r); + // Same data → all ratios should be ~1.0 + for (&name, &ratio) in &ratios { + assert!( + (ratio - 1.0).abs() < 1e-10, + "ratio for {name} should be 1.0, got {ratio}" + ); + } + assert!(ratios.contains_key("cmax")); + assert!(ratios.contains_key("auc_last")); + } +} diff --git a/src/nca/calc.rs b/src/nca/calc.rs new file mode 100644 index 00000000..fb9b5aa4 --- /dev/null +++ b/src/nca/calc.rs @@ -0,0 +1,1007 @@ +//! Pure calculation functions for NCA parameters +//! +//! This module contains stateless functions that compute individual NCA parameters. +//! All functions take validated inputs and return calculated values. +//! +//! AUC segment calculations are delegated to [`crate::data::auc`]. + +use crate::data::observation::ObservationProfile as Profile; + +use super::types::*; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Lambda-z Calculations +// ============================================================================ + +/// Result of lambda-z estimation +#[derive(Debug, Clone)] +pub struct LambdaZResult { + pub lambda_z: f64, + pub intercept: f64, + pub r_squared: f64, + pub adj_r_squared: f64, + pub n_points: usize, + pub time_first: f64, + pub time_last: f64, + pub clast_pred: f64, +} + +impl From for RegressionStats { + fn from(lz: LambdaZResult) -> Self { + let half_life = std::f64::consts::LN_2 / lz.lambda_z; + let span = lz.time_last - lz.time_first; + // corrxy is -sqrt(R²) since the terminal slope is negative + let corrxy = if lz.r_squared >= 0.0 { + -(lz.r_squared.sqrt()) + } else { + f64::NAN + }; + RegressionStats { + r_squared: lz.r_squared, + adj_r_squared: lz.adj_r_squared, + corrxy, + n_points: lz.n_points, + time_first: lz.time_first, + time_last: lz.time_last, + span_ratio: span / half_life, + } + } +} + +/// A single candidate regression for λz estimation +/// +/// Each candidate represents a different set of terminal points used for +/// log-linear regression. Use [`lambda_z_candidates`] to enumerate all +/// valid candidates, or call `.nca()` which auto-selects the best. +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::nca::{lambda_z_candidates, LambdaZOptions, ObservationProfile}; +/// +/// let candidates = lambda_z_candidates(&profile, &LambdaZOptions::default(), auc_last); +/// for c in &candidates { +/// println!("{} pts: λz={:.4} t½={:.2} R²={:.4} {}", +/// c.n_points, c.lambda_z, c.half_life, c.r_squared, +/// if c.is_selected { "← selected" } else { "" }); +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LambdaZCandidate { + /// Number of points used in regression + pub n_points: usize, + /// Index of first point in the profile + pub start_idx: usize, + /// Index of last point in the profile + pub end_idx: usize, + /// Time of first point + pub start_time: f64, + /// Time of last point + pub end_time: f64, + /// Terminal elimination rate constant + pub lambda_z: f64, + /// Terminal half-life (ln(2) / λz) + pub half_life: f64, + /// Regression intercept (in log-concentration space) + pub intercept: f64, + /// Coefficient of determination + pub r_squared: f64, + /// Adjusted R² + pub adj_r_squared: f64, + /// Span ratio (time span / half-life) + pub span_ratio: f64, + /// AUC∞ computed from this candidate's λz + pub auc_inf: f64, + /// Percentage of AUC extrapolated + pub auc_pct_extrap: f64, + /// Whether this candidate was auto-selected as best + pub is_selected: bool, +} + +/// Enumerate all valid λz regression candidates for a profile +/// +/// Returns every valid regression from `min_points` to `max_points` terminal +/// points, each with its computed λz, half-life, R², and derived AUC∞. The +/// auto-selected best candidate has `is_selected = true`. +/// +/// This is useful for interactive exploration: a GUI can display all candidates +/// and let the user override the automatic selection. +/// +/// # Arguments +/// * `profile` - Validated observation profile +/// * `options` - Lambda-z estimation options (controls point range, R² thresholds) +/// * `auc_last` - AUC from time 0 to Tlast (needed to compute AUC∞ for each candidate) +pub fn lambda_z_candidates( + profile: &Profile, + options: &LambdaZOptions, + auc_last: f64, +) -> Vec { + let start_idx = if options.include_tmax { + 0 + } else { + profile.cmax_idx + 1 + }; + + if profile.tlast_idx < start_idx + options.min_points - 1 { + return Vec::new(); + } + + let max_n = if let Some(max) = options.max_points { + (profile.tlast_idx - start_idx + 1).min(max) + } else { + profile.tlast_idx - start_idx + 1 + }; + + let clast_obs = profile.concentrations[profile.tlast_idx]; + + let mut candidates = Vec::new(); + let mut best_idx: Option = None; + let mut best_score = f64::NEG_INFINITY; + + for n_points in options.min_points..=max_n { + let first_idx = profile.tlast_idx - n_points + 1; + if first_idx < start_idx { + continue; + } + + if let Some(result) = fit_lambda_z(profile, first_idx, profile.tlast_idx, options) { + let hl = std::f64::consts::LN_2 / result.lambda_z; + let span = result.time_last - result.time_first; + let span_ratio = span / hl; + let auc_inf_val = auc_inf(auc_last, clast_obs, result.lambda_z); + let extrap_pct = auc_extrap_pct(auc_last, auc_inf_val); + + let candidate = LambdaZCandidate { + n_points: result.n_points, + start_idx: first_idx, + end_idx: profile.tlast_idx, + start_time: result.time_first, + end_time: result.time_last, + lambda_z: result.lambda_z, + half_life: hl, + intercept: result.intercept, + r_squared: result.r_squared, + adj_r_squared: result.adj_r_squared, + span_ratio, + auc_inf: auc_inf_val, + auc_pct_extrap: extrap_pct, + is_selected: false, + }; + + // Check if this candidate qualifies for "best" selection + let qualifies = + result.r_squared >= options.min_r_squared && span_ratio >= options.min_span_ratio; + + if qualifies { + let factor = options.adj_r_squared_factor; + let score = match options.method { + LambdaZMethod::AdjR2 => result.adj_r_squared + factor * result.n_points as f64, + _ => result.r_squared, + }; + if score > best_score { + best_score = score; + best_idx = Some(candidates.len()); + } + } + + candidates.push(candidate); + } + } + + // Mark the selected candidate + if let Some(idx) = best_idx { + candidates[idx].is_selected = true; + } + + candidates +} + +/// Estimate lambda-z using log-linear regression +pub fn lambda_z(profile: &Profile, options: &LambdaZOptions) -> Option { + // Determine start index (exclude or include Tmax) + let start_idx = if options.include_tmax { + 0 + } else { + profile.cmax_idx + 1 + }; + + // Need at least min_points between start and tlast + if profile.tlast_idx < start_idx + options.min_points - 1 { + return None; + } + + match options.method { + LambdaZMethod::Manual(n) => lambda_z_with_n_points(profile, start_idx, n, options), + LambdaZMethod::R2 | LambdaZMethod::AdjR2 => lambda_z_best_fit(profile, start_idx, options), + } +} + +/// Lambda-z with specified number of terminal points +fn lambda_z_with_n_points( + profile: &Profile, + start_idx: usize, + n_points: usize, + options: &LambdaZOptions, +) -> Option { + if n_points < options.min_points { + return None; + } + + let first_idx = profile.tlast_idx.saturating_sub(n_points - 1); + if first_idx < start_idx { + return None; + } + + fit_lambda_z(profile, first_idx, profile.tlast_idx, options) +} + +/// Lambda-z with best fit selection +/// +/// Delegates to [`lambda_z_candidates`] and returns the selected candidate's +/// underlying [`LambdaZResult`]. We use `auc_last = 0.0` here because the +/// caller only needs the regression result, not AUC∞ (which is computed later). +fn lambda_z_best_fit( + profile: &Profile, + _start_idx: usize, + options: &LambdaZOptions, +) -> Option { + let candidates = lambda_z_candidates(profile, options, 0.0); + let selected = candidates.iter().find(|c| c.is_selected)?; + + // Reconstruct LambdaZResult from the selected candidate + let clast_pred = + (selected.intercept - selected.lambda_z * profile.times[selected.end_idx]).exp(); + + Some(LambdaZResult { + lambda_z: selected.lambda_z, + intercept: selected.intercept, + r_squared: selected.r_squared, + adj_r_squared: selected.adj_r_squared, + n_points: selected.n_points, + time_first: selected.start_time, + time_last: selected.end_time, + clast_pred, + }) +} + +/// Fit log-linear regression for lambda-z +fn fit_lambda_z( + profile: &Profile, + first_idx: usize, + last_idx: usize, + options: &LambdaZOptions, +) -> Option { + // Extract points with positive concentrations, respecting exclusion list + let mut times = Vec::new(); + let mut log_concs = Vec::new(); + + for i in first_idx..=last_idx { + // Skip excluded indices + if options.exclude_indices.contains(&i) { + continue; + } + if profile.concentrations[i] > 0.0 { + times.push(profile.times[i]); + log_concs.push(profile.concentrations[i].ln()); + } + } + + if times.len() < 2 { + return None; + } + + // Simple linear regression: ln(C) = intercept + slope * t + let (slope, intercept, r_squared) = linear_regression(×, &log_concs)?; + + let lambda_z = -slope; + + // Lambda-z must be positive + if lambda_z <= 0.0 { + return None; + } + + let n = times.len() as f64; + let adj_r_squared = 1.0 - (1.0 - r_squared) * (n - 1.0) / (n - 2.0); + + // Predicted concentration at Tlast + let clast_pred = (intercept + slope * profile.times[last_idx]).exp(); + + Some(LambdaZResult { + lambda_z, + intercept, + r_squared, + adj_r_squared, + n_points: times.len(), + time_first: times[0], + time_last: times[times.len() - 1], + clast_pred, + }) +} + +/// Numerically stable linear regression using Kahan (compensated) summation. +/// +/// Uses compensated summation for all accumulations to avoid catastrophic +/// cancellation with large time values (e.g., time in minutes > 10,000). +/// +/// Returns (slope, intercept, r_squared) +fn linear_regression(x: &[f64], y: &[f64]) -> Option<(f64, f64, f64)> { + let n = x.len() as f64; + if n < 2.0 { + return None; + } + + // Kahan compensated summation for all sums + let sum_x = kahan_sum(x.iter().copied()); + let sum_y = kahan_sum(y.iter().copied()); + let sum_xy = kahan_sum(x.iter().zip(y.iter()).map(|(xi, yi)| xi * yi)); + let sum_x2 = kahan_sum(x.iter().map(|xi| xi * xi)); + + let denom = n * sum_x2 - sum_x * sum_x; + if denom.abs() < 1e-15 { + return None; + } + + let slope = (n * sum_xy - sum_x * sum_y) / denom; + let intercept = (sum_y - slope * sum_x) / n; + + // Calculate R² using residuals (more stable than sum_y2 formula) + let mean_y = sum_y / n; + let ss_tot = kahan_sum(y.iter().map(|yi| (yi - mean_y).powi(2))); + let ss_res = kahan_sum(x.iter().zip(y.iter()).map(|(xi, yi)| { + let pred = intercept + slope * xi; + (yi - pred).powi(2) + })); + + let r_squared = if ss_tot.abs() < 1e-15 { + 1.0 + } else { + 1.0 - ss_res / ss_tot + }; + + Some((slope, intercept, r_squared)) +} + +/// Kahan (compensated) summation for improved numerical precision. +/// +/// Reduces floating-point accumulation error from O(n·ε) to O(ε) where +/// ε is machine epsilon, making it safe for large values and long sums. +#[inline] +fn kahan_sum(iter: impl Iterator) -> f64 { + let mut sum = 0.0_f64; + let mut comp = 0.0_f64; // compensation for lost low-order bits + for val in iter { + let y = val - comp; + let t = sum + y; + comp = (t - sum) - y; + sum = t; + } + sum +} + +// ============================================================================ +// Derived Parameters +// ============================================================================ + +/// Calculate terminal half-life +#[inline] +pub fn half_life(lambda_z: f64) -> f64 { + std::f64::consts::LN_2 / lambda_z +} + +/// Calculate AUC extrapolated to infinity +#[inline] +pub fn auc_inf(auc_last: f64, clast: f64, lambda_z: f64) -> f64 { + if lambda_z <= 0.0 { + return f64::NAN; + } + auc_last + clast / lambda_z +} + +/// Calculate percentage of AUC extrapolated +#[inline] +pub fn auc_extrap_pct(auc_last: f64, auc_inf: f64) -> f64 { + if auc_inf <= 0.0 || !auc_inf.is_finite() { + return f64::NAN; + } + (auc_inf - auc_last) / auc_inf * 100.0 +} + +/// Calculate AUMC extrapolated to infinity +pub fn aumc_inf(aumc_last: f64, clast: f64, tlast: f64, lambda_z: f64) -> f64 { + if lambda_z <= 0.0 { + return f64::NAN; + } + aumc_last + clast * tlast / lambda_z + clast / (lambda_z * lambda_z) +} + +/// Calculate mean residence time +#[inline] +pub fn mrt(aumc_inf: f64, auc_inf: f64) -> f64 { + if auc_inf <= 0.0 || !auc_inf.is_finite() { + return f64::NAN; + } + aumc_inf / auc_inf +} + +/// Calculate clearance +#[inline] +pub fn clearance(dose: f64, auc_inf: f64) -> f64 { + if auc_inf <= 0.0 || !auc_inf.is_finite() { + return f64::NAN; + } + dose / auc_inf +} + +/// Calculate volume of distribution +#[inline] +pub fn vz(dose: f64, lambda_z: f64, auc_inf: f64) -> f64 { + if lambda_z <= 0.0 || auc_inf <= 0.0 || !auc_inf.is_finite() { + return f64::NAN; + } + dose / (lambda_z * auc_inf) +} + +// ============================================================================ +// Route-Specific Parameters +// ============================================================================ + +use super::types::C0Method; + +/// Estimate C0 using a cascade of methods (first success wins) +/// +/// Methods are tried in order. Default cascade: `[Observed, LogSlope, FirstConc]` +/// +/// Returns `(c0_value, method_used)` or `(NaN, None)` if all methods fail. +pub fn c0(profile: &Profile, methods: &[C0Method], lambda_z: f64) -> (f64, Option) { + for m in methods { + if let Some(val) = try_c0_method(profile, *m, lambda_z) { + return (val, Some(*m)); + } + } + (f64::NAN, None) +} + +/// Try a single C0 estimation method +fn try_c0_method(profile: &Profile, method: C0Method, _lambda_z: f64) -> Option { + match method { + C0Method::Observed => { + // Use concentration at t=0 if present and positive + if !profile.times.is_empty() && profile.times[0].abs() < 1e-10 { + let c = profile.concentrations[0]; + if c > 0.0 { + return Some(c); + } + } + None + } + C0Method::LogSlope => c0_logslope(profile), + C0Method::FirstConc => { + // Use first positive concentration + profile.concentrations.iter().find(|&&c| c > 0.0).copied() + } + C0Method::Cmin => { + // Use minimum positive concentration + profile + .concentrations + .iter() + .filter(|&&c| c > 0.0) + .min_by(|a, b| a.partial_cmp(b).unwrap()) + .copied() + } + C0Method::Zero => Some(0.0), + } +} + +/// Semilog back-extrapolation from first two positive points (PKNCA logslope method) +fn c0_logslope(profile: &Profile) -> Option { + if profile.concentrations.is_empty() { + return None; + } + + // Find first two positive concentrations + let positive_points: Vec<(f64, f64)> = profile + .times + .iter() + .zip(profile.concentrations.iter()) + .filter(|(_, &c)| c > 0.0) + .map(|(&t, &c)| (t, c)) + .take(2) + .collect(); + + if positive_points.len() < 2 { + return None; + } + + let (t1, c1) = positive_points[0]; + let (t2, c2) = positive_points[1]; + + // PKNCA requires c2 < c1 (declining) for logslope + if c2 >= c1 || (t2 - t1).abs() < 1e-10 { + return None; + } + + // Semilog extrapolation: C0 = exp(ln(c1) - slope * t1) + let slope = (c2.ln() - c1.ln()) / (t2 - t1); + Some((c1.ln() - slope * t1).exp()) +} + +/// Calculate Vd for IV bolus +#[inline] +pub fn vd_bolus(dose: f64, c0: f64) -> f64 { + if c0 <= 0.0 || !c0.is_finite() { + return f64::NAN; + } + dose / c0 +} + +/// Calculate Vss for IV administration +pub fn vss(dose: f64, aumc_inf: f64, auc_inf: f64) -> f64 { + if auc_inf <= 0.0 || !auc_inf.is_finite() { + return f64::NAN; + } + dose * aumc_inf / (auc_inf * auc_inf) +} + +/// Calculate MRT corrected for infusion duration +#[inline] +pub fn mrt_infusion(mrt: f64, duration: f64) -> f64 { + mrt - duration / 2.0 +} + +/// Detect lag time for extravascular administration from raw concentration data +/// +/// This matches PKNCA's approach: tlag is calculated on raw data with BLQ treated as 0, +/// BEFORE any BLQ filtering is applied to the profile. +/// +/// Returns the time at which concentration first increases (PKNCA method). +/// For profiles starting at t=0 with C=0 (or BLQ), this returns 0 if there's +/// an increase to the next point. +pub fn tlag_from_raw( + times: &[f64], + concentrations: &[f64], + censoring: &[crate::Censor], +) -> Option { + if times.len() < 2 || concentrations.len() < 2 { + return None; + } + + // Use iterator-based approach to avoid allocating a Vec for BLQ-converted concentrations. + // Convert BLQ to 0 on-the-fly, keep other values as-is (matching PKNCA). + let conc_iter = concentrations + .iter() + .zip(censoring.iter()) + .map(|(&c, censor)| { + if matches!(censor, crate::Censor::BLOQ) { + 0.0 + } else { + c + } + }); + + // Find first time when concentration increases (PKNCA method) + // We need to compare adjacent elements, so we use a sliding window via zip + let mut prev = None; + for (i, c) in conc_iter.enumerate() { + if let Some(prev_c) = prev { + if c > prev_c { + return Some(times[i - 1]); + } + } + prev = Some(c); + } + // No increase found - either flat or all decreasing + None +} + +// ============================================================================ +// Steady-State Parameters +// ============================================================================ + +/// Calculate Cmin from profile +pub fn cmin(profile: &Profile) -> f64 { + profile + .concentrations + .iter() + .copied() + .filter(|&c| c > 0.0) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(0.0) +} + +/// Calculate average concentration +#[inline] +pub fn cavg(auc_tau: f64, tau: f64) -> f64 { + if tau <= 0.0 { + return f64::NAN; + } + auc_tau / tau +} + +/// Calculate fluctuation percentage +pub fn fluctuation(cmax: f64, cmin: f64, cavg: f64) -> f64 { + if cavg <= 0.0 { + return f64::NAN; + } + (cmax - cmin) / cavg * 100.0 +} + +/// Calculate swing +pub fn swing(cmax: f64, cmin: f64) -> f64 { + if cmin <= 0.0 { + return f64::NAN; + } + (cmax - cmin) / cmin +} + +// ============================================================================ +// Derived Parameters — Phase 2 additions +// ============================================================================ + +/// Calculate effective half-life: t½,eff = ln(2) × MRT +/// +/// Useful for drugs with nonlinear pharmacokinetics where terminal half-life +/// may not reflect the effective duration of drug persistence. +#[inline] +pub fn effective_half_life(mrt: f64) -> f64 { + if !mrt.is_finite() || mrt <= 0.0 { + return f64::NAN; + } + std::f64::consts::LN_2 * mrt +} + +/// Calculate elimination rate constant: Kel = 1 / MRT +/// +/// Alternative representation of overall elimination. +#[inline] +pub fn kel(mrt: f64) -> f64 { + if !mrt.is_finite() || mrt <= 0.0 { + return f64::NAN; + } + 1.0 / mrt +} + +/// Calculate peak-to-trough ratio: PTR = Cmax / Cmin +/// +/// Used in steady-state analysis to assess PK variability within a dosing interval. +#[inline] +pub fn peak_trough_ratio(cmax: f64, cmin: f64) -> f64 { + if cmin <= 0.0 || !cmin.is_finite() { + return f64::NAN; + } + cmax / cmin +} + +/// Calculate time above a target concentration +/// +/// Uses linear interpolation to find exact crossing times. +/// Returns the total time spent above the threshold within the profile. +/// +/// This is PD-relevant for concentration-dependent drugs (e.g., antibiotics) +/// where efficacy correlates with the time the drug concentration exceeds +/// a minimum inhibitory concentration (MIC). +pub fn time_above_concentration(times: &[f64], concentrations: &[f64], threshold: f64) -> f64 { + if times.len() < 2 || concentrations.len() < 2 { + return 0.0; + } + + let mut total_time = 0.0; + + for i in 0..times.len() - 1 { + let (t1, c1) = (times[i], concentrations[i]); + let (t2, c2) = (times[i + 1], concentrations[i + 1]); + let dt = t2 - t1; + + if c1 >= threshold && c2 >= threshold { + // Both above: entire interval counts + total_time += dt; + } else if c1 >= threshold && c2 < threshold { + // Crosses below: interpolate the crossing time + let t_cross = t1 + dt * (c1 - threshold) / (c1 - c2); + total_time += t_cross - t1; + } else if c1 < threshold && c2 >= threshold { + // Crosses above: interpolate the crossing time + let t_cross = t1 + dt * (threshold - c1) / (c2 - c1); + total_time += t2 - t_cross; + } + // Both below: nothing added + } + + total_time +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::auc::auc_segment; + use crate::data::builder::SubjectBuilderExt; + use crate::data::event::{AUCMethod, BLQRule}; + use crate::Subject; + + fn make_test_profile() -> Profile { + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.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) + .observation(12.0, 1.0, 0) + .build(); + let occ = &subject.occasions()[0]; + Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap() + } + + #[test] + fn test_auc_segment_linear() { + let auc = auc_segment(0.0, 10.0, 1.0, 8.0, &AUCMethod::Linear); + assert!((auc - 9.0).abs() < 1e-10); // (10 + 8) / 2 * 1 + } + + #[test] + fn test_auc_segment_log_down() { + // Descending - should use log-linear + let auc = auc_segment(0.0, 10.0, 1.0, 5.0, &AUCMethod::LinUpLogDown); + let expected = 5.0 / (10.0_f64 / 5.0).ln(); // (C1-C2) * dt / ln(C1/C2) + assert!((auc - expected).abs() < 1e-10); + } + + #[test] + fn test_auc_last() { + let profile = make_test_profile(); + let auc = profile.auc_last(&AUCMethod::Linear); + + // Manual calculation: + // 0-1: (0 + 10) / 2 * 1 = 5 + // 1-2: (10 + 8) / 2 * 1 = 9 + // 2-4: (8 + 4) / 2 * 2 = 12 + // 4-8: (4 + 2) / 2 * 4 = 12 + // 8-12: (2 + 1) / 2 * 4 = 6 + // Total = 44 + assert!((auc - 44.0).abs() < 1e-10); + } + + #[test] + fn test_half_life() { + let hl = half_life(0.1); + assert!((hl - 6.931).abs() < 0.01); // ln(2) / 0.1 ≈ 6.931 + } + + #[test] + fn test_clearance() { + let cl = clearance(100.0, 50.0); + assert!((cl - 2.0).abs() < 1e-10); + } + + #[test] + fn test_vz() { + let v = vz(100.0, 0.1, 50.0); + assert!((v - 20.0).abs() < 1e-10); // 100 / (0.1 * 50) = 20 + } + + #[test] + fn test_linear_regression() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![2.0, 4.0, 6.0, 8.0, 10.0]; // Perfect line: y = 2x + + let (slope, intercept, r_squared) = linear_regression(&x, &y).unwrap(); + assert!((slope - 2.0).abs() < 1e-10); + assert!(intercept.abs() < 1e-10); + assert!((r_squared - 1.0).abs() < 1e-10); + } + + #[test] + fn test_fluctuation() { + let fluct = fluctuation(10.0, 2.0, 5.0); + assert!((fluct - 160.0).abs() < 1e-10); // (10-2)/5 * 100 = 160% + } + + #[test] + fn test_swing() { + let s = swing(10.0, 2.0); + assert!((s - 4.0).abs() < 1e-10); // (10-2)/2 = 4 + } + + // ======================================================================== + // Additional calc.rs unit tests (Task 3.1) + // ======================================================================== + + #[test] + fn test_time_above_concentration_all_above() { + let times = [0.0, 1.0, 2.0, 4.0]; + let concs = [10.0, 8.0, 6.0, 5.0]; + let result = time_above_concentration(×, &concs, 1.0); + assert!((result - 4.0).abs() < 1e-10, "All above: full duration"); + } + + #[test] + fn test_time_above_concentration_all_below() { + let times = [0.0, 1.0, 2.0]; + let concs = [0.5, 0.3, 0.1]; + let result = time_above_concentration(×, &concs, 1.0); + assert!((result - 0.0).abs() < 1e-10, "All below: zero time"); + } + + #[test] + fn test_time_above_concentration_crossing() { + // Crosses below at interpolated point + let times = [0.0, 1.0, 2.0]; + let concs = [10.0, 5.0, 0.0]; // crosses threshold=4 at t ≈ 0.0 + 1.0 * (10-4)/(10-5) = 1.2 + let result = time_above_concentration(×, &concs, 4.0); + // 0→1: both above (10≥4, 5≥4) → 1.0 + // 1→2: crosses below, t_cross = 1.0 + 1.0 * (5-4)/(5-0) = 1.2 + let expected = 1.0 + 0.2; + assert!( + (result - expected).abs() < 1e-10, + "Crossing: {result} != {expected}" + ); + } + + #[test] + fn test_time_above_concentration_crosses_above() { + let times = [0.0, 1.0, 2.0]; + let concs = [0.0, 10.0, 10.0]; + // threshold = 5: crosses above at t = 0.5 + let result = time_above_concentration(×, &concs, 5.0); + // 0→1: crosses above at t = 0.0 + 1.0*(5-0)/(10-0) = 0.5 → 1.0-0.5=0.5 + // 1→2: both above → 1.0 + assert!((result - 1.5).abs() < 1e-10); + } + + #[test] + fn test_time_above_concentration_empty() { + assert!((time_above_concentration(&[], &[], 1.0) - 0.0).abs() < 1e-10); + assert!((time_above_concentration(&[1.0], &[5.0], 1.0) - 0.0).abs() < 1e-10); + } + + #[test] + fn test_c0_logslope_normal() { + // Two declining points: t=0.5,c=20 and t=1.0,c=10 + // slope = (ln10-ln20)/(1.0-0.5) = -ln2/0.5 = -1.3863 + // c0 = exp(ln20 - (-1.3863)*0.5) = exp(ln20 + 0.6931) = exp(3.6889) ≈ 40 + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 1) + .observation(0.5, 20.0, 0) + .observation(1.0, 10.0, 0) + .observation(4.0, 1.0, 0) + .build(); + let occ = &subject.occasions()[0]; + let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + let result = c0_logslope(&profile); + assert!(result.is_some()); + assert!((result.unwrap() - 40.0).abs() < 0.1); + } + + #[test] + fn test_c0_logslope_first_conc_zero() { + // First positive after a zero: t=0,c=0 then t=1,c=10 then t=2,c=5 + // positive_points = [(1,10),(2,5)], c2 < c1, ok + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 1) + .observation(0.0, 0.0, 0) + .observation(1.0, 10.0, 0) + .observation(2.0, 5.0, 0) + .build(); + let occ = &subject.occasions()[0]; + let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + let result = c0_logslope(&profile); + assert!(result.is_some()); + } + + #[test] + fn test_c0_logslope_both_equal() { + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 1) + .observation(1.0, 10.0, 0) + .observation(2.0, 10.0, 0) + .build(); + let occ = &subject.occasions()[0]; + let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + let result = c0_logslope(&profile); + // c2 >= c1, so should return None + assert!(result.is_none()); + } + + #[test] + fn test_tlag_from_raw_clear_lag() { + // BLQ, BLQ, then increase + let times = vec![0.0, 0.5, 1.0, 2.0]; + let concs = vec![0.0, 0.0, 5.0, 10.0]; + let censoring = vec![ + crate::Censor::BLOQ, + crate::Censor::BLOQ, + crate::Censor::None, + crate::Censor::None, + ]; + let result = tlag_from_raw(×, &concs, &censoring); + // BLQ→BLQ: 0→0 no increase, BLQ→5: 0→5 increase at index 2, so tlag = times[1] = 0.5 + assert_eq!(result, Some(0.5)); + } + + #[test] + fn test_tlag_from_raw_no_lag() { + // First point already increasing + let times = vec![0.0, 1.0, 2.0]; + let concs = vec![0.0, 10.0, 8.0]; + let censoring = vec![crate::Censor::None; 3]; + let result = tlag_from_raw(×, &concs, &censoring); + // 0→10: increase at index 1, tlag = times[0] = 0.0 + assert_eq!(result, Some(0.0)); + } + + #[test] + fn test_tlag_from_raw_all_declining() { + let times = vec![0.0, 1.0, 2.0]; + let concs = vec![10.0, 5.0, 2.0]; + let censoring = vec![crate::Censor::None; 3]; + let result = tlag_from_raw(×, &concs, &censoring); + assert!(result.is_none()); + } + + #[test] + fn test_c0_cascade() { + // Build an IV bolus profile with observation at t=0 + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 1) // IV bolus + .observation(0.0, 50.0, 0) // t=0 with positive conc → Observed method + .observation(0.5, 40.0, 0) + .observation(1.0, 30.0, 0) + .observation(4.0, 10.0, 0) + .build(); + let occ = &subject.occasions()[0]; + let profile = Profile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + let methods = vec![C0Method::Observed, C0Method::LogSlope, C0Method::FirstConc]; + let lambda_z = 0.5; + let (c0_val, method) = c0(&profile, &methods, lambda_z); + assert!((c0_val - 50.0).abs() < 1e-10); + assert_eq!(method, Some(C0Method::Observed)); + + // If Observed is removed, LogSlope should be used + let methods2 = vec![C0Method::LogSlope, C0Method::FirstConc]; + let (c0_val2, method2) = c0(&profile, &methods2, lambda_z); + assert!(c0_val2 > 0.0); + assert_eq!(method2, Some(C0Method::LogSlope)); + } + + #[test] + fn test_effective_half_life_known() { + let mrt = 10.0; + let t_half_eff = effective_half_life(mrt); + assert!((t_half_eff - std::f64::consts::LN_2 * 10.0).abs() < 1e-10); + } + + #[test] + fn test_effective_half_life_invalid() { + assert!(effective_half_life(0.0).is_nan()); + assert!(effective_half_life(-1.0).is_nan()); + assert!(effective_half_life(f64::NAN).is_nan()); + } + + #[test] + fn test_kel_known() { + let mrt = 5.0; + assert!((kel(mrt) - 0.2).abs() < 1e-10); + } + + #[test] + fn test_kel_invalid() { + assert!(kel(0.0).is_nan()); + assert!(kel(-1.0).is_nan()); + } + + #[test] + fn test_peak_trough_ratio() { + assert!((peak_trough_ratio(10.0, 2.0) - 5.0).abs() < 1e-10); + assert!(peak_trough_ratio(10.0, 0.0).is_nan()); + } + + #[test] + fn test_cavg_known() { + assert!((cavg(100.0, 10.0) - 10.0).abs() < 1e-10); + assert!(cavg(100.0, 0.0).is_nan()); + } +} diff --git a/src/nca/error.rs b/src/nca/error.rs new file mode 100644 index 00000000..c5bf9203 --- /dev/null +++ b/src/nca/error.rs @@ -0,0 +1,23 @@ +//! NCA error types + +use thiserror::Error; + +/// Errors that can occur during NCA analysis +#[derive(Error, Debug, Clone)] +pub enum NCAError { + /// An error from observation data processing (BLQ filtering, profile construction) + #[error(transparent)] + Observation(#[from] crate::data::observation_error::ObservationError), + + /// An error from observation metrics computation + #[error(transparent)] + Metrics(#[from] crate::data::traits::MetricsError), + + /// Lambda-z estimation failed + #[error("Lambda-z estimation failed: {reason}")] + LambdaZFailed { reason: String }, + + /// Invalid parameter value + #[error("Invalid parameter: {param} = {value}")] + InvalidParameter { param: String, value: String }, +} diff --git a/src/nca/mod.rs b/src/nca/mod.rs new file mode 100644 index 00000000..941086e3 --- /dev/null +++ b/src/nca/mod.rs @@ -0,0 +1,127 @@ +//! Non-Compartmental Analysis (NCA) for pharmacokinetic data +//! +//! This module provides a clean, powerful API for calculating standard NCA parameters +//! from concentration-time data. It integrates seamlessly with pharmsol's data structures +//! ([`crate::Subject`], [`crate::Occasion`]). +//! +//! # Design Philosophy +//! +//! - **Simple**: Single entry point via `.nca()` method on data structures +//! - **Powerful**: Full support for all standard NCA parameters +//! - **Data-aware**: Doses and routes are auto-detected from the data +//! - **Configurable**: Analysis options via [`NCAOptions`] +//! +//! # Key Parameters +//! +//! | Parameter | Description | +//! |-----------|-------------| +//! | Cmax | Maximum observed concentration | +//! | Tmax | Time of maximum concentration | +//! | Clast | Last measurable concentration (> 0) | +//! | Tlast | Time of last measurable concentration | +//! | AUClast | Area under curve from 0 to Tlast | +//! | AUCinf | AUC extrapolated to infinity | +//! | λz | Terminal elimination rate constant | +//! | t½ | Terminal half-life (ln(2)/λz) | +//! | CL/F | Apparent clearance | +//! | Vz/F | Apparent volume of distribution | +//! | MRT | Mean residence time | +//! +//! # Usage +//! +//! NCA is performed by calling `.nca()` on a `Subject`. Dose and route +//! information are automatically detected from the dose events in the data. +//! +//! ```rust,ignore +//! use pharmsol::prelude::*; +//! use pharmsol::nca::NCAOptions; +//! +//! // Build subject with dose and observation events +//! let subject = Subject::builder("patient_001") +//! .bolus(0.0, 100.0, 0) // 100 mg oral dose +//! .observation(1.0, 10.0, 0) +//! .observation(2.0, 8.0, 0) +//! .observation(4.0, 4.0, 0) +//! .build(); +//! +//! // Perform NCA with default options +//! let result = subject.nca(&NCAOptions::default()).expect("NCA failed"); +//! +//! println!("Cmax: {:.2}", result.exposure.cmax); +//! println!("AUClast: {:.2}", result.exposure.auc_last); +//! ``` +//! +//! # Steady-State Analysis +//! +//! ```rust,ignore +//! use pharmsol::nca::NCAOptions; +//! +//! // Configure for steady-state with 12h dosing interval +//! let options = NCAOptions::default().with_tau(12.0); +//! let result = subject.nca(&options).unwrap(); +//! +//! if let Some(ref ss) = result.steady_state { +//! println!("Cavg: {:.2}", ss.cavg); +//! println!("Fluctuation: {:.1}%", ss.fluctuation); +//! } +//! ``` +//! +//! # Population Analysis +//! +//! ```rust,ignore +//! use pharmsol::nca::{NCAOptions, NCA, NCAPopulation}; +//! +//! // All occasions flat +//! let all_results = data.nca_all(&options); +//! +//! // Grouped by subject (includes error isolation) +//! let grouped = data.nca_grouped(&options); +//! for subj in &grouped { +//! println!("{}: {} ok, {} errors", +//! subj.subject_id, +//! subj.successes().len(), +//! subj.errors().len()); +//! } +//! ``` + +// Internal modules +mod analyze; +mod calc; +mod error; +pub mod summary; +mod traits; +mod types; + +// Feature modules +pub mod bioavailability; +pub mod sparse; +pub mod superposition; + +#[cfg(test)] +mod tests; + +// Crate-internal re-exports +// (traits.rs accesses analyze::analyze and calc::tlag_from_raw directly) + +// Public API +pub use bioavailability::{ + bioavailability, bioequivalence, compare, metabolite_parent_ratio, BioavailabilityResult, + BioequivalenceResult, +}; +pub use calc::{lambda_z_candidates, LambdaZCandidate}; +pub use error::NCAError; +pub use sparse::{sparse_auc, sparse_auc_from_data, SparsePKResult}; +pub use summary::{nca_to_csv, summarize, ParameterSummary, PopulationSummary}; +pub use superposition::{ + predict as superposition_predict, predict_from_nca, Superposition, SuperpositionResult, +}; +pub use traits::{NCAPopulation, SubjectNCAResult, NCA}; +pub use types::{ + C0Method, ClearanceParams, ExposureParams, IVBolusParams, IVInfusionParams, LambdaZMethod, + LambdaZOptions, MultiDoseParams, NCAOptions, NCAResult, Quality, RegressionStats, RouteParams, + Severity, SteadyStateParams, TerminalParams, Warning, +}; + +// Re-export shared types (backwards compatible) +pub use crate::data::event::{AUCMethod, BLQRule, Route}; +pub use crate::data::observation::ObservationProfile; diff --git a/src/nca/sparse.rs b/src/nca/sparse.rs new file mode 100644 index 00000000..eaecfa8e --- /dev/null +++ b/src/nca/sparse.rs @@ -0,0 +1,281 @@ +//! Sparse PK analysis using Bailer's method +//! +//! For studies with destructive sampling (e.g., preclinical) or very sparse designs +//! (e.g., pediatric/oncology), individual subjects don't have enough samples for +//! traditional NCA. Bailer's method computes a population AUC with standard error +//! by using the trapezoidal rule on mean concentrations at each time point. +//! +//! # Usage +//! +//! The simplest way is via [`sparse_auc_from_data`] which accepts a [`Data`] object: +//! +//! ```rust,ignore +//! use pharmsol::nca::sparse::sparse_auc_from_data; +//! +//! let result = sparse_auc_from_data(&data, 0, None).unwrap(); +//! println!("Population AUC: {:.2} ± {:.2}", result.auc, result.auc_se); +//! ``` +//! +//! Reference: Bailer AJ. "Testing for the equality of area under the curves when +//! using destructive measurement techniques." J Pharmacokinet Biopharm. 1988;16(3):303-309. + +use crate::Data; +use serde::{Deserialize, Serialize}; + +/// Result of sparse PK analysis using Bailer's method +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SparsePKResult { + /// Population AUC estimate (trapezoidal on mean concentrations) + pub auc: f64, + /// Standard error of the AUC estimate + pub auc_se: f64, + /// 95% confidence interval lower bound + pub auc_ci_lower: f64, + /// 95% confidence interval upper bound + pub auc_ci_upper: f64, + /// Number of time points + pub n_timepoints: usize, + /// Mean concentrations at each time point + pub mean_concentrations: Vec, + /// Number of observations at each time point + pub n_per_timepoint: Vec, + /// Unique time points + pub times: Vec, +} + +/// Compute population AUC from sparse/destructive sampling using Bailer's method +/// +/// Groups observations by time point, computes mean and variance at each time, +/// then applies the trapezoidal rule to the mean concentrations. The standard +/// error is computed using the variance propagation formula for the trapezoidal rule. +/// +/// # Arguments +/// * `times` - Observation times (parallel with `concentrations`) +/// * `concentrations` - Observed concentrations (parallel with `times`) +/// * `time_tolerance` - Tolerance for grouping time points (default: exact matching). +/// Observations at times within this tolerance are considered the same nominal time. +/// +/// # Returns +/// `None` if fewer than 2 unique time points with data +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::nca::sparse::sparse_auc; +/// +/// let times = vec![0.0, 0.0, 1.0, 1.0, 4.0, 4.0, 8.0, 8.0]; +/// let concs = vec![0.0, 0.0, 10.5, 12.0, 5.0, 4.5, 1.5, 2.0]; +/// +/// let result = sparse_auc(×, &concs, None).unwrap(); +/// println!("Population AUC: {:.2} ± {:.2}", result.auc, result.auc_se); +/// println!("95% CI: [{:.2}, {:.2}]", result.auc_ci_lower, result.auc_ci_upper); +/// ``` +pub fn sparse_auc( + times: &[f64], + concentrations: &[f64], + time_tolerance: Option, +) -> Option { + if times.is_empty() || times.len() != concentrations.len() { + return None; + } + + let tol = time_tolerance.unwrap_or(0.0); + + // Group observations by time point + let mut time_groups: Vec<(f64, Vec)> = Vec::new(); + + // Sort by time using indices + let mut indices: Vec = (0..times.len()).collect(); + indices.sort_by(|&a, &b| times[a].partial_cmp(×[b]).unwrap()); + + for &idx in &indices { + let t = times[idx]; + let c = concentrations[idx]; + let matched = time_groups + .iter_mut() + .find(|(gt, _)| (t - *gt).abs() <= tol); + if let Some((_, group)) = matched { + group.push(c); + } else { + time_groups.push((t, vec![c])); + } + } + + // Sort by time + time_groups.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + if time_groups.len() < 2 { + return None; + } + + let n_timepoints = time_groups.len(); + let group_times: Vec = time_groups.iter().map(|(t, _)| *t).collect(); + let n_per_timepoint: Vec = time_groups.iter().map(|(_, g)| g.len()).collect(); + + // Compute mean and variance at each time point + let mean_concentrations: Vec = time_groups + .iter() + .map(|(_, group)| { + let n = group.len() as f64; + group.iter().sum::() / n + }) + .collect(); + + let variances: Vec = time_groups + .iter() + .map(|(_, group)| { + let n = group.len() as f64; + if n < 2.0 { + return 0.0; // Single observation: no variance estimate + } + let mean = group.iter().sum::() / n; + group.iter().map(|c| (c - mean).powi(2)).sum::() / (n - 1.0) + }) + .collect(); + + // Bailer's AUC: trapezoidal rule on mean concentrations + let mut auc = 0.0; + for i in 0..n_timepoints - 1 { + let dt = group_times[i + 1] - group_times[i]; + auc += (mean_concentrations[i] + mean_concentrations[i + 1]) * dt / 2.0; + } + + // Bailer's variance: sum of weighted variances + let mut weights = vec![0.0; n_timepoints]; + for i in 0..n_timepoints - 1 { + let dt = group_times[i + 1] - group_times[i]; + weights[i] += dt / 2.0; + weights[i + 1] += dt / 2.0; + } + + let auc_variance: f64 = (0..n_timepoints) + .map(|j| { + let n_j = n_per_timepoint[j] as f64; + if n_j > 0.0 { + weights[j].powi(2) * variances[j] / n_j + } else { + 0.0 + } + }) + .sum(); + + let auc_se = auc_variance.sqrt(); + + // 95% CI using normal approximation (z = 1.96) + let z = 1.96; + let auc_ci_lower = auc - z * auc_se; + let auc_ci_upper = auc + z * auc_se; + + Some(SparsePKResult { + auc, + auc_se, + auc_ci_lower, + auc_ci_upper, + n_timepoints, + mean_concentrations, + n_per_timepoint, + times: group_times, + }) +} + +/// Compute population AUC from sparse/destructive sampling using a [`Data`] dataset +/// +/// Extracts all observations for the given `outeq` from every subject and occasion +/// in the dataset, then applies Bailer's method. +/// +/// # Arguments +/// * `data` - Population dataset with sparsely-sampled subjects +/// * `outeq` - Output equation index to extract observations for +/// * `time_tolerance` - Tolerance for grouping time points (None = exact matching) +/// +/// # Returns +/// `None` if fewer than 2 unique time points with data +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::prelude::*; +/// use pharmsol::nca::sparse::sparse_auc_from_data; +/// +/// let data: Data = /* load or build population data */; +/// let result = sparse_auc_from_data(&data, 0, None).unwrap(); +/// println!("Population AUC: {:.2} ± {:.2}", result.auc, result.auc_se); +/// ``` +pub fn sparse_auc_from_data( + data: &Data, + outeq: usize, + time_tolerance: Option, +) -> Option { + let (mut all_times, mut all_concs) = (Vec::new(), Vec::new()); + for subject in data.subjects() { + for occasion in subject.occasions() { + let (times, concs, _censoring) = occasion.get_observations(outeq); + all_times.extend(times); + all_concs.extend(concs); + } + } + sparse_auc(&all_times, &all_concs, time_tolerance) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sparse_auc_basic() { + // 4 time points, 3 subjects each + let times = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 4.0, 4.0, 4.0, 8.0, 8.0, 8.0]; + let concs = vec![ + 0.0, 0.0, 0.0, 10.0, 12.0, 11.0, 5.0, 4.0, 6.0, 1.0, 1.5, 1.2, + ]; + + let result = sparse_auc(×, &concs, None).unwrap(); + + assert_eq!(result.n_timepoints, 4); + assert!(result.auc > 0.0); + assert!(result.auc_se >= 0.0); + assert!(result.auc_ci_lower <= result.auc); + assert!(result.auc_ci_upper >= result.auc); + + // Manual: means = [0, 11, 5, ~1.23] + assert!((result.mean_concentrations[0] - 0.0).abs() < 1e-10); + assert!((result.mean_concentrations[1] - 11.0).abs() < 1e-10); + assert!((result.mean_concentrations[2] - 5.0).abs() < 1e-10); + } + + #[test] + fn test_sparse_auc_single_timepoint() { + let times = vec![0.0, 0.0]; + let concs = vec![10.0, 12.0]; + + assert!(sparse_auc(×, &concs, None).is_none()); + } + + #[test] + fn test_sparse_auc_with_tolerance() { + let times = vec![0.0, 0.01, 1.0, 0.99]; + let concs = vec![0.0, 0.0, 10.0, 12.0]; + + let result = sparse_auc(×, &concs, Some(0.05)).unwrap(); + assert_eq!(result.n_timepoints, 2); // Should have 2 groups, not 4 + } + + #[test] + fn test_sparse_auc_empty() { + assert!(sparse_auc(&[], &[], None).is_none()); + } + + #[test] + fn test_sparse_auc_known_values() { + // If all subjects have the same concentration at each time point, + // variance = 0, SE = 0, and AUC = simple trapezoidal + let times = vec![0.0, 0.0, 2.0, 2.0]; + let concs = vec![10.0, 10.0, 5.0, 5.0]; + + let result = sparse_auc(×, &concs, None).unwrap(); + + // AUC = (10 + 5) / 2 * 2 = 15 + assert!((result.auc - 15.0).abs() < 1e-10); + assert!((result.auc_se - 0.0).abs() < 1e-10); + } +} diff --git a/src/nca/summary.rs b/src/nca/summary.rs new file mode 100644 index 00000000..77a67129 --- /dev/null +++ b/src/nca/summary.rs @@ -0,0 +1,440 @@ +//! Population summary statistics for NCA results +//! +//! Computes descriptive statistics across multiple [`NCAResult`]s, +//! including geometric mean, CV%, and percentiles — standard PK reporting metrics. +//! +//! # Example +//! +//! ```rust,ignore +//! use pharmsol::nca::{summarize, NCAOptions, NCA}; +//! +//! let results: Vec = subjects.iter() +//! .flat_map(|s| s.nca_all(&NCAOptions::default())) +//! .filter_map(|r| r.ok()) +//! .collect(); +//! +//! let summary = summarize(&results); +//! println!("N subjects: {}", summary.n_subjects); +//! for p in &summary.parameters { +//! println!("{}: mean={:.2} CV%={:.1}", p.name, p.mean, p.cv_pct); +//! } +//! ``` + +use super::types::NCAResult; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Types +// ============================================================================ + +/// Descriptive statistics for a single NCA parameter across subjects +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParameterSummary { + /// Parameter name (matches keys from `NCAResult::to_params()`) + pub name: String, + /// Number of subjects with this parameter + pub n: usize, + /// Arithmetic mean + pub mean: f64, + /// Standard deviation + pub sd: f64, + /// Coefficient of variation (%) + pub cv_pct: f64, + /// Median + pub median: f64, + /// Minimum + pub min: f64, + /// Maximum + pub max: f64, + /// Geometric mean (NaN if any values ≤ 0) + pub geo_mean: f64, + /// Geometric CV% (NaN if any values ≤ 0) + pub geo_cv_pct: f64, + /// 5th percentile + pub p5: f64, + /// 25th percentile (Q1) + pub p25: f64, + /// 75th percentile (Q3) + pub p75: f64, + /// 95th percentile + pub p95: f64, +} + +/// Summary of NCA results across a population +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PopulationSummary { + /// Total number of NCA results summarized + pub n_subjects: usize, + /// Per-parameter descriptive statistics + pub parameters: Vec, +} + +// ============================================================================ +// Public API +// ============================================================================ + +/// Compute population summary statistics from a collection of NCA results +/// +/// Extracts each named parameter via [`NCAResult::to_params()`], then computes +/// descriptive statistics across all results that have that parameter. +/// +/// Parameters are returned in a stable alphabetical order. +pub fn summarize(results: &[NCAResult]) -> PopulationSummary { + if results.is_empty() { + return PopulationSummary { + n_subjects: 0, + parameters: Vec::new(), + }; + } + + // Collect all parameter names across all results + let mut all_params: std::collections::BTreeMap<&'static str, Vec> = + std::collections::BTreeMap::new(); + + for result in results { + let params = result.to_params(); + for (name, value) in params { + all_params.entry(name).or_default().push(value); + } + } + + // Compute summary for each parameter + let parameters: Vec = all_params + .into_iter() + .map(|(name, values)| compute_parameter_summary(name, &values)) + .collect(); + + PopulationSummary { + n_subjects: results.len(), + parameters, + } +} + +/// Generate a CSV string from a slice of NCA results +/// +/// The CSV has a header row containing `subject_id`, `occasion`, and all +/// parameter names (union across all results). Each subsequent row contains +/// one result. Missing parameters are left empty. +/// +/// # Example +/// +/// ```rust,ignore +/// let csv = pharmsol::nca::nca_to_csv(&results); +/// std::fs::write("nca_results.csv", csv).unwrap(); +/// ``` +pub fn nca_to_csv(results: &[NCAResult]) -> String { + if results.is_empty() { + return String::new(); + } + + // Collect all unique parameter names in stable order + let mut param_names: std::collections::BTreeSet<&'static str> = + std::collections::BTreeSet::new(); + let param_maps: Vec<_> = results + .iter() + .map(|r| { + let p = r.to_params(); + for name in p.keys() { + param_names.insert(name); + } + p + }) + .collect(); + + let ordered_names: Vec<&str> = param_names.into_iter().collect(); + + // Build CSV + let mut csv = String::new(); + + // Header + csv.push_str("subject_id,occasion"); + for name in &ordered_names { + csv.push(','); + csv.push_str(name); + } + csv.push('\n'); + + // Data rows + for (result, params) in results.iter().zip(param_maps.iter()) { + // Subject ID + match &result.subject_id { + Some(id) => csv.push_str(id), + None => csv.push_str("NA"), + } + csv.push(','); + + // Occasion + match result.occasion { + Some(occ) => csv.push_str(&occ.to_string()), + None => csv.push_str("NA"), + } + + // Parameters + for name in &ordered_names { + csv.push(','); + if let Some(val) = params.get(name) { + csv.push_str(&val.to_string()); + } + } + csv.push('\n'); + } + + csv +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +fn compute_parameter_summary(name: &str, values: &[f64]) -> ParameterSummary { + use statrs::statistics::{Data, Distribution, Max, Min, OrderStatistics}; + + let n = values.len(); + assert!(n > 0); + + let mut data = Data::new(values.to_vec()); + + let mean = data.mean().unwrap_or(f64::NAN); + let sd = if n > 1 { + data.std_dev().unwrap_or(0.0) + } else { + 0.0 + }; + let cv_pct = if mean.abs() > f64::EPSILON { + (sd / mean) * 100.0 + } else { + f64::NAN + }; + + let median = data.median(); + let min = data.min(); + let max = data.max(); + + // Geometric statistics (only valid for positive values) + let (geo_mean, geo_cv_pct) = if values.iter().all(|&v| v > 0.0) { + let log_values: Vec = values.iter().map(|v| v.ln()).collect(); + let log_data = Data::new(log_values); + let log_mean = log_data.mean().unwrap_or(f64::NAN); + let gm = log_mean.exp(); + + let log_var = log_data.variance().unwrap_or(0.0); + // Geometric CV% = sqrt(exp(s²) - 1) * 100 + let gcv = (log_var.exp() - 1.0).sqrt() * 100.0; + (gm, gcv) + } else { + (f64::NAN, f64::NAN) + }; + + ParameterSummary { + name: name.to_string(), + n, + mean, + sd, + cv_pct, + median, + min, + max, + geo_mean, + geo_cv_pct, + p5: data.percentile(5), + p25: data.percentile(25), + p75: data.percentile(75), + p95: data.percentile(95), + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::event::Route; + use crate::nca::types::*; + + fn make_result(subject_id: &str, cmax: f64, auc_last: f64, lambda_z: f64) -> NCAResult { + let half_life = std::f64::consts::LN_2 / lambda_z; + NCAResult { + subject_id: Some(subject_id.to_string()), + occasion: Some(0), + dose_amount: Some(100.0), + route: Some(Route::Extravascular), + infusion_duration: None, + exposure: ExposureParams { + cmax, + tmax: 1.0, + clast: cmax * 0.1, + tlast: 24.0, + tfirst: Some(0.5), + auc_last, + auc_inf_obs: Some(auc_last * 1.1), + auc_inf_pred: Some(auc_last * 1.12), + auc_pct_extrap_obs: Some(9.1), + auc_pct_extrap_pred: Some(10.7), + auc_partial: None, + aumc_last: None, + aumc_inf: None, + tlag: None, + cmax_dn: Some(cmax / 100.0), + auc_last_dn: Some(auc_last / 100.0), + auc_inf_dn: Some(auc_last * 1.1 / 100.0), + time_above_mic: None, + }, + terminal: Some(TerminalParams { + lambda_z, + half_life, + regression: Some(RegressionStats { + r_squared: 0.99, + adj_r_squared: 0.98, + corrxy: -0.995, + n_points: 5, + time_first: 4.0, + time_last: 24.0, + span_ratio: 3.0, + }), + mrt: Some(half_life * 1.44), + effective_half_life: Some(std::f64::consts::LN_2 * half_life * 1.44), + kel: Some(1.0 / (half_life * 1.44)), + }), + clearance: Some(ClearanceParams { + cl_f: 100.0 / (auc_last * 1.1), + vz_f: 100.0 / (auc_last * 1.1 * lambda_z), + vss: None, + }), + route_params: Some(RouteParams::Extravascular), + steady_state: None, + multi_dose: None, + quality: Quality { warnings: vec![] }, + } + } + + #[test] + fn test_summarize_basic() { + let results = vec![ + make_result("S1", 10.0, 100.0, 0.1), + make_result("S2", 20.0, 200.0, 0.15), + make_result("S3", 15.0, 150.0, 0.12), + ]; + + let summary = summarize(&results); + assert_eq!(summary.n_subjects, 3); + assert!(!summary.parameters.is_empty()); + + // Check cmax summary + let cmax = summary + .parameters + .iter() + .find(|p| p.name == "cmax") + .unwrap(); + assert_eq!(cmax.n, 3); + assert!((cmax.mean - 15.0).abs() < 1e-10); + assert_eq!(cmax.min, 10.0); + assert_eq!(cmax.max, 20.0); + assert_eq!(cmax.median, 15.0); + } + + #[test] + fn test_summarize_single_result() { + let results = vec![make_result("S1", 10.0, 100.0, 0.1)]; + + let summary = summarize(&results); + assert_eq!(summary.n_subjects, 1); + + let cmax = summary + .parameters + .iter() + .find(|p| p.name == "cmax") + .unwrap(); + assert_eq!(cmax.n, 1); + assert!((cmax.mean - 10.0).abs() < 1e-10); + assert_eq!(cmax.sd, 0.0); + assert_eq!(cmax.min, 10.0); + assert_eq!(cmax.max, 10.0); + } + + #[test] + fn test_summarize_empty() { + let summary = summarize(&[]); + assert_eq!(summary.n_subjects, 0); + assert!(summary.parameters.is_empty()); + } + + #[test] + fn test_summarize_geometric_stats() { + // Known values for geometric mean + let results = vec![ + make_result("S1", 10.0, 100.0, 0.1), + make_result("S2", 10.0, 100.0, 0.1), + ]; + + let summary = summarize(&results); + let cmax = summary + .parameters + .iter() + .find(|p| p.name == "cmax") + .unwrap(); + + // All same value → geo_mean = 10.0, geo_cv = 0% + assert!((cmax.geo_mean - 10.0).abs() < 1e-10); + assert!((cmax.geo_cv_pct - 0.0).abs() < 1e-10); + } + + #[test] + fn test_summarize_percentiles() { + // Create 5 results with known cmax values: 10, 20, 30, 40, 50 + let results: Vec = (1..=5) + .map(|i| make_result(&format!("S{}", i), i as f64 * 10.0, 100.0, 0.1)) + .collect(); + + let summary = summarize(&results); + let cmax = summary + .parameters + .iter() + .find(|p| p.name == "cmax") + .unwrap(); + + assert_eq!(cmax.n, 5); + assert!((cmax.mean - 30.0).abs() < 1e-10); + assert_eq!(cmax.median, 30.0); + assert_eq!(cmax.min, 10.0); + assert_eq!(cmax.max, 50.0); + } + + #[test] + fn test_summarize_parameters_sorted() { + let results = vec![make_result("S1", 10.0, 100.0, 0.1)]; + let summary = summarize(&results); + + // Parameters should be in alphabetical order (BTreeMap) + let names: Vec<&str> = summary.parameters.iter().map(|p| p.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted, "Parameters should be alphabetically sorted"); + } + + #[test] + fn test_nca_to_csv_basic() { + let results = vec![ + make_result("S1", 10.0, 100.0, 0.1), + make_result("S2", 20.0, 200.0, 0.15), + ]; + + let csv = nca_to_csv(&results); + + // Check header + let lines: Vec<&str> = csv.lines().collect(); + assert!(lines.len() >= 3, "Should have header + 2 data rows"); + assert!(lines[0].starts_with("subject_id,occasion")); + + // Check subject IDs appear + assert!(lines[1].starts_with("S1,")); + assert!(lines[2].starts_with("S2,")); + } + + #[test] + fn test_nca_to_csv_empty() { + let csv = nca_to_csv(&[]); + assert!(csv.is_empty()); + } +} diff --git a/src/nca/superposition.rs b/src/nca/superposition.rs new file mode 100644 index 00000000..051ce81f --- /dev/null +++ b/src/nca/superposition.rs @@ -0,0 +1,434 @@ +//! Single-dose to steady-state prediction via superposition +//! +//! Given a single-dose concentration-time profile and a dosing interval (τ), +//! predict the steady-state profile by summing shifted copies of the single-dose +//! profile, using the terminal phase (λz) to extrapolate beyond the observed data. +//! +//! This is a standard NCA technique for dose selection and steady-state prediction +//! without requiring actual multiple-dose study data. +//! +//! # Usage +//! +//! The simplest way is via the [`Superposition`] trait on [`Subject`]: +//! +//! ```rust,ignore +//! use pharmsol::prelude::*; +//! use pharmsol::nca::{NCAOptions, Superposition}; +//! +//! let result = subject.superposition(12.0, &NCAOptions::default(), None)?; +//! println!("Predicted Cmax_ss: {:.2}", result.cmax_ss); +//! ``` + +use crate::data::auc::auc as compute_auc; +use crate::data::event::{AUCMethod, BLQRule}; +use crate::data::observation::ObservationProfile; +use crate::nca::error::NCAError; +use crate::nca::traits::NCA; +use crate::nca::types::{NCAOptions, NCAResult}; +use crate::Subject; +use serde::{Deserialize, Serialize}; + +/// Result of a superposition prediction +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SuperpositionResult { + /// Time points at steady state (within one dosing interval) + pub times: Vec, + /// Predicted concentrations at steady state + pub concentrations: Vec, + /// Predicted Cmax at steady state + pub cmax_ss: f64, + /// Time of predicted Cmax at steady state + pub tmax_ss: f64, + /// Predicted Cmin at steady state (trough) + pub cmin_ss: f64, + /// Predicted AUC over one dosing interval at steady state + pub auc_tau_ss: f64, + /// Predicted average concentration + pub cavg_ss: f64, + /// Number of doses summed to reach steady state + pub n_doses: usize, + /// Predicted accumulation ratio (AUC_tau_ss / AUC_tau_single) + pub accumulation_ratio: f64, +} + +/// Predict steady-state concentrations by superposition of a single-dose profile +/// +/// The algorithm: +/// 1. For each evaluation time t in [0, τ], sum contributions from N previous doses +/// 2. Each dose contribution at time t from dose k is: C(t + k·τ) +/// 3. For times beyond the observed profile, extrapolate using: C_pred = Clast × exp(-λz × (t - Tlast)) +/// 4. Continue summing until the contribution from the next dose is negligible (< tolerance) +/// +/// # Arguments +/// * `profile` - Single-dose observation profile +/// * `lambda_z` - Terminal elimination rate constant (from NCA) +/// * `tau` - Dosing interval +/// * `n_eval_points` - Number of evaluation points within [0, τ] (default: use observed times) +/// +/// # Returns +/// `None` if `lambda_z` is not positive or profile is empty +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::nca::{superposition, NCAOptions, NCA, ObservationProfile}; +/// +/// let result = subject.nca(&NCAOptions::default())?; +/// if let Some(lz) = result.terminal.as_ref().map(|t| t.lambda_z) { +/// let profile = subject.filtered_observations(0, &BLQRule::Exclude)[0].as_ref().unwrap(); +/// let ss = superposition::predict(profile, lz, 12.0, None).unwrap(); +/// println!("Predicted Cmax_ss: {:.2}", ss.cmax_ss); +/// println!("Predicted Cmin_ss: {:.2}", ss.cmin_ss); +/// println!("Accumulation ratio: {:.2}", ss.accumulation_ratio); +/// } +/// ``` +pub fn predict( + profile: &ObservationProfile, + lambda_z: f64, + tau: f64, + n_eval_points: Option, +) -> Option { + if lambda_z <= 0.0 || !lambda_z.is_finite() || tau <= 0.0 || profile.is_empty() { + return None; + } + + let clast = profile.clast(); + let tlast = profile.tlast(); + + // Generate evaluation times within [0, tau] + let eval_times: Vec = match n_eval_points { + Some(n) if n >= 2 => (0..n).map(|i| i as f64 * tau / (n - 1) as f64).collect(), + _ => { + // Use observed times that fall within [0, tau], plus tau itself + let mut times: Vec = profile + .times + .iter() + .copied() + .filter(|&t| t >= 0.0 && t <= tau) + .collect(); + if times.is_empty() || (times.last().unwrap() - tau).abs() > 1e-10 { + times.push(tau); + } + if times[0] > 0.0 { + times.insert(0, 0.0); + } + times + } + }; + + // Tolerance for convergence: stop when dose contribution < this fraction of current total + let tolerance = 1e-10; + let max_doses = 1000; // Safety limit + + let mut ss_concentrations = vec![0.0_f64; eval_times.len()]; + let mut n_doses = 0; + + for dose_k in 0..max_doses { + let mut max_contribution = 0.0_f64; + + for (i, &t) in eval_times.iter().enumerate() { + // Time since this dose: t + k * tau + let t_since_dose = t + dose_k as f64 * tau; + let conc = concentration_at_time(profile, clast, tlast, lambda_z, t_since_dose); + ss_concentrations[i] += conc; + max_contribution = max_contribution.max(conc); + } + + n_doses = dose_k + 1; + + // Check convergence: if the maximum contribution from this dose is negligible + if dose_k > 0 + && max_contribution + < tolerance * ss_concentrations.iter().cloned().fold(0.0_f64, f64::max) + { + break; + } + } + + // Compute derived parameters + let (cmax_idx, cmax_ss) = ss_concentrations + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .map(|(i, &v)| (i, v)) + .unwrap_or((0, 0.0)); + + let tmax_ss = eval_times[cmax_idx]; + + let cmin_ss = ss_concentrations + .iter() + .copied() + .filter(|&c| c > 0.0) + .min_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); + + // AUC_tau using trapezoidal rule + let auc_tau_ss = trapezoidal_auc(&eval_times, &ss_concentrations); + + let cavg_ss = if tau > 0.0 { auc_tau_ss / tau } else { 0.0 }; + + // Single-dose AUC over tau for accumulation ratio + let single_dose_auc_tau = + trapezoidal_auc_from_profile(profile, clast, tlast, lambda_z, tau, &eval_times); + let accumulation_ratio = if single_dose_auc_tau > 0.0 { + auc_tau_ss / single_dose_auc_tau + } else { + f64::NAN + }; + + Some(SuperpositionResult { + times: eval_times, + concentrations: ss_concentrations, + cmax_ss, + tmax_ss, + cmin_ss, + auc_tau_ss, + cavg_ss, + n_doses, + accumulation_ratio, + }) +} + +/// Get concentration at a specific time from the profile, with extrapolation +fn concentration_at_time( + profile: &ObservationProfile, + clast: f64, + tlast: f64, + lambda_z: f64, + time: f64, +) -> f64 { + if time < 0.0 { + return 0.0; + } + + if time <= tlast { + // Within observation range: interpolate + profile.interpolate(time) + } else { + // Beyond observed data: extrapolate using terminal phase + clast * (-lambda_z * (time - tlast)).exp() + } +} + +/// Simple trapezoidal AUC — delegates to data::auc::auc +fn trapezoidal_auc(times: &[f64], concentrations: &[f64]) -> f64 { + compute_auc(times, concentrations, &AUCMethod::Linear) +} + +/// Single-dose AUC over [0, tau] from profile with extrapolation +fn trapezoidal_auc_from_profile( + profile: &ObservationProfile, + clast: f64, + tlast: f64, + lambda_z: f64, + tau: f64, + eval_times: &[f64], +) -> f64 { + let concs: Vec = eval_times + .iter() + .map(|&t| concentration_at_time(profile, clast, tlast, lambda_z, t.min(tau))) + .collect(); + trapezoidal_auc(eval_times, &concs) +} + +/// Convenience wrapper: run superposition using an existing [`NCAResult`]. +/// +/// Extracts `lambda_z` from the terminal phase and delegates to [`predict()`]. +/// +/// # Arguments +/// * `profile` - Observation profile (single-dose) +/// * `nca_result` - NCA result containing terminal phase parameters +/// * `tau` - Dosing interval +/// * `n_eval_points` - Number of evaluation points (None = use observed times) +/// +/// # Errors +/// Returns [`NCAError::LambdaZFailed`] if the NCA result has no terminal phase. +pub fn predict_from_nca( + profile: &ObservationProfile, + nca_result: &NCAResult, + tau: f64, + n_eval_points: Option, +) -> Result { + let lambda_z = nca_result + .terminal + .as_ref() + .map(|t| t.lambda_z) + .ok_or_else(|| NCAError::LambdaZFailed { + reason: "λz not estimable; cannot perform superposition".to_string(), + })?; + + predict(profile, lambda_z, tau, n_eval_points).ok_or_else(|| NCAError::InvalidParameter { + param: "superposition".to_string(), + value: "prediction returned None (check lambda_z and tau)".to_string(), + }) +} + +/// Extension trait for running superposition directly from a [`Subject`] +/// +/// Chains NCA → λz extraction → superposition in a single call. +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::prelude::*; +/// use pharmsol::nca::{NCAOptions, Superposition}; +/// +/// let subject = Subject::builder("pt1") +/// .bolus(0.0, 100.0, 0) +/// .observation(0.0, 10.0, 0) +/// .observation(1.0, 9.0, 0) +/// .observation(4.0, 6.0, 0) +/// .observation(12.0, 3.0, 0) +/// .observation(24.0, 0.9, 0) +/// .build(); +/// +/// let ss = subject.superposition(12.0, &NCAOptions::default(), None)?; +/// println!("Cmax_ss: {:.2}, Cmin_ss: {:.2}", ss.cmax_ss, ss.cmin_ss); +/// ``` +pub trait Superposition { + /// Predict steady-state profile via superposition + /// + /// Performs NCA to estimate λz, then runs superposition to predict + /// the steady-state concentration-time profile. + /// + /// # Arguments + /// * `tau` - Dosing interval + /// * `options` - NCA options (used for λz estimation; `outeq` is read from here) + /// * `n_eval_points` - Number of evaluation points (None = use observed times) + fn superposition( + &self, + tau: f64, + options: &NCAOptions, + n_eval_points: Option, + ) -> Result; +} + +impl Superposition for Subject { + fn superposition( + &self, + tau: f64, + options: &NCAOptions, + n_eval_points: Option, + ) -> Result { + let outeq = options.outeq; + // Run NCA to get lambda_z + let nca_result = self.nca(options)?; + + let lambda_z = nca_result + .terminal + .as_ref() + .map(|t| t.lambda_z) + .ok_or_else(|| NCAError::LambdaZFailed { + reason: "λz not estimable; cannot perform superposition".to_string(), + })?; + + // Get profile from first occasion + let occ = self + .occasions() + .first() + .ok_or_else(|| NCAError::InvalidParameter { + param: "occasion".to_string(), + value: "no occasions found".to_string(), + })?; + let profile = ObservationProfile::from_occasion(occ, outeq, &BLQRule::Exclude)?; + + predict(&profile, lambda_z, tau, n_eval_points).ok_or_else(|| NCAError::InvalidParameter { + param: "superposition".to_string(), + value: "prediction returned None (check lambda_z and tau)".to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::builder::SubjectBuilderExt; + use crate::data::event::BLQRule; + use crate::Subject; + + #[test] + fn test_superposition_basic() { + // Simple exponential decay: C = 10 * exp(-0.1 * t) + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 10.0, 0) + .observation(1.0, 9.048, 0) // 10 * exp(-0.1) + .observation(2.0, 8.187, 0) // 10 * exp(-0.2) + .observation(4.0, 6.703, 0) // 10 * exp(-0.4) + .observation(8.0, 4.493, 0) // 10 * exp(-0.8) + .observation(12.0, 3.012, 0) // 10 * exp(-1.2) + .observation(24.0, 0.907, 0) // 10 * exp(-2.4) + .build(); + + let occ = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + + let lambda_z = 0.1; + let tau = 12.0; + let result = predict(&profile, lambda_z, tau, Some(25)).unwrap(); + + assert!( + result.cmax_ss > 10.0, + "SS Cmax should be > single dose Cmax due to accumulation" + ); + assert!(result.cmin_ss > 0.0, "SS Cmin should be positive"); + assert!( + result.accumulation_ratio > 1.0, + "Accumulation ratio should be > 1" + ); + assert!( + result.n_doses > 1, + "Should require multiple doses to converge" + ); + } + + #[test] + fn test_superposition_invalid_inputs() { + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 10.0, 0) + .observation(1.0, 5.0, 0) + .build(); + + let occ = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + + assert!(predict(&profile, -0.1, 12.0, None).is_none()); + assert!(predict(&profile, 0.1, 0.0, None).is_none()); + assert!(predict(&profile, 0.0, 12.0, None).is_none()); + } + + #[test] + fn test_superposition_theoretical_accumulation() { + // For a one-compartment IV model with first-order elimination: + // Theoretical accumulation factor = 1 / (1 - exp(-λz * τ)) + let lambda_z: f64 = 0.1; + let tau: f64 = 8.0; + let theoretical_af = 1.0 / (1.0 - (-lambda_z * tau).exp()); + + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 10.0, 0) + .observation(1.0, 9.048, 0) + .observation(2.0, 8.187, 0) + .observation(4.0, 6.703, 0) + .observation(8.0, 4.493, 0) + .observation(12.0, 3.012, 0) + .observation(24.0, 0.907, 0) + .build(); + + let occ = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occ, 0, &BLQRule::Exclude).unwrap(); + + let result = predict(&profile, lambda_z, tau, Some(50)).unwrap(); + + // Accumulation ratio should be close to theoretical + let tol = 0.05; // 5% tolerance for interpolation effects + assert!( + (result.accumulation_ratio - theoretical_af).abs() / theoretical_af < tol, + "Accumulation ratio {:.3} should be close to theoretical {:.3}", + result.accumulation_ratio, + theoretical_af + ); + } +} diff --git a/src/nca/tests.rs b/src/nca/tests.rs new file mode 100644 index 00000000..9cedfc02 --- /dev/null +++ b/src/nca/tests.rs @@ -0,0 +1,925 @@ +//! Comprehensive tests for NCA module +//! +//! Tests cover all major NCA parameters and edge cases. +//! All tests use Subject::builder() as the single entry point. + +use crate::data::Subject; +use crate::nca::*; +use crate::Data; +use crate::SubjectBuilderExt; + +// ============================================================================ +// Test subject builders +// ============================================================================ + +/// Create a typical single-dose oral PK subject +fn single_dose_oral() -> Subject { + Subject::builder("test") + .bolus(0.0, 100.0, 0) // 100 mg to depot (extravascular) + .observation(0.0, 0.0, 0) + .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) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build() +} + +/// Create an IV bolus subject (high C0, dose to central) +fn iv_bolus_subject() -> Subject { + Subject::builder("test") + .bolus(0.0, 500.0, 1) // 500 mg to central (IV) + .observation(0.0, 100.0, 0) + .observation(0.25, 75.0, 0) + .observation(0.5, 56.0, 0) + .observation(1.0, 32.0, 0) + .observation(2.0, 10.0, 0) + .observation(4.0, 3.0, 0) + .observation(8.0, 0.9, 0) + .observation(12.0, 0.3, 0) + .build() +} + +/// Create an IV infusion subject +fn iv_infusion_subject() -> Subject { + Subject::builder("test") + .infusion(0.0, 100.0, 1, 0.5) // 100 mg over 0.5h to central + .observation(0.0, 0.0, 0) + .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) + .observation(12.0, 1.0, 0) + .observation(24.0, 0.25, 0) + .build() +} + +/// Create a steady-state profile subject +fn steady_state_subject() -> Subject { + Subject::builder("test") + .bolus(0.0, 100.0, 0) // 100 mg oral + .observation(0.0, 5.0, 0) + .observation(1.0, 15.0, 0) + .observation(2.0, 12.0, 0) + .observation(4.0, 8.0, 0) + .observation(6.0, 6.0, 0) + .observation(8.0, 5.5, 0) + .observation(12.0, 5.0, 0) + .build() +} + +/// Create a subject with BLQ values +fn blq_subject() -> Subject { + use crate::Censor; + + Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.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) + .observation(12.0, 0.5, 0) + .censored_observation(24.0, 0.1, 0, Censor::BLOQ) // BLQ with LOQ=0.1 + .build() +} + +/// Create a minimal subject (no dose) +fn no_dose_subject() -> Subject { + Subject::builder("test") + .observation(0.0, 0.0, 0) + .observation(1.0, 10.0, 0) + .observation(2.0, 8.0, 0) + .observation(4.0, 4.0, 0) + .build() +} + +// ============================================================================ +// Basic NCA parameter tests +// ============================================================================ + +#[test] +fn test_nca_basic_exposure() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Check Cmax/Tmax + assert_eq!(result.exposure.cmax, 10.0, "Cmax should be 10.0"); + assert_eq!(result.exposure.tmax, 1.0, "Tmax should be 1.0"); + + // Check Clast/Tlast + assert_eq!(result.exposure.clast, 0.25, "Clast should be 0.25"); + assert_eq!(result.exposure.tlast, 24.0, "Tlast should be 24.0"); + + // AUClast should be positive + assert!(result.exposure.auc_last > 0.0, "AUClast should be positive"); +} + +#[test] +fn test_nca_with_dose() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should have clearance parameters if lambda-z was estimated + if let Some(ref cl) = result.clearance { + assert!(cl.cl_f > 0.0, "CL/F should be positive"); + assert!(cl.vz_f > 0.0, "Vz/F should be positive"); + } +} + +#[test] +fn test_nca_without_dose() { + let subject = no_dose_subject(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Exposure should still be computed + assert!(result.exposure.cmax > 0.0); + // But clearance should be None (no dose) + assert!(result.clearance.is_none()); +} + +#[test] +fn test_nca_terminal_phase() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Check terminal phase was estimated + assert!( + result.terminal.is_some(), + "Terminal phase should be estimated" + ); + + if let Some(ref term) = result.terminal { + assert!(term.lambda_z > 0.0, "Lambda-z should be positive"); + assert!(term.half_life > 0.0, "Half-life should be positive"); + + // Half-life relationship + let expected_hl = std::f64::consts::LN_2 / term.lambda_z; + assert!( + (term.half_life - expected_hl).abs() < 1e-10, + "Half-life = ln(2)/lambda_z" + ); + } +} + +// ============================================================================ +// AUC calculation tests +// ============================================================================ + +#[test] +fn test_auc_linear_method() { + let subject = single_dose_oral(); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + assert!(result.exposure.auc_last > 0.0); +} + +#[test] +fn test_auc_linuplogdown_method() { + let subject = single_dose_oral(); + let options = NCAOptions::default().with_auc_method(AUCMethod::LinUpLogDown); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + assert!(result.exposure.auc_last > 0.0); +} + +#[test] +fn test_auc_methods_differ() { + let subject = single_dose_oral(); + + let linear = NCAOptions::default().with_auc_method(AUCMethod::Linear); + let logdown = NCAOptions::default().with_auc_method(AUCMethod::LinUpLogDown); + + let result_linear = subject.nca_all(&linear)[0] + .as_ref() + .unwrap() + .exposure + .auc_last; + let result_logdown = subject.nca_all(&logdown)[0] + .as_ref() + .unwrap() + .exposure + .auc_last; + + // Methods should give slightly different results + assert!( + result_linear != result_logdown, + "Different AUC methods should give different results" + ); +} + +// ============================================================================ +// Route-specific tests +// ============================================================================ + +#[test] +fn test_iv_bolus_route() { + let subject = iv_bolus_subject(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should have IV bolus parameters + assert!( + matches!(result.route_params, Some(RouteParams::IVBolus(_))), + "IV bolus parameters should be present" + ); + + if let Some(RouteParams::IVBolus(ref bolus)) = result.route_params { + assert!(bolus.c0 > 0.0, "C0 should be positive"); + assert!(bolus.vd > 0.0, "Vd should be positive"); + } +} + +#[test] +fn test_iv_infusion_route() { + let subject = iv_infusion_subject(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should have IV infusion parameters + assert!( + matches!(result.route_params, Some(RouteParams::IVInfusion(_))), + "IV infusion parameters should be present" + ); + + if let Some(RouteParams::IVInfusion(ref infusion)) = result.route_params { + assert_eq!( + infusion.infusion_duration, 0.5, + "Infusion duration should be 0.5" + ); + } +} + +#[test] +fn test_extravascular_route() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Tlag should be in exposure params (may be None if no lag detected) + // For extravascular, should have Extravascular route params + assert!( + matches!(result.route_params, Some(RouteParams::Extravascular)), + "Extravascular route should not have IV-specific params" + ); +} + +// ============================================================================ +// Steady-state tests +// ============================================================================ + +#[test] +fn test_steady_state_parameters() { + let subject = steady_state_subject(); + let options = NCAOptions::default().with_tau(12.0); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should have steady-state parameters + assert!( + result.steady_state.is_some(), + "Steady-state parameters should be present" + ); + + if let Some(ref ss) = result.steady_state { + assert_eq!(ss.tau, 12.0, "Tau should be 12.0"); + assert!(ss.auc_tau > 0.0, "AUCtau should be positive"); + assert!(ss.cmin > 0.0, "Cmin should be positive"); + assert!(ss.cavg > 0.0, "Cavg should be positive"); + assert!(ss.fluctuation > 0.0, "Fluctuation should be positive"); + } +} + +// ============================================================================ +// BLQ handling tests +// ============================================================================ + +#[test] +fn test_blq_exclude() { + let subject = blq_subject(); + let options = NCAOptions::default().with_blq_rule(BLQRule::Exclude); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Tlast should be at t=12 (last non-BLQ point) + assert_eq!(result.exposure.tlast, 12.0, "Tlast should exclude BLQ"); +} + +#[test] +fn test_blq_zero() { + let subject = blq_subject(); + let options = NCAOptions::default().with_blq_rule(BLQRule::Zero); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should include the BLQ points as zeros + assert!(result.exposure.auc_last > 0.0); +} + +#[test] +fn test_blq_loq_over_2() { + let subject = blq_subject(); + let options = NCAOptions::default().with_blq_rule(BLQRule::LoqOver2); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should include the BLQ points as LOQ/2 (0.1 / 2 = 0.05) + assert!(result.exposure.auc_last > 0.0); +} + +// ============================================================================ +// Lambda-z estimation tests +// ============================================================================ + +#[test] +fn test_lambda_z_auto_selection() { + let subject = single_dose_oral(); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + method: LambdaZMethod::AdjR2, + ..Default::default() + }); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Should have terminal phase + assert!(result.terminal.is_some()); + + if let Some(ref term) = result.terminal { + assert!(term.regression.is_some()); + if let Some(ref reg) = term.regression { + assert!(reg.r_squared > 0.9, "R² should be high for good fit"); + assert!(reg.n_points >= 3, "Should use at least 3 points"); + } + } +} + +#[test] +fn test_lambda_z_manual_points() { + let subject = single_dose_oral(); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + method: LambdaZMethod::Manual(4), + ..Default::default() + }); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + if let Some(ref term) = result.terminal { + if let Some(ref reg) = term.regression { + assert_eq!(reg.n_points, 4, "Should use exactly 4 points"); + } + } +} + +// ============================================================================ +// Edge case tests +// ============================================================================ + +#[test] +fn test_insufficient_observations() { + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(1.0, 10.0, 0) + .build(); + + let results = subject.nca_all(&NCAOptions::default()); + // Should fail with insufficient data + assert!( + results[0].is_err(), + "Single observation should return error" + ); +} + +#[test] +fn test_all_zero_concentrations() { + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .observation(1.0, 0.0, 0) + .observation(2.0, 0.0, 0) + .observation(4.0, 0.0, 0) + .build(); + + let results = subject.nca_all(&NCAOptions::default()); + assert!(results[0].is_err(), "All zero concentrations should fail"); +} + +// ============================================================================ +// Quality/Warning tests +// ============================================================================ + +#[test] +fn test_quality_warnings_lambda_z() { + // Profile with too few points for lambda-z + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .observation(1.0, 10.0, 0) + .observation(2.0, 8.0, 0) + .build(); + + let results = subject.nca_all(&NCAOptions::default()); + let result = results[0].as_ref().unwrap(); + + // Should have lambda-z warning + assert!( + result + .quality + .warnings + .iter() + .any(|w| matches!(w, Warning::LambdaZNotEstimable)), + "Should warn about lambda-z" + ); +} + +// ============================================================================ +// Result conversion tests +// ============================================================================ + +#[test] +fn test_result_to_params() { + let subject = single_dose_oral(); + let results = subject.nca_all(&NCAOptions::default()); + let result = results[0].as_ref().unwrap(); + + let params = result.to_params(); + + // Check key parameters are present + assert!(params.contains_key("cmax")); + assert!(params.contains_key("tmax")); + assert!(params.contains_key("auc_last")); +} + +#[test] +fn test_result_display() { + let subject = single_dose_oral(); + let results = subject.nca_all(&NCAOptions::default()); + let result = results[0].as_ref().unwrap(); + + let display = format!("{}", result); + assert!(display.contains("Cmax"), "Display should contain Cmax"); + assert!(display.contains("AUC"), "Display should contain AUC"); +} + +// ============================================================================ +// Subject/Occasion identification tests +// ============================================================================ + +#[test] +fn test_result_subject_id() { + let subject = Subject::builder("patient_001") + .bolus(0.0, 100.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 results = subject.nca_all(&NCAOptions::default()); + let result = results[0].as_ref().unwrap(); + + assert_eq!(result.subject_id.as_deref(), Some("patient_001")); + assert_eq!(result.occasion, Some(0)); +} + +// ============================================================================ +// Presets tests +// ============================================================================ + +#[test] +fn test_bioequivalence_preset() { + let options = NCAOptions::bioequivalence(); + assert_eq!(options.lambda_z.min_r_squared, 0.90); + assert_eq!(options.max_auc_extrap_pct, 20.0); +} + +#[test] +fn test_sparse_preset() { + let options = NCAOptions::sparse(); + assert_eq!(options.lambda_z.min_r_squared, 0.80); + assert_eq!(options.max_auc_extrap_pct, 30.0); +} + +// ============================================================================ +// Partial AUC tests +// ============================================================================ + +#[test] +fn test_partial_auc_interval() { + let subject = single_dose_oral(); + let options = NCAOptions::default().with_auc_interval(0.0, 4.0); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Partial AUC should be calculated + assert!( + result.exposure.auc_partial.is_some(), + "Partial AUC should be computed when interval specified" + ); + + let auc_partial = result.exposure.auc_partial.unwrap(); + assert!(auc_partial > 0.0, "Partial AUC should be positive"); + + // Partial AUC (0-4h) should be less than AUClast (0-24h) + assert!( + auc_partial < result.exposure.auc_last, + "Partial AUC should be less than AUClast" + ); +} + +#[test] +fn test_positional_blq_rule() { + use crate::Censor; + + // Create subject with BLQ at start, middle, and end + let subject = Subject::builder("test") + .bolus(0.0, 100.0, 0) + .censored_observation(0.0, 0.1, 0, Censor::BLOQ) // First - keep as 0 + .observation(1.0, 10.0, 0) + .censored_observation(2.0, 0.1, 0, Censor::BLOQ) // Middle - drop + .observation(4.0, 4.0, 0) + .observation(8.0, 2.0, 0) + .censored_observation(12.0, 0.1, 0, Censor::BLOQ) // Last - keep as 0 + .build(); + + // With positional BLQ handling + let options = NCAOptions::default().with_blq_rule(BLQRule::Positional); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + + // Middle BLQ at t=2 should be dropped, but first and last kept as 0 (PKNCA behavior) + // With last BLQ kept as 0 (not LOQ), tlast remains at 8.0 (last positive conc) + assert_eq!(result.exposure.cmax, 10.0, "Cmax should be 10.0"); + // tlast is the last time with positive concentration (8.0), the BLQ at 12 is 0 + assert_eq!( + result.exposure.tlast, 8.0, + "Tlast should be 8.0 (last positive concentration)" + ); + assert_eq!( + result.exposure.clast, 2.0, + "Clast should be 2.0 (last positive value)" + ); +} + +// ============================================================================ +// Lambda-z Candidates API tests +// ============================================================================ + +#[test] +fn test_lambda_z_candidates_returns_multiple() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + let auc_last = result.exposure.auc_last; + + // Get ObservationProfile for the first occasion + let occasion = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occasion, 0, &options.blq_rule).unwrap(); + + let candidates = lambda_z_candidates(&profile, &options.lambda_z, auc_last); + assert!( + candidates.len() >= 2, + "Should produce multiple candidates, got {}", + candidates.len() + ); + + // Exactly one should be selected + let selected_count = candidates.iter().filter(|c| c.is_selected).count(); + assert_eq!( + selected_count, 1, + "Exactly one candidate should be selected" + ); +} + +#[test] +fn test_lambda_z_candidates_selected_matches_nca_result() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let result = results[0].as_ref().unwrap(); + let auc_last = result.exposure.auc_last; + + let occasion = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occasion, 0, &options.blq_rule).unwrap(); + + let candidates = lambda_z_candidates(&profile, &options.lambda_z, auc_last); + let selected = candidates.iter().find(|c| c.is_selected).unwrap(); + + // Selected candidate's lambda_z should match what NCA computed + let terminal = result.terminal.as_ref().unwrap(); + let rel_diff = (selected.lambda_z - terminal.lambda_z).abs() / terminal.lambda_z; + assert!( + rel_diff < 1e-10, + "Selected λz ({}) should match NCA result ({})", + selected.lambda_z, + terminal.lambda_z + ); + + // Half-life should also match + let hl_diff = (selected.half_life - terminal.half_life).abs() / terminal.half_life; + assert!( + hl_diff < 1e-10, + "Selected t½ ({}) should match NCA result ({})", + selected.half_life, + terminal.half_life + ); +} + +#[test] +fn test_lambda_z_candidates_all_have_positive_lambda_z() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let auc_last = results[0].as_ref().unwrap().exposure.auc_last; + + let occasion = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occasion, 0, &options.blq_rule).unwrap(); + + let candidates = lambda_z_candidates(&profile, &options.lambda_z, auc_last); + for c in &candidates { + assert!(c.lambda_z > 0.0, "λz must be positive, got {}", c.lambda_z); + assert!( + c.half_life > 0.0, + "t½ must be positive, got {}", + c.half_life + ); + assert!(c.n_points >= 3, "Must have at least 3 points"); + assert!(c.r_squared >= 0.0 && c.r_squared <= 1.0, "R² out of range"); + } +} + +#[test] +fn test_lambda_z_candidates_empty_for_insufficient_points() { + // Subject with too few observations for terminal regression + let subject = Subject::builder("short") + .bolus(0.0, 100.0, 0) + .observation(0.0, 0.0, 0) + .observation(1.0, 10.0, 0) + .observation(2.0, 5.0, 0) + .build(); + + let options = NCAOptions::default(); + let occasion = &subject.occasions()[0]; + + if let Ok(profile) = ObservationProfile::from_occasion(occasion, 0, &options.blq_rule) { + let candidates = lambda_z_candidates(&profile, &options.lambda_z, 10.0); + // Either empty or no selected candidate (not enough points after Cmax) + let selected = candidates.iter().filter(|c| c.is_selected).count(); + assert!( + candidates.is_empty() || selected == 0, + "Should have no selected candidate with insufficient terminal points" + ); + } +} + +#[test] +fn test_lambda_z_candidates_span_ratio_and_extrap() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let results = subject.nca_all(&options); + let auc_last = results[0].as_ref().unwrap().exposure.auc_last; + + let occasion = &subject.occasions()[0]; + let profile = ObservationProfile::from_occasion(occasion, 0, &options.blq_rule).unwrap(); + + let candidates = lambda_z_candidates(&profile, &options.lambda_z, auc_last); + for c in &candidates { + // span_ratio = (end_time - start_time) / half_life + let expected_span = (c.end_time - c.start_time) / c.half_life; + let diff = (c.span_ratio - expected_span).abs(); + assert!( + diff < 1e-10, + "Span ratio mismatch: {} vs expected {}", + c.span_ratio, + expected_span + ); + + // auc_inf should be >= auc_last + assert!( + c.auc_inf >= auc_last, + "AUC∞ ({}) should be >= AUClast ({})", + c.auc_inf, + auc_last + ); + + // extrap pct should be 0..100 + assert!( + c.auc_pct_extrap >= 0.0 && c.auc_pct_extrap <= 100.0, + "Extrap % ({}) out of range", + c.auc_pct_extrap + ); + } +} + +// ============================================================================ +// Phase 8: nca() / nca_all() and to_row() tests +// ============================================================================ + +#[test] +fn test_nca_returns_single_result() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let result = subject.nca(&options); + assert!(result.is_ok(), "nca() should succeed for a valid subject"); + let r = result.unwrap(); + assert!(r.exposure.cmax > 0.0); + assert_eq!(r.subject_id.as_deref(), Some("test")); +} + +#[test] +fn test_nca_matches_nca_all_vec() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + + let first = subject.nca(&options).unwrap(); + let vec_result = subject.nca_all(&options); + let vec_first = vec_result[0].as_ref().unwrap(); + + assert!((first.exposure.cmax - vec_first.exposure.cmax).abs() < 1e-10); + assert!((first.exposure.auc_last - vec_first.exposure.auc_last).abs() < 1e-10); +} + +#[test] +fn test_nca_error_on_empty_outeq() { + // A subject with no observations for outeq=99 + let subject = Subject::builder("empty") + .bolus(0.0, 100.0, 0) + .observation(1.0, 10.0, 0) + .build(); + let options = NCAOptions::default().with_outeq(99); + let result = subject.nca(&options); + assert!(result.is_err(), "nca() should fail for missing outeq"); +} + +#[test] +fn test_to_row_contains_expected_keys() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let result = subject.nca(&options).unwrap(); + let row = result.to_row(); + + let keys: Vec<&str> = row.iter().map(|(k, _)| *k).collect(); + assert!(keys.contains(&"cmax"), "to_row should contain cmax"); + assert!(keys.contains(&"tmax"), "to_row should contain tmax"); + assert!(keys.contains(&"auc_last"), "to_row should contain auc_last"); + assert!(keys.contains(&"clast"), "to_row should contain clast"); + assert!(keys.contains(&"tlast"), "to_row should contain tlast"); +} + +#[test] +fn test_to_row_values_match_result() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let result = subject.nca(&options).unwrap(); + let row = result.to_row(); + + let find = + |key: &str| -> Option { row.iter().find(|(k, _)| *k == key).and_then(|(_, v)| *v) }; + + assert!((find("cmax").unwrap() - result.exposure.cmax).abs() < 1e-10); + assert!((find("tmax").unwrap() - result.exposure.tmax).abs() < 1e-10); + assert!((find("auc_last").unwrap() - result.exposure.auc_last).abs() < 1e-10); +} + +#[test] +fn test_to_row_terminal_params_present_when_lambda_z_succeeds() { + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let result = subject.nca(&options).unwrap(); + + // Verify terminal phase succeeded + assert!( + result.terminal.is_some(), + "Expected terminal phase to succeed" + ); + + let row = result.to_row(); + let find = + |key: &str| -> Option { row.iter().find(|(k, _)| *k == key).and_then(|(_, v)| *v) }; + + assert!( + find("lambda_z").is_some(), + "to_row should have lambda_z when terminal succeeds" + ); + assert!( + find("half_life").is_some(), + "to_row should have half_life when terminal succeeds" + ); +} + +// ============================================================================ +// Phase 9: ObservationProfile NCA tests +// ============================================================================ + +#[test] +fn test_nca_with_dose_matches_subject() { + use crate::data::observation::ObservationProfile; + use crate::data::Route; + + let subject = single_dose_oral(); + let options = NCAOptions::default(); + let subject_result = subject.nca(&options).unwrap(); + + // Build a profile from the same raw data as single_dose_oral() + let times = vec![0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let concs = vec![0.0, 5.0, 10.0, 8.0, 4.0, 2.0, 1.0, 0.25]; + let profile = ObservationProfile::from_raw(×, &concs).unwrap(); + let profile_result = profile + .nca_with_dose(Some(100.0), Route::Extravascular, None, &options) + .unwrap(); + + // Cmax and tmax should match exactly (same data, same filtering) + assert!( + (subject_result.exposure.cmax - profile_result.exposure.cmax).abs() < 1e-10, + "Cmax should match" + ); + assert!( + (subject_result.exposure.tmax - profile_result.exposure.tmax).abs() < 1e-10, + "Tmax should match" + ); + // AUClast should be very close (tlag may differ slightly) + assert!( + (subject_result.exposure.auc_last - profile_result.exposure.auc_last).abs() + / subject_result.exposure.auc_last + < 0.01, + "AUClast should be within 1%" + ); +} + +#[test] +fn test_nca_with_dose_no_dose() { + use crate::data::observation::ObservationProfile; + use crate::data::Route; + + let profile = + ObservationProfile::from_raw(&[0.0, 1.0, 4.0, 8.0], &[0.0, 10.0, 5.0, 1.0]).unwrap(); + let options = NCAOptions::default(); + let result = profile + .nca_with_dose(None, Route::Extravascular, None, &options) + .unwrap(); + + // Should work but dose-normalized params should be None + assert!(result.exposure.cmax > 0.0); + assert!(result.exposure.cmax_dn.is_none()); +} + +// ============================================================================ +// Phase 10: Population error isolation (Task 4.5) +// ============================================================================ + +#[test] +fn test_population_error_isolation() { + // Create a population: one good subject, one with no observations (will fail) + let good = Subject::builder("good") + .bolus(0.0, 100.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 bad = Subject::builder("bad") + .bolus(0.0, 100.0, 0) + // No observations → will fail + .build(); + + let data = Data::new(vec![good, bad]); + let opts = NCAOptions::default(); + let grouped = data.nca_grouped(&opts); + + assert_eq!(grouped.len(), 2); + + // Good subject + let good_result = grouped.iter().find(|r| r.subject_id == "good").unwrap(); + assert_eq!(good_result.successes().len(), 1); + assert_eq!(good_result.errors().len(), 0); + + // Bad subject + let bad_result = grouped.iter().find(|r| r.subject_id == "bad").unwrap(); + assert_eq!(bad_result.successes().len(), 0); + assert_eq!(bad_result.errors().len(), 1); + + // nca_all() should have both success and failure + let all = data.nca_all(&opts); + let ok_count = all.iter().filter(|r| r.is_ok()).count(); + let err_count = all.iter().filter(|r| r.is_err()).count(); + assert_eq!(ok_count, 1); + assert_eq!(err_count, 1); +} diff --git a/src/nca/traits.rs b/src/nca/traits.rs new file mode 100644 index 00000000..c65ef499 --- /dev/null +++ b/src/nca/traits.rs @@ -0,0 +1,283 @@ +//! Extension traits for NCA analysis on pharmsol data types +//! +//! The [`NCA`] trait adds full non-compartmental analysis to [`Data`], [`Subject`], +//! and [`Occasion`] without creating a dependency from `data` → `nca`. +//! +//! +//! ```rust,ignore +//! use pharmsol::prelude::*; +//! +//! let result = subject.nca(&NCAOptions::default())?; +//! ``` + +use crate::data::observation::ObservationProfile; +use crate::nca::analyze::analyze; +use crate::nca::calc::tlag_from_raw; +use crate::nca::error::NCAError; +use crate::nca::types::{NCAOptions, NCAResult, Warning}; +use crate::{Data, Occasion, Subject}; +use rayon::prelude::*; + +/// Structured NCA result for a single subject +/// +/// Groups occasion-level results under a subject identifier, +/// making it easy to associate results back to subjects. +#[derive(Debug, Clone)] +pub struct SubjectNCAResult { + /// Subject identifier + pub subject_id: String, + /// NCA results for each occasion + pub occasions: Vec>, +} + +impl SubjectNCAResult { + /// Collect all successful NCA results across occasions + pub fn successes(&self) -> Vec<&NCAResult> { + self.occasions + .iter() + .filter_map(|r| r.as_ref().ok()) + .collect() + } + + /// Collect all errors across occasions + pub fn errors(&self) -> Vec<&NCAError> { + self.occasions + .iter() + .filter_map(|r| r.as_ref().err()) + .collect() + } +} + +// ============================================================================ +// Trait: Full NCA analysis +// ============================================================================ + +/// Extension trait for Non-Compartmental Analysis +/// +/// Provides `.nca()` (first occasion) and `.nca_all()` (all occasions) +/// on [`Data`], [`Subject`], and [`Occasion`]. +/// +/// The output equation is controlled by [`NCAOptions::outeq`] (default 0). +/// +/// # Example +/// +/// ```rust,ignore +/// use pharmsol::prelude::*; +/// use pharmsol::nca::NCAOptions; +/// +/// let subject = Subject::builder("patient_001") +/// .bolus(0.0, 100.0, 0) +/// .observation(1.0, 10.0, 0) +/// .observation(2.0, 8.0, 0) +/// .observation(4.0, 4.0, 0) +/// .build(); +/// +/// // Single-occasion (the common case) +/// let result = subject.nca(&NCAOptions::default())?; +/// println!("Cmax: {:.2}", result.exposure.cmax); +/// +/// // All occasions +/// let all = subject.nca_all(&NCAOptions::default()); +/// ``` +pub trait NCA { + /// NCA on the first occasion (the common case). Returns a single result. + fn nca(&self, options: &NCAOptions) -> Result; + + /// NCA on all occasions. Returns a Vec of results. + fn nca_all(&self, options: &NCAOptions) -> Vec>; +} + +/// Extension trait for structured population-level NCA +/// +/// Returns results grouped by subject, making it easy to associate +/// NCA results back to their source subjects. +pub trait NCAPopulation { + /// Perform NCA and return results grouped by subject + /// + /// Unlike [`NCA::nca_all`] which returns a flat `Vec`, this returns + /// a `Vec` where each entry groups all occasion + /// results for a single subject. + /// + /// # Example + /// + /// ```rust,ignore + /// use pharmsol::prelude::*; + /// use pharmsol::nca::{NCAOptions, NCAPopulation}; + /// + /// let population_results = data.nca_grouped(&NCAOptions::default()); + /// for subject_result in &population_results { + /// println!("Subject {}: {} occasions", subject_result.subject_id, subject_result.occasions.len()); + /// } + /// ``` + fn nca_grouped(&self, options: &NCAOptions) -> Vec; +} + +// ============================================================================ +// NCA on ObservationProfile (simulated / raw data) +// ============================================================================ + +use crate::data::Route; + +impl ObservationProfile { + /// Run NCA directly on an observation profile with explicit dose information. + /// + /// This is the entry point for simulated or predicted data where there is + /// no `Subject` or `Occasion` to attach to. + /// + /// # Arguments + /// * `dose_amount` - Total dose amount (None = no dose-normalized params) + /// * `route` - Administration route + /// * `infusion_duration` - Duration of infusion (for IV infusion route) + /// * `options` - NCA options (outeq is ignored; the profile is already filtered) + /// + /// # Example + /// + /// ```rust,ignore + /// use pharmsol::data::observation::ObservationProfile; + /// use pharmsol::nca::NCAOptions; + /// use pharmsol::data::Route; + /// + /// let profile = ObservationProfile::from_raw( + /// &[0.0, 1.0, 2.0, 4.0, 8.0], + /// &[0.0, 10.0, 8.0, 4.0, 1.0], + /// ); + /// let result = profile.nca_with_dose(Some(100.0), Route::Extravascular, None, &NCAOptions::default())?; + /// println!("Cmax: {:.2}", result.exposure.cmax); + /// ``` + pub fn nca_with_dose( + &self, + dose_amount: Option, + route: Route, + infusion_duration: Option, + options: &NCAOptions, + ) -> Result { + analyze( + self, + dose_amount, + route, + infusion_duration, + options, + None, + None, + None, + ) + } +} + +impl NCA for Occasion { + fn nca(&self, options: &NCAOptions) -> Result { + nca_occasion(self, options, None) + } + + fn nca_all(&self, options: &NCAOptions) -> Vec> { + vec![self.nca(options)] + } +} + +impl NCA for Subject { + fn nca(&self, options: &NCAOptions) -> Result { + self.occasions() + .first() + .map(|occ| nca_occasion(occ, options, Some(self.id()))) + .unwrap_or(Err(NCAError::InvalidParameter { + param: "occasion".to_string(), + value: "none found".to_string(), + })) + } + + fn nca_all(&self, options: &NCAOptions) -> Vec> { + self.occasions() + .par_iter() + .map(|occasion| nca_occasion(occasion, options, Some(self.id()))) + .collect() + } +} + +impl NCA for Data { + fn nca(&self, options: &NCAOptions) -> Result { + self.subjects() + .first() + .map(|s| s.nca(options)) + .unwrap_or(Err(NCAError::InvalidParameter { + param: "subject".to_string(), + value: "none found".to_string(), + })) + } + + fn nca_all(&self, options: &NCAOptions) -> Vec> { + self.subjects() + .par_iter() + .flat_map(|subject| subject.nca_all(options)) + .collect() + } +} + +impl NCAPopulation for Data { + fn nca_grouped(&self, options: &NCAOptions) -> Vec { + self.subjects() + .par_iter() + .map(|subject| { + let occasions = subject + .occasions() + .par_iter() + .map(|occasion| nca_occasion(occasion, options, Some(subject.id()))) + .collect(); + SubjectNCAResult { + subject_id: subject.id().to_string(), + occasions, + } + }) + .collect() + } +} + +/// Core NCA implementation for a single occasion +fn nca_occasion( + occasion: &Occasion, + options: &NCAOptions, + subject_id: Option<&str>, +) -> Result { + let outeq = options.outeq; + + // Build profile directly from the occasion + let profile = ObservationProfile::from_occasion(occasion, outeq, &options.blq_rule)?; + + // Compute tlag from raw (unfiltered) data to match PKNCA + let (times, concs, censoring) = occasion.get_observations(outeq); + let raw_tlag = tlag_from_raw(×, &concs, &censoring); + + // Extract dose info from Occasion directly (no DoseContext) + let dose_amount = { + let d = occasion.total_dose(); + if d > 0.0 { + Some(d) + } else { + None + } + }; + let route = options.route_override.unwrap_or_else(|| occasion.route()); + let infusion_duration = occasion.infusion_duration(); + + // Calculate NCA directly on the profile + let mut result = analyze( + &profile, + dose_amount, + route, + infusion_duration, + options, + raw_tlag, + subject_id, + Some(occasion.index()), + )?; + + // Warn about mixed routes if no explicit override was given + let routes = occasion.routes(); + if routes.len() > 1 && options.route_override.is_none() { + result + .quality + .warnings + .push(Warning::MixedRoutes { routes }); + } + + Ok(result) +} diff --git a/src/nca/types.rs b/src/nca/types.rs new file mode 100644 index 00000000..72459353 --- /dev/null +++ b/src/nca/types.rs @@ -0,0 +1,1168 @@ +//! NCA types: results, options, and configuration structures +//! +//! This module defines all public types for NCA analysis including: +//! - [`NCAResult`]: Complete structured results +//! - [`NCAOptions`]: Configuration options +//! - [`Route`]: Administration route +//! - Parameter group structs + +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt}; + +use crate::data::event::{AUCMethod, BLQRule, Route}; + +// ============================================================================ +// Configuration Types +// ============================================================================ + +/// Complete NCA configuration +/// +/// Dose and route information are automatically detected from the data. +/// Use these options to control calculation methods and quality thresholds. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NCAOptions { + /// AUC calculation method (default: LinUpLogDown) + pub auc_method: AUCMethod, + + /// BLQ handling rule (default: Exclude) + /// + /// When an observation is censored (`Censor::BLOQ` or `Censor::ALOQ`), + /// its value represents the quantification limit (lower or upper). + /// This rule determines how such observations are handled in the analysis. + /// + /// Note: ALOQ (Above LOQ) values are currently kept unchanged in the analysis. + /// This follows PKNCA behavior which also does not explicitly handle ALOQ. + pub blq_rule: BLQRule, + + /// Terminal phase (λz) estimation options + pub lambda_z: LambdaZOptions, + + /// Dosing interval for steady-state analysis (None = single-dose) + pub tau: Option, + + /// Time interval for partial AUC calculation (start, end) + /// + /// If specified, `auc_partial` in the result will contain the AUC + /// over this interval. Useful for regulatory submissions requiring + /// AUC over specific time windows (e.g., AUC0-4h). + pub auc_interval: Option<(f64, f64)>, + + /// C0 estimation methods for IV bolus (tried in order) + /// + /// Default: `[Observed, LogSlope, FirstConc]` + pub c0_methods: Vec, + + /// Maximum acceptable AUC extrapolation percentage (default: 20.0) + pub max_auc_extrap_pct: f64, + + /// Target concentration for time-above-concentration calculation (None = skip) + /// + /// When specified, the result will contain `time_above_mic` — the total time + /// the concentration profile is above this threshold. Uses linear interpolation + /// at crossing points. Commonly set to MIC for antibiotics. + pub concentration_threshold: Option, + + /// Override the auto-detected route + /// + /// By default, the administration route is inferred from dose events + /// (compartment number). Set this to override the heuristic when the + /// auto-detection gives wrong results (e.g., models where compartment 1 + /// is a depot, not central). + pub route_override: Option, + + /// Output equation index to analyze (default: 0) + /// + /// For multi-output models, select which output equation to run NCA on. + pub outeq: usize, + + /// Dose times for multi-dose NCA (None = single-dose) + /// + /// When set, AUC/Cmax/Tmax will be computed for each dosing interval + /// and stored in [`NCAResult::multi_dose`]. + pub dose_times: Option>, +} + +impl Default for NCAOptions { + fn default() -> Self { + Self { + auc_method: AUCMethod::LinUpLogDown, + blq_rule: BLQRule::Exclude, + lambda_z: LambdaZOptions::default(), + tau: None, + auc_interval: None, + c0_methods: vec![C0Method::Observed, C0Method::LogSlope, C0Method::FirstConc], + max_auc_extrap_pct: 20.0, + concentration_threshold: None, + route_override: None, + outeq: 0, + dose_times: None, + } + } +} + +impl NCAOptions { + /// FDA Bioequivalence study defaults + pub fn bioequivalence() -> Self { + Self { + lambda_z: LambdaZOptions { + min_r_squared: 0.90, + min_points: 3, + ..Default::default() + }, + max_auc_extrap_pct: 20.0, + ..Default::default() + } + } + + /// Lenient settings for sparse/exploratory data + pub fn sparse() -> Self { + Self { + lambda_z: LambdaZOptions { + min_r_squared: 0.80, + min_points: 3, + ..Default::default() + }, + max_auc_extrap_pct: 30.0, + ..Default::default() + } + } + + /// Set AUC calculation method + pub fn with_auc_method(mut self, method: AUCMethod) -> Self { + self.auc_method = method; + self + } + + /// Set BLQ handling rule + /// + /// Censoring is determined by `Censor` markings on observations (`BLOQ`/`ALOQ`), + /// not by a numeric threshold. This method sets how censored observations + /// are handled in the analysis. + pub fn with_blq_rule(mut self, rule: BLQRule) -> Self { + self.blq_rule = rule; + self + } + + /// Set dosing interval for steady-state analysis + pub fn with_tau(mut self, tau: f64) -> Self { + self.tau = Some(tau); + self + } + + /// Set time interval for partial AUC calculation + pub fn with_auc_interval(mut self, start: f64, end: f64) -> Self { + self.auc_interval = Some((start, end)); + self + } + + /// Set lambda-z options + pub fn with_lambda_z(mut self, options: LambdaZOptions) -> Self { + self.lambda_z = options; + self + } + + /// Set minimum R² for lambda-z + pub fn with_min_r_squared(mut self, min_r_squared: f64) -> Self { + self.lambda_z.min_r_squared = min_r_squared; + self + } + + /// Set C0 estimation methods (tried in order) + pub fn with_c0_methods(mut self, methods: Vec) -> Self { + self.c0_methods = methods; + self + } + + /// Set a target concentration threshold for time-above-concentration + /// + /// When set, the result will include `time_above_mic` — the total time + /// the profile is above this concentration. + pub fn with_concentration_threshold(mut self, threshold: f64) -> Self { + self.concentration_threshold = Some(threshold); + self + } + + /// Override the auto-detected route + /// + /// Use this when the auto-detection from compartment numbers gives wrong + /// results. For example, if your model uses compartment 1 as a depot + /// (not central), the auto-detection would incorrectly classify it as IV. + pub fn with_route(mut self, route: Route) -> Self { + self.route_override = Some(route); + self + } + + /// Set output equation index (default: 0) + pub fn with_outeq(mut self, outeq: usize) -> Self { + self.outeq = outeq; + self + } + + /// Set dose times for multi-dose NCA (interval-based AUC, Cmax, Tmax) + /// + /// When set, `analyze` will compute AUC, Cmax, and Tmax for each dosing + /// interval and store them in [`NCAResult::multi_dose`]. + pub fn with_dose_times(mut self, times: Vec) -> Self { + self.dose_times = Some(times); + self + } +} + +/// Lambda-z estimation options +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LambdaZOptions { + /// Point selection method + pub method: LambdaZMethod, + /// Minimum number of points for regression (default: 3) + pub min_points: usize, + /// Maximum number of points (None = no limit) + pub max_points: Option, + /// Minimum R² to accept (default: 0.90) + pub min_r_squared: f64, + /// Minimum span ratio (default: 2.0) + pub min_span_ratio: f64, + /// Whether to include Tmax in regression (default: false) + pub include_tmax: bool, + /// Factor added to adjusted R² to prefer more points (default: 0.0001, PKNCA default) + /// + /// The scoring formula becomes: adj_r_squared + adj_r_squared_factor * n_points + /// This allows preferring regressions with more points when R² values are similar. + pub adj_r_squared_factor: f64, + + /// Indices of observation points to exclude from λz regression + /// + /// These are indices into the observation profile (0-based). Points at these + /// indices will be skipped when fitting the terminal log-linear regression. + /// Useful for analyst-directed exclusion of outlier points. + pub exclude_indices: Vec, +} + +impl Default for LambdaZOptions { + fn default() -> Self { + Self { + method: LambdaZMethod::AdjR2, + min_points: 3, + max_points: None, + min_r_squared: 0.90, + min_span_ratio: 2.0, + include_tmax: false, + adj_r_squared_factor: 0.0001, // PKNCA default + exclude_indices: Vec::new(), + } + } +} + +/// Lambda-z point selection method +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum LambdaZMethod { + /// Best adjusted R² (recommended) + #[default] + AdjR2, + /// Best raw R² + R2, + /// Use specific number of terminal points + Manual(usize), +} + +/// C0 (initial concentration) estimation method for IV bolus +/// +/// Methods are tried in order until one succeeds. Default cascade: +/// `[Observed, LogSlope, FirstConc]` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum C0Method { + /// Use observed concentration at dose time if present and non-zero + Observed, + /// Semilog back-extrapolation from first two positive concentrations + LogSlope, + /// Use first positive concentration after dose time + FirstConc, + /// Use minimum positive concentration (for IV infusion steady-state) + Cmin, + /// Set C0 = 0 (for extravascular where C0 doesn't exist) + Zero, +} + +// ============================================================================ +// Result Types +// ============================================================================ + +/// Complete NCA result with logical parameter grouping +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NCAResult { + /// Subject identifier + pub subject_id: Option, + /// Occasion index + pub occasion: Option, + + /// Total dose amount (None if no dose events) + pub dose_amount: Option, + /// Administration route (auto-detected or overridden) + pub route: Option, + /// Infusion duration (None for bolus/extravascular) + pub infusion_duration: Option, + + /// Core exposure parameters (always computed) + pub exposure: ExposureParams, + + /// Terminal phase parameters (if λz succeeds) + pub terminal: Option, + + /// Clearance parameters (if dose + λz available) + pub clearance: Option, + + /// Route-specific parameters (IV bolus, IV infusion, or extravascular) + pub route_params: Option, + + /// Steady-state parameters (if tau specified) + pub steady_state: Option, + + /// Multi-dose interval parameters (if dose_times specified) + pub multi_dose: Option, + + /// Quality metrics and warnings + pub quality: Quality, +} + +impl NCAResult { + /// Get half-life if available + pub fn half_life(&self) -> Option { + self.terminal.as_ref().map(|t| t.half_life) + } + + /// C0 (IV Bolus only) — back-extrapolated initial concentration + pub fn c0(&self) -> Option { + match &self.route_params { + Some(RouteParams::IVBolus(p)) => Some(p.c0), + _ => None, + } + } + + /// Volume of distribution by back-extrapolated C0 (IV Bolus only) + pub fn vd(&self) -> Option { + match &self.route_params { + Some(RouteParams::IVBolus(p)) => Some(p.vd), + _ => None, + } + } + + /// Volume of distribution at steady state (from [`ClearanceParams`]) + pub fn vss(&self) -> Option { + self.clearance.as_ref().and_then(|c| c.vss) + } + + /// Concentration at end of infusion (IV Infusion only) + pub fn ceoi(&self) -> Option { + match &self.route_params { + Some(RouteParams::IVInfusion(p)) => p.ceoi, + _ => None, + } + } + + /// MRT for IV Infusion (adjusted for infusion time) + pub fn mrt_iv(&self) -> Option { + match &self.route_params { + Some(RouteParams::IVInfusion(p)) => p.mrt_iv, + _ => None, + } + } + + /// Flatten result to parameter name-value pairs for export + pub fn to_params(&self) -> HashMap<&'static str, f64> { + let mut p = HashMap::new(); + + // Exposure + p.insert("cmax", self.exposure.cmax); + p.insert("tmax", self.exposure.tmax); + p.insert("clast", self.exposure.clast); + p.insert("tlast", self.exposure.tlast); + if let Some(v) = self.exposure.tfirst { + p.insert("tfirst", v); + } + p.insert("auc_last", self.exposure.auc_last); + if let Some(v) = self.exposure.auc_inf_obs { + p.insert("auc_inf_obs", v); + } + if let Some(v) = self.exposure.auc_inf_pred { + p.insert("auc_inf_pred", v); + } + if let Some(v) = self.exposure.auc_pct_extrap_obs { + p.insert("auc_pct_extrap_obs", v); + } + if let Some(v) = self.exposure.auc_pct_extrap_pred { + p.insert("auc_pct_extrap_pred", v); + } + if let Some(v) = self.exposure.auc_partial { + p.insert("auc_partial", v); + } + if let Some(v) = self.exposure.aumc_last { + p.insert("aumc_last", v); + } + if let Some(v) = self.exposure.aumc_inf { + p.insert("aumc_inf", v); + } + if let Some(v) = self.exposure.tlag { + p.insert("tlag", v); + } + + // Dose-normalized + if let Some(v) = self.exposure.cmax_dn { + p.insert("cmax_dn", v); + } + if let Some(v) = self.exposure.auc_last_dn { + p.insert("auc_last_dn", v); + } + if let Some(v) = self.exposure.auc_inf_dn { + p.insert("auc_inf_dn", v); + } + + if let Some(v) = self.exposure.time_above_mic { + p.insert("time_above_mic", v); + } + + // Dose + if let Some(v) = self.dose_amount { + p.insert("dose", v); + } + + // Terminal + if let Some(ref t) = self.terminal { + p.insert("lambda_z", t.lambda_z); + p.insert("half_life", t.half_life); + if let Some(mrt) = t.mrt { + p.insert("mrt", mrt); + } + if let Some(eff_hl) = t.effective_half_life { + p.insert("effective_half_life", eff_hl); + } + if let Some(kel) = t.kel { + p.insert("kel", kel); + } + if let Some(ref reg) = t.regression { + if reg.corrxy.is_finite() { + p.insert("lambda_z_corrxy", reg.corrxy); + } + } + } + + // Clearance + if let Some(ref c) = self.clearance { + p.insert("cl_f", c.cl_f); + p.insert("vz_f", c.vz_f); + if let Some(vss) = c.vss { + p.insert("vss", vss); + } + } + + // Route-specific + if let Some(ref rp) = self.route_params { + match rp { + RouteParams::IVBolus(ref b) => { + p.insert("c0", b.c0); + p.insert("vd", b.vd); + } + RouteParams::IVInfusion(ref inf) => { + p.insert("infusion_duration", inf.infusion_duration); + if let Some(mrt_iv) = inf.mrt_iv { + p.insert("mrt_iv", mrt_iv); + } + if let Some(ceoi) = inf.ceoi { + p.insert("ceoi", ceoi); + } + } + RouteParams::Extravascular => {} + } + } + + // Steady-state + if let Some(ref ss) = self.steady_state { + p.insert("tau", ss.tau); + p.insert("auc_tau", ss.auc_tau); + p.insert("cmin", ss.cmin); + p.insert("cmax_ss", ss.cmax_ss); + p.insert("cavg", ss.cavg); + p.insert("fluctuation", ss.fluctuation); + p.insert("swing", ss.swing); + p.insert("peak_trough_ratio", ss.peak_trough_ratio); + if let Some(acc) = ss.accumulation { + p.insert("accumulation", acc); + } + } + + p + } + + /// Flatten result to ordered key-value pairs + /// + /// Unlike [`to_params()`](Self::to_params) which returns a HashMap, this returns + /// a `Vec` with a canonical ordering suitable for tabular display. Optional + /// parameters that are absent produce `None` values. + /// + /// The ordering follows PK reporting convention: + /// exposure → terminal → clearance → route-specific → steady-state → dose-normalized → quality + pub fn to_row(&self) -> Vec<(&'static str, Option)> { + let mut row = Vec::with_capacity(40); + + // Exposure + row.push(("cmax", Some(self.exposure.cmax))); + row.push(("tmax", Some(self.exposure.tmax))); + row.push(("clast", Some(self.exposure.clast))); + row.push(("tlast", Some(self.exposure.tlast))); + row.push(("tfirst", self.exposure.tfirst)); + row.push(("auc_last", Some(self.exposure.auc_last))); + row.push(("auc_inf_obs", self.exposure.auc_inf_obs)); + row.push(("auc_inf_pred", self.exposure.auc_inf_pred)); + row.push(("auc_pct_extrap_obs", self.exposure.auc_pct_extrap_obs)); + row.push(("auc_pct_extrap_pred", self.exposure.auc_pct_extrap_pred)); + row.push(("auc_partial", self.exposure.auc_partial)); + row.push(("aumc_last", self.exposure.aumc_last)); + row.push(("aumc_inf", self.exposure.aumc_inf)); + row.push(("tlag", self.exposure.tlag)); + + // Terminal + if let Some(ref t) = self.terminal { + row.push(("lambda_z", Some(t.lambda_z))); + row.push(("half_life", Some(t.half_life))); + row.push(("mrt", t.mrt)); + row.push(("effective_half_life", t.effective_half_life)); + row.push(("kel", t.kel)); + } else { + row.push(("lambda_z", None)); + row.push(("half_life", None)); + row.push(("mrt", None)); + row.push(("effective_half_life", None)); + row.push(("kel", None)); + } + + // Clearance + if let Some(ref c) = self.clearance { + row.push(("cl_f", Some(c.cl_f))); + row.push(("vz_f", Some(c.vz_f))); + row.push(("vss", c.vss)); + } else { + row.push(("cl_f", None)); + row.push(("vz_f", None)); + row.push(("vss", None)); + } + + // Route-specific — always emit all columns, None when not applicable + match self.route_params.as_ref() { + Some(RouteParams::IVBolus(ref b)) => { + row.push(("c0", Some(b.c0))); + row.push(("vd", Some(b.vd))); + row.push(("infusion_duration", None)); + row.push(("ceoi", None)); + } + Some(RouteParams::IVInfusion(ref inf)) => { + row.push(("c0", None)); + row.push(("vd", None)); + row.push(("infusion_duration", Some(inf.infusion_duration))); + row.push(("ceoi", inf.ceoi)); + } + Some(RouteParams::Extravascular) | None => { + row.push(("c0", None)); + row.push(("vd", None)); + row.push(("infusion_duration", None)); + row.push(("ceoi", None)); + } + } + + // Steady-state — always emit all columns + if let Some(ref ss) = self.steady_state { + row.push(("tau", Some(ss.tau))); + row.push(("auc_tau", Some(ss.auc_tau))); + row.push(("cmin", Some(ss.cmin))); + row.push(("cmax_ss", Some(ss.cmax_ss))); + row.push(("cavg", Some(ss.cavg))); + row.push(("fluctuation", Some(ss.fluctuation))); + row.push(("swing", Some(ss.swing))); + row.push(("peak_trough_ratio", Some(ss.peak_trough_ratio))); + row.push(("accumulation", ss.accumulation)); + } else { + row.push(("tau", None)); + row.push(("auc_tau", None)); + row.push(("cmin", None)); + row.push(("cmax_ss", None)); + row.push(("cavg", None)); + row.push(("fluctuation", None)); + row.push(("swing", None)); + row.push(("peak_trough_ratio", None)); + row.push(("accumulation", None)); + } + + // Dose-normalized + row.push(("cmax_dn", self.exposure.cmax_dn)); + row.push(("auc_last_dn", self.exposure.auc_last_dn)); + row.push(("auc_inf_dn", self.exposure.auc_inf_dn)); + row.push(("time_above_mic", self.exposure.time_above_mic)); + + // Dose + row.push(("dose", self.dose_amount)); + + row + } +} + +impl fmt::Display for NCAResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "╔══════════════════════════════════════╗")?; + writeln!(f, "║ NCA Results ║")?; + writeln!(f, "╠══════════════════════════════════════╣")?; + + if let Some(ref id) = self.subject_id { + writeln!(f, "║ Subject: {:<27} ║", id)?; + } + if let Some(occ) = self.occasion { + writeln!(f, "║ Occasion: {:<26} ║", occ)?; + } + if let Some(amount) = self.dose_amount { + let route_str = self + .route + .map(|r| format!("{:?}", r)) + .unwrap_or_else(|| "Unknown".to_string()); + writeln!( + f, + "║ Dose: {:<30} ║", + format!("{:.2} ({})", amount, route_str) + )?; + } + + writeln!(f, "╠══════════════════════════════════════╣")?; + writeln!(f, "║ EXPOSURE ║")?; + writeln!( + f, + "║ Cmax: {:>10.4} at Tmax={:<6.2} ║", + self.exposure.cmax, self.exposure.tmax + )?; + writeln!( + f, + "║ AUClast: {:>10.4} ║", + self.exposure.auc_last + )?; + if let Some(v) = self.exposure.auc_inf_obs { + writeln!(f, "║ AUCinf(obs): {:>10.4} ║", v)?; + } + if let Some(v) = self.exposure.auc_inf_pred { + writeln!(f, "║ AUCinf(pred): {:>10.4} ║", v)?; + } + writeln!( + f, + "║ Clast: {:>10.4} at Tlast={:<5.2}║", + self.exposure.clast, self.exposure.tlast + )?; + + if let Some(ref t) = self.terminal { + writeln!(f, "╠══════════════════════════════════════╣")?; + writeln!(f, "║ TERMINAL ║")?; + writeln!(f, "║ λz: {:>10.5} ║", t.lambda_z)?; + writeln!(f, "║ t½: {:>10.2} ║", t.half_life)?; + if let Some(eff_hl) = t.effective_half_life { + writeln!(f, "║ t½eff: {:>10.2} ║", eff_hl)?; + } + if let Some(kel) = t.kel { + writeln!(f, "║ Kel: {:>10.5} ║", kel)?; + } + if let Some(ref reg) = t.regression { + writeln!(f, "║ R²: {:>10.4} ║", reg.r_squared)?; + if reg.corrxy.is_finite() { + writeln!(f, "║ corrxy: {:>10.4} ║", reg.corrxy)?; + } + } + } + + if let Some(ref c) = self.clearance { + writeln!(f, "╠══════════════════════════════════════╣")?; + writeln!(f, "║ CLEARANCE ║")?; + writeln!(f, "║ CL/F: {:>10.4} ║", c.cl_f)?; + writeln!(f, "║ Vz/F: {:>10.4} ║", c.vz_f)?; + } + + if let Some(ref rp) = self.route_params { + match rp { + RouteParams::IVBolus(ref b) => { + writeln!(f, "╠══════════════════════════════════════╣")?; + writeln!(f, "║ IV BOLUS ║")?; + writeln!(f, "║ C0: {:>10.4} ║", b.c0)?; + writeln!(f, "║ Vd: {:>10.4} ║", b.vd)?; + } + RouteParams::IVInfusion(ref inf) => { + writeln!(f, "╠══════════════════════════════════════╣")?; + writeln!(f, "║ IV INFUSION ║")?; + writeln!( + f, + "║ Dur: {:>10.4} ║", + inf.infusion_duration + )?; + } + RouteParams::Extravascular => {} + } + } + + if !self.quality.warnings.is_empty() { + writeln!(f, "╠══════════════════════════════════════╣")?; + writeln!(f, "║ WARNINGS ║")?; + for w in &self.quality.warnings { + writeln!(f, "║ • {:<32} ║", format!("{}", w))?; + } + } + + writeln!(f, "╚══════════════════════════════════════╝")?; + Ok(()) + } +} + +/// Core exposure parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExposureParams { + /// Maximum observed concentration + pub cmax: f64, + /// Time of maximum concentration + pub tmax: f64, + /// Last quantifiable concentration + pub clast: f64, + /// Time of last quantifiable concentration + pub tlast: f64, + /// First measurable (positive) concentration time + pub tfirst: Option, + /// AUC from time 0 to Tlast + pub auc_last: f64, + /// AUC extrapolated to infinity using observed Clast + pub auc_inf_obs: Option, + /// AUC extrapolated to infinity using predicted Clast (from λz regression) + pub auc_inf_pred: Option, + /// Percentage of AUC extrapolated (observed Clast) + pub auc_pct_extrap_obs: Option, + /// Percentage of AUC extrapolated (predicted Clast) + pub auc_pct_extrap_pred: Option, + /// Partial AUC (if requested) + pub auc_partial: Option, + /// AUMC from time 0 to Tlast + pub aumc_last: Option, + /// AUMC extrapolated to infinity + pub aumc_inf: Option, + /// Lag time (extravascular only) + pub tlag: Option, + + // Dose-normalized parameters (computed when dose > 0) + /// Cmax normalized by dose (Cmax / dose) + pub cmax_dn: Option, + /// AUClast normalized by dose (AUClast / dose) + pub auc_last_dn: Option, + /// AUCinf(obs) normalized by dose (AUCinf_obs / dose) + pub auc_inf_dn: Option, + + /// Total time above a concentration threshold (e.g., MIC) + /// + /// Only computed when [`NCAOptions::concentration_threshold`] is set. + pub time_above_mic: Option, +} + +/// Terminal phase parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalParams { + /// Terminal elimination rate constant + pub lambda_z: f64, + /// Terminal half-life + pub half_life: f64, + /// Mean residence time + pub mrt: Option, + /// Effective half-life: ln(2) × MRT + pub effective_half_life: Option, + /// Elimination rate constant: 1 / MRT + pub kel: Option, + /// Regression statistics + pub regression: Option, +} + +/// Regression statistics for λz estimation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegressionStats { + /// Coefficient of determination + pub r_squared: f64, + /// Adjusted R² + pub adj_r_squared: f64, + /// Pearson correlation coefficient (corrxy) — negative for terminal elimination + pub corrxy: f64, + /// Number of points used + pub n_points: usize, + /// First time point in regression + pub time_first: f64, + /// Last time point in regression + pub time_last: f64, + /// Span ratio + pub span_ratio: f64, +} + +/// Clearance parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClearanceParams { + /// Apparent clearance (CL/F) + pub cl_f: f64, + /// Apparent volume of distribution (Vz/F) + pub vz_f: f64, + /// Volume at steady state (for IV) + pub vss: Option, +} + +/// IV Bolus-specific parameters +/// +/// Note: Volume of distribution at steady state (Vss) is computed from clearance +/// and is therefore located in [`ClearanceParams::vss`], not here. Use +/// [`NCAResult::vss()`] for convenient access. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IVBolusParams { + /// Back-extrapolated initial concentration + pub c0: f64, + /// Volume of distribution + pub vd: f64, + /// Which C0 estimation method succeeded + pub c0_method: Option, +} + +/// IV Infusion-specific parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IVInfusionParams { + /// Infusion duration + pub infusion_duration: f64, + /// MRT corrected for infusion + pub mrt_iv: Option, + /// Concentration at end of infusion + pub ceoi: Option, +} + +/// Route-specific NCA parameters +/// +/// Replaces separate `iv_bolus`/`iv_infusion` fields with a single discriminated union. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RouteParams { + /// IV bolus route with back-extrapolated C0, Vd, and optional Vss + IVBolus(IVBolusParams), + /// IV infusion route with infusion duration, MRT correction, and optional Vss + IVInfusion(IVInfusionParams), + /// Extravascular route (oral, SC, IM, etc.) — tlag is in [`ExposureParams`] + Extravascular, +} + +/// Steady-state parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SteadyStateParams { + /// Dosing interval + pub tau: f64, + /// AUC over dosing interval + pub auc_tau: f64, + /// Minimum concentration + pub cmin: f64, + /// Maximum concentration at steady state + pub cmax_ss: f64, + /// Average concentration + pub cavg: f64, + /// Percent fluctuation + pub fluctuation: f64, + /// Swing + pub swing: f64, + /// Peak-to-trough ratio (Cmax / Cmin) + pub peak_trough_ratio: f64, + /// Accumulation ratio (AUC_tau / AUC_inf from single dose) + pub accumulation: Option, +} + +/// Per-interval parameters for multi-dose NCA +/// +/// Computed when [`NCAOptions::dose_times`] is set. Contains AUC, Cmax, and Tmax +/// for each dosing interval defined by consecutive dose times. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiDoseParams { + /// Dose time marking the start of each interval + pub dose_times: Vec, + /// AUC for each dosing interval (dose_i → dose_{i+1}, or dose_last → tlast) + pub auc_intervals: Vec, + /// Cmax within each dosing interval + pub cmax_intervals: Vec, + /// Tmax within each dosing interval + pub tmax_intervals: Vec, +} + +/// Quality metrics and warnings +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Quality { + /// List of warnings + pub warnings: Vec, +} + +impl Quality { + /// Get only critical warnings (errors that may invalidate results) + pub fn errors(&self) -> Vec<&Warning> { + self.warnings + .iter() + .filter(|w| w.severity() == Severity::Error) + .collect() + } + + /// Get non-critical warnings (suboptimal but usable results) + pub fn warnings_only(&self) -> Vec<&Warning> { + self.warnings + .iter() + .filter(|w| w.severity() == Severity::Warning) + .collect() + } + + /// Get informational notices + pub fn info(&self) -> Vec<&Warning> { + self.warnings + .iter() + .filter(|w| w.severity() == Severity::Info) + .collect() + } + + /// Check if any critical errors are present + pub fn has_errors(&self) -> bool { + self.warnings + .iter() + .any(|w| w.severity() == Severity::Error) + } +} + +/// Severity level for NCA warnings +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Severity { + /// Informational — results are valid but of note + Info, + /// Warning — results are usable but suboptimal + Warning, + /// Error — results may be invalid or analysis failed + Error, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Info => write!(f, "INFO"), + Severity::Warning => write!(f, "WARN"), + Severity::Error => write!(f, "ERROR"), + } + } +} + +/// NCA analysis warnings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Warning { + /// AUC extrapolation percentage exceeds threshold + HighExtrapolation { + /// Actual extrapolation percentage + pct: f64, + /// Configured threshold + threshold: f64, + }, + /// Poor lambda-z regression fit + PoorFit { + /// Actual R² value + r_squared: f64, + /// Minimum required R² + threshold: f64, + }, + /// Lambda-z could not be estimated + LambdaZNotEstimable, + /// Terminal phase span ratio too short + ShortTerminalPhase { + /// Actual span ratio + span_ratio: f64, + /// Minimum required span ratio + threshold: f64, + }, + /// Cmax is zero or negative + LowCmax, + /// Multiple routes detected in a single occasion without explicit override + MixedRoutes { + /// Routes detected in the occasion + routes: Vec, + }, +} + +impl Warning { + /// Get the severity level of this warning + /// + /// - **Error**: `LambdaZNotEstimable`, `LowCmax` — analysis may be invalid + /// - **Warning**: `HighExtrapolation`, `PoorFit` — results usable but suboptimal + /// - **Info**: `ShortTerminalPhase` — informational only + pub fn severity(&self) -> Severity { + match self { + Warning::LambdaZNotEstimable | Warning::LowCmax => Severity::Error, + Warning::HighExtrapolation { .. } | Warning::PoorFit { .. } => Severity::Warning, + Warning::ShortTerminalPhase { .. } | Warning::MixedRoutes { .. } => Severity::Info, + } + } +} + +impl fmt::Display for Warning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Warning::HighExtrapolation { pct, threshold } => { + write!( + f, + "AUC extrapolation {:.1}% exceeds {:.1}% threshold", + pct, threshold + ) + } + Warning::PoorFit { + r_squared, + threshold, + } => { + write!(f, "λz R²={:.4} below minimum {:.4}", r_squared, threshold) + } + Warning::LambdaZNotEstimable => write!(f, "λz could not be estimated"), + Warning::ShortTerminalPhase { + span_ratio, + threshold, + } => { + write!( + f, + "Terminal phase span ratio {:.2} below minimum {:.2}", + span_ratio, threshold + ) + } + Warning::LowCmax => write!(f, "Cmax ≤ 0"), + Warning::MixedRoutes { routes } => { + write!(f, "Mixed routes detected: {:?}", routes) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nca_options_default() { + let opts = NCAOptions::default(); + assert_eq!(opts.auc_method, AUCMethod::LinUpLogDown); + assert_eq!(opts.blq_rule, BLQRule::Exclude); + assert!(opts.tau.is_none()); + assert_eq!(opts.max_auc_extrap_pct, 20.0); + } + + #[test] + fn test_nca_options_builder() { + let opts = NCAOptions::default() + .with_auc_method(AUCMethod::Linear) + .with_blq_rule(BLQRule::LoqOver2) + .with_tau(24.0) + .with_min_r_squared(0.95); + + assert_eq!(opts.auc_method, AUCMethod::Linear); + assert_eq!(opts.blq_rule, BLQRule::LoqOver2); + assert_eq!(opts.tau, Some(24.0)); + assert_eq!(opts.lambda_z.min_r_squared, 0.95); + } + + #[test] + fn test_nca_options_presets() { + let be = NCAOptions::bioequivalence(); + assert_eq!(be.lambda_z.min_r_squared, 0.90); + assert_eq!(be.max_auc_extrap_pct, 20.0); + + let sparse = NCAOptions::sparse(); + assert_eq!(sparse.lambda_z.min_r_squared, 0.80); + assert_eq!(sparse.max_auc_extrap_pct, 30.0); + } + + /// Helper: minimal NCAResult with given route_params and clearance + fn make_result_with( + route_params: Option, + clearance: Option, + ) -> NCAResult { + NCAResult { + subject_id: None, + occasion: None, + dose_amount: Some(100.0), + route: Some(crate::data::Route::Extravascular), + infusion_duration: None, + exposure: ExposureParams { + cmax: 10.0, + tmax: 1.0, + clast: 1.0, + tlast: 8.0, + tfirst: None, + auc_last: 50.0, + auc_inf_obs: None, + auc_inf_pred: None, + auc_pct_extrap_obs: None, + auc_pct_extrap_pred: None, + auc_partial: None, + aumc_last: None, + aumc_inf: None, + tlag: None, + cmax_dn: None, + auc_last_dn: None, + auc_inf_dn: None, + time_above_mic: None, + }, + terminal: None, + clearance, + route_params, + steady_state: None, + multi_dose: None, + quality: Quality::default(), + } + } + + #[test] + fn test_accessor_c0_iv_bolus() { + let result = make_result_with( + Some(RouteParams::IVBolus(IVBolusParams { + c0: 25.0, + vd: 20.0, + c0_method: None, + })), + None, + ); + assert_eq!(result.c0(), Some(25.0)); + assert_eq!(result.vd(), Some(20.0)); + } + + #[test] + fn test_accessor_c0_not_bolus() { + let result = make_result_with(Some(RouteParams::Extravascular), None); + assert_eq!(result.c0(), None); + assert_eq!(result.vd(), None); + } + + #[test] + fn test_accessor_vss() { + let result = make_result_with( + None, + Some(ClearanceParams { + cl_f: 5.0, + vz_f: 10.0, + vss: Some(15.0), + }), + ); + assert_eq!(result.vss(), Some(15.0)); + } + + #[test] + fn test_accessor_vss_none() { + let result = make_result_with(None, None); + assert_eq!(result.vss(), None); + } + + #[test] + fn test_accessor_ceoi_infusion() { + let result = make_result_with( + Some(RouteParams::IVInfusion(IVInfusionParams { + infusion_duration: 1.0, + mrt_iv: Some(4.0), + ceoi: Some(30.0), + })), + None, + ); + assert_eq!(result.ceoi(), Some(30.0)); + assert_eq!(result.mrt_iv(), Some(4.0)); + } + + #[test] + fn test_accessor_ceoi_not_infusion() { + let result = make_result_with(Some(RouteParams::Extravascular), None); + assert_eq!(result.ceoi(), None); + assert_eq!(result.mrt_iv(), None); + } +} diff --git a/src/simulator/equation/ode/closure.rs b/src/simulator/equation/ode/closure.rs index 8c4489c3..ccb8a4c2 100644 --- a/src/simulator/equation/ode/closure.rs +++ b/src/simulator/equation/ode/closure.rs @@ -74,14 +74,15 @@ impl InfusionSchedule { }; } - let mut per_input: Vec> = vec![Vec::new(); nstates]; + let buffer_size = nstates; + let mut per_input: Vec> = vec![Vec::new(); buffer_size]; for infusion in infusions { if infusion.duration() <= 0.0 { continue; } let input = infusion.input(); - if input >= nstates { + if input >= buffer_size { continue; } @@ -341,10 +342,11 @@ where init: V, ) -> Self { let nparams = p.len(); - let rateiv_buffer = RefCell::new(V::zeros(nstates, NalgebraContext)); + let buffer_size = nstates; + let rateiv_buffer = RefCell::new(V::zeros(buffer_size, NalgebraContext)); let infusion_schedule = InfusionSchedule::new(nstates, infusions); // Pre-allocate zero bolus vector - let zero_bolus = V::zeros(nstates, NalgebraContext); + let zero_bolus = V::zeros(buffer_size, NalgebraContext); Self { func, diff --git a/src/simulator/equation/ode/mod.rs b/src/simulator/equation/ode/mod.rs index 23746c8d..a231ca58 100644 --- a/src/simulator/equation/ode/mod.rs +++ b/src/simulator/equation/ode/mod.rs @@ -219,15 +219,18 @@ impl Equation for ODE { // Cache nstates to avoid repeated method calls let nstates = self.get_nstates(); + let state_buffer_size = nstates; + let output_buffer_size = self.get_nouteqs(); + // Preallocate reusable vectors for bolus computation - let mut state_with_bolus = V::zeros(nstates, NalgebraContext); - let mut state_without_bolus = V::zeros(nstates, NalgebraContext); - let zero_vector = V::zeros(nstates, NalgebraContext); - let mut bolus_v = V::zeros(nstates, NalgebraContext); + let mut state_with_bolus = V::zeros(state_buffer_size, NalgebraContext); + let mut state_without_bolus = V::zeros(state_buffer_size, NalgebraContext); + let zero_vector = V::zeros(state_buffer_size, NalgebraContext); + let mut bolus_v = V::zeros(state_buffer_size, NalgebraContext); let spp_v: V = DVector::from_vec(support_point.clone()).into(); // Pre-allocate output vector for observations - let mut y_out = V::zeros(self.get_nouteqs(), NalgebraContext); + let mut y_out = V::zeros(output_buffer_size, NalgebraContext); // Iterate over occasions for occasion in subject.occasions() { diff --git a/src/simulator/equation/sde/mod.rs b/src/simulator/equation/sde/mod.rs index e7b7f243..a734a8e1 100644 --- a/src/simulator/equation/sde/mod.rs +++ b/src/simulator/equation/sde/mod.rs @@ -290,8 +290,10 @@ impl EquationPriv for SDE { output: &mut Self::P, ) -> Result<(), PharmsolError> { let mut pred = vec![Prediction::default(); self.nparticles]; + // Use nouteqs + 1 to support both 0-indexed and 1-indexed data + let output_buffer_size = self.get_nouteqs() + 1; pred.par_iter_mut().enumerate().for_each(|(i, p)| { - let mut y = V::zeros(self.get_nouteqs(), NalgebraContext); + let mut y = V::zeros(output_buffer_size, NalgebraContext); (self.out)( &x[i].clone().into(), &V::from_vec(support_point.clone(), NalgebraContext), diff --git a/tests/mod.rs b/tests/mod.rs new file mode 100644 index 00000000..7d924fd7 --- /dev/null +++ b/tests/mod.rs @@ -0,0 +1,2 @@ +/// NCA integration tests +mod nca; diff --git a/tests/nca/mod.rs b/tests/nca/mod.rs new file mode 100644 index 00000000..e4576238 --- /dev/null +++ b/tests/nca/mod.rs @@ -0,0 +1,11 @@ +// NCA Integration Tests Module +// Tests using the public NCA API via Subject::builder().nca() +// +// Note: Most NCA tests are in src/nca/tests.rs (internal unit tests). +// These integration tests verify the public API works correctly. + +pub mod test_auc; +pub mod test_params; +pub mod test_pknca; +pub mod test_quality; +pub mod test_terminal; diff --git a/tests/nca/pknca/README.md b/tests/nca/pknca/README.md new file mode 100644 index 00000000..0ea5ca07 --- /dev/null +++ b/tests/nca/pknca/README.md @@ -0,0 +1,66 @@ +# PKNCA Cross-Validation Framework + +This framework validates pharmsol's NCA implementation against PKNCA (the gold-standard R package) using a **clean-room approach**: + +1. **Test cases are independently designed** based on pharmacokinetic principles +2. **PKNCA serves as an oracle** - we run it to get expected values +3. **pharmsol results are compared** against these expected values + +## Directory Structure + +``` +tests/pknca_validation/ +├── README.md # This file +├── generate_expected.R # R script to run PKNCA and save expected values +├── expected_values.json # Generated expected outputs from PKNCA +├── test_scenarios.json # Test case definitions (inputs) +└── validation_tests.rs # Rust tests that compare pharmsol vs expected +``` + +## Usage + +### Step 1: Generate Expected Values (requires R + PKNCA) + +```bash +cd tests/pknca_validation +Rscript generate_expected.R +``` + +This creates `expected_values.json` with PKNCA's outputs. + +### Step 2: Run Validation Tests + +```bash +cargo test pknca_validation +``` + +## Test Scenarios + +Test cases are designed to cover: + +| Category | Scenarios | +| ---------------- | ----------------------------------------------------- | +| **Basic PK** | Single-dose oral, IV bolus, IV infusion | +| **AUC Methods** | Linear, lin-up/log-down, lin-log | +| **Lambda-z** | Various terminal phase slopes, different point counts | +| **BLQ Handling** | Zero, LOQ/2, exclude, positional | +| **C0 Methods** | Back-extrapolation, observed, first conc | +| **Edge Cases** | Sparse data, flat profiles, noisy data | + +## Validation Results + +**Current Status: 100% match (194/194 parameters)** + +| Metric | Value | +| ---------------------------- | -------------- | +| Exact matches | 194/194 (100%) | +| Known convention differences | 0 | +| Unexpected failures | 0 | + +All NCA parameters computed by pharmsol match PKNCA v0.12.1 exactly. + +## Legal Note + +This framework does NOT copy PKNCA code or tests. Test scenarios are independently +designed based on pharmacokinetic theory. PKNCA is used only as a reference +implementation to validate numerical accuracy. diff --git a/tests/nca/pknca/expected_values.json b/tests/nca/pknca/expected_values.json new file mode 100644 index 00000000..bf66e2cd --- /dev/null +++ b/tests/nca/pknca/expected_values.json @@ -0,0 +1,619 @@ +{ + "generated_at": "2026-02-15T16:07:09", + "r_version": "R version 4.5.1 (2025-06-13 ucrt)", + "pknca_version": "0.12.1", + "scenario_count": 25, + "results": { + "basic_oral_01": { + "id": "basic_oral_01", + "name": "Basic single-dose oral absorption", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 12, + "tmax": 2, + "tlast": 24, + "clast.obs": 0.05, + "tlag": 0, + "lambda.z": 0.2526, + "r.squared": 0.9941, + "adj.r.squared": 0.9926, + "lambda.z.time.first": 3, + "lambda.z.time.last": 24, + "lambda.z.n.points": 6, + "clast.pred": 0.044, + "half.life": 2.7445, + "span.ratio": 7.6516 + } + }, + "basic_oral_02": { + "id": "basic_oral_02", + "name": "Oral with delayed Tmax", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 4, + "tlast": 48, + "clast.obs": 0.05, + "tlag": 0, + "lambda.z": 0.1148, + "r.squared": 1, + "adj.r.squared": 0.9999, + "lambda.z.time.first": 12, + "lambda.z.time.last": 48, + "lambda.z.n.points": 3, + "clast.pred": 0.0502, + "half.life": 6.0395, + "span.ratio": 5.9607 + } + }, + "iv_bolus_01": { + "id": "iv_bolus_01", + "name": "IV bolus single compartment", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "auclast": 20.172, + "aucall": 20.172, + "aumclast": 40.3646, + "c0": 10, + "cmax": 10, + "tmax": 0, + "tlast": 12, + "clast.obs": 0.03, + "lambda.z": 0.4854, + "r.squared": 0.9998, + "adj.r.squared": 0.9998, + "lambda.z.time.first": 0.25, + "lambda.z.time.last": 12, + "lambda.z.n.points": 8, + "clast.pred": 0.0289, + "half.life": 1.4279, + "span.ratio": 8.2287, + "aucinf.obs": 20.2338, + "aucinf.pred": 20.2316, + "aumcinf.obs": 41.2336, + "aumcinf.pred": 41.2024, + "cl.obs": 4.9422, + "mrt.obs": 2.0379, + "vz.obs": 10.1814, + "vss.obs": 10.0716 + } + }, + "iv_bolus_02": { + "id": "iv_bolus_02", + "name": "IV bolus two-compartment", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "auclast": 51.7981, + "aucall": 51.7981, + "aumclast": 166.7329, + "c0": 50, + "cmax": 50, + "tmax": 0, + "tlast": 24, + "clast.obs": 0.05, + "lambda.z": 0.1989, + "r.squared": 0.9932, + "adj.r.squared": 0.9865, + "lambda.z.time.first": 8, + "lambda.z.time.last": 24, + "lambda.z.n.points": 3, + "clast.pred": 0.0481, + "half.life": 3.485, + "span.ratio": 4.5911, + "aucinf.obs": 52.0494, + "aucinf.pred": 52.0401, + "aumcinf.obs": 174.0302, + "aumcinf.pred": 173.7588, + "cl.obs": 9.6063, + "mrt.obs": 3.3436, + "vz.obs": 48.2984, + "vss.obs": 32.119 + } + }, + "iv_infusion_01": { + "id": "iv_infusion_01", + "name": "1-hour IV infusion", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 15, + "tmax": 1, + "tlast": 12, + "clast.obs": 0.3, + "tlag": 0, + "lambda.z": 0.3525, + "r.squared": 0.9999, + "adj.r.squared": 0.9998, + "lambda.z.time.first": 1.5, + "lambda.z.time.last": 12, + "lambda.z.n.points": 6, + "clast.pred": 0.3014, + "half.life": 1.9666, + "span.ratio": 5.339 + } + }, + "auc_method_linear": { + "id": "auc_method_linear", + "name": "AUC comparison - Linear method", + "pknca_version": "0.12.1", + "auc_method": "linear", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 12, + "clast.obs": 0.4, + "tlag": 0, + "lambda.z": 0.3356, + "r.squared": 0.9997, + "adj.r.squared": 0.9997, + "lambda.z.time.first": 3, + "lambda.z.time.last": 12, + "lambda.z.n.points": 5, + "clast.pred": 0.3983, + "half.life": 2.0652, + "span.ratio": 4.3579 + } + }, + "auc_method_linuplogdown": { + "id": "auc_method_linuplogdown", + "name": "AUC comparison - Lin up/log down", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 12, + "clast.obs": 0.4, + "tlag": 0, + "lambda.z": 0.3356, + "r.squared": 0.9997, + "adj.r.squared": 0.9997, + "lambda.z.time.first": 3, + "lambda.z.time.last": 12, + "lambda.z.n.points": 5, + "clast.pred": 0.3983, + "half.life": 2.0652, + "span.ratio": 4.3579 + } + }, + "auc_method_linlog": { + "id": "auc_method_linlog", + "name": "AUC comparison - Lin-log method", + "pknca_version": "0.12.1", + "auc_method": "lin-log", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 12, + "clast.obs": 0.4, + "tlag": 0, + "lambda.z": 0.3356, + "r.squared": 0.9997, + "adj.r.squared": 0.9997, + "lambda.z.time.first": 3, + "lambda.z.time.last": 12, + "lambda.z.n.points": 5, + "clast.pred": 0.3983, + "half.life": 2.0652, + "span.ratio": 4.3579 + } + }, + "lambda_z_short": { + "id": "lambda_z_short", + "name": "Lambda-z with minimum points", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 1, + "tlast": 8, + "clast.obs": 1, + "tlag": 0, + "lambda.z": 0.3466, + "r.squared": 1, + "adj.r.squared": 1, + "lambda.z.time.first": 2, + "lambda.z.time.last": 8, + "lambda.z.n.points": 4, + "clast.pred": 1, + "half.life": 2, + "span.ratio": 3 + } + }, + "lambda_z_long": { + "id": "lambda_z_long", + "name": "Lambda-z with many points", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 12, + "tmax": 2, + "tlast": 48, + "clast.obs": 0.002, + "tlag": 0, + "lambda.z": 0.1882, + "r.squared": 1, + "adj.r.squared": 1, + "lambda.z.time.first": 4, + "lambda.z.time.last": 48, + "lambda.z.n.points": 8, + "clast.pred": 0.002, + "half.life": 3.6828, + "span.ratio": 11.9474 + } + }, + "blq_middle": { + "id": "blq_middle", + "name": "BLQ in middle of profile", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": "exclude", + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 12, + "clast.obs": 0.4, + "tlag": 0, + "lambda.z": 0.3383, + "r.squared": 0.9998, + "adj.r.squared": 0.9997, + "lambda.z.time.first": 4, + "lambda.z.time.last": 12, + "lambda.z.n.points": 4, + "clast.pred": 0.3956, + "half.life": 2.0491, + "span.ratio": 3.9042 + } + }, + "blq_positional": { + "id": "blq_positional", + "name": "BLQ with positional handling", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": "positional", + "parameters": { + "auclast": 36.186, + "aucall": 40.186, + "aumclast": 116.2766, + "cmax": 10, + "tmax": 1, + "tlast": 8, + "clast.obs": 2, + "tlag": 0 + } + }, + "sparse_profile": { + "id": "sparse_profile", + "name": "Sparse sampling profile", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 12, + "tmax": 2, + "tlast": 24, + "clast.obs": 0.2, + "tlag": 0 + } + }, + "flat_cmax": { + "id": "flat_cmax", + "name": "Multiple Tmax candidates", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 8, + "clast.obs": 3, + "tlag": 0, + "lambda.z": 0.301, + "r.squared": 0.9924, + "adj.r.squared": 0.9848, + "lambda.z.time.first": 4, + "lambda.z.time.last": 8, + "lambda.z.n.points": 3, + "clast.pred": 3.0926, + "half.life": 2.3029, + "span.ratio": 1.737 + } + }, + "high_extrapolation": { + "id": "high_extrapolation", + "name": "High AUC extrapolation percentage", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 1, + "tlast": 6, + "clast.obs": 3, + "tlag": 0, + "lambda.z": 0.2452, + "r.squared": 0.9994, + "adj.r.squared": 0.9988, + "lambda.z.time.first": 2, + "lambda.z.time.last": 6, + "lambda.z.n.points": 3, + "clast.pred": 3.0205, + "half.life": 2.8268, + "span.ratio": 1.415 + } + }, + "clast_pred_comparison": { + "id": "clast_pred_comparison", + "name": "Clast observed vs predicted", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 12, + "tmax": 2, + "tlast": 12, + "clast.obs": 0.8, + "tlag": 0, + "lambda.z": 0.2708, + "r.squared": 0.9998, + "adj.r.squared": 0.9997, + "lambda.z.time.first": 4, + "lambda.z.time.last": 12, + "lambda.z.n.points": 4, + "clast.pred": 0.7921, + "half.life": 2.5597, + "span.ratio": 3.1254 + } + }, + "partial_auc": { + "id": "partial_auc", + "name": "Partial AUC calculation", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 24, + "clast.obs": 0.3, + "tlag": 0, + "lambda.z": 0.1631, + "r.squared": 0.9862, + "adj.r.squared": 0.9816, + "lambda.z.time.first": 4, + "lambda.z.time.last": 24, + "lambda.z.n.points": 5, + "clast.pred": 0.271, + "half.life": 4.2493, + "span.ratio": 4.7066, + "partial_auc": 40.1198, + "partial_auc_start": 2, + "partial_auc_end": 8 + } + }, + "mrt_calculation": { + "id": "mrt_calculation", + "name": "MRT and related parameters", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 24, + "clast.obs": 0.15, + "tlag": 0, + "lambda.z": 0.1792, + "r.squared": 0.9913, + "adj.r.squared": 0.987, + "lambda.z.time.first": 6, + "lambda.z.time.last": 24, + "lambda.z.n.points": 4, + "clast.pred": 0.1409, + "half.life": 3.8672, + "span.ratio": 4.6545 + } + }, + "tlag_detection": { + "id": "tlag_detection", + "name": "Lag time detection", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 8, + "clast.obs": 1.5, + "tlag": 0.5, + "lambda.z": 0.3466, + "r.squared": 1, + "adj.r.squared": 1, + "lambda.z.time.first": 4, + "lambda.z.time.last": 8, + "lambda.z.n.points": 3, + "clast.pred": 1.5, + "half.life": 2, + "span.ratio": 2 + } + }, + "numerical_precision": { + "id": "numerical_precision", + "name": "Numerical precision test", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 67.891, + "tmax": 2, + "tlast": 96, + "clast.obs": 0.002, + "tlag": 0, + "lambda.z": 0.1059, + "r.squared": 0.9998, + "adj.r.squared": 0.9997, + "lambda.z.time.first": 12, + "lambda.z.time.last": 96, + "lambda.z.n.points": 5, + "clast.pred": 0.0021, + "half.life": 6.5456, + "span.ratio": 12.8331 + } + }, + "steady_state_oral": { + "id": "steady_state_oral", + "name": "Steady-state oral dosing", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "auclast": 67.5547, + "aucall": 67.5547, + "aumclast": 295.7289, + "cmax": 12, + "cmin": 1.5, + "tmax": 2, + "tlast": 12, + "clast.obs": 1.5, + "cav": 5.6296, + "tlag": 0, + "lambda.z": 0.2132, + "r.squared": 0.9986, + "adj.r.squared": 0.9981, + "lambda.z.time.first": 4, + "lambda.z.time.last": 12, + "lambda.z.n.points": 5, + "clast.pred": 1.4819, + "half.life": 3.251, + "span.ratio": 2.4608, + "aucinf.obs": 74.59, + "aucinf.pred": 74.5051, + "aumcinf.obs": 413.1483, + "aumcinf.pred": 411.7316, + "cl.obs": 1.3407, + "mrt.obs": 5.5389, + "vz.obs": 6.2879 + } + }, + "steady_state_iv": { + "id": "steady_state_iv", + "name": "Steady-state IV infusion", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "auclast": 139.0232, + "aucall": 139.0232, + "aumclast": 920.3314, + "cmax": 18, + "cmin": 0.5, + "tmax": 2, + "tlast": 24, + "clast.obs": 0.5, + "cav": 5.7926, + "tlag": 0, + "lambda.z": 0.1661, + "r.squared": 0.999, + "adj.r.squared": 0.9988, + "lambda.z.time.first": 4, + "lambda.z.time.last": 24, + "lambda.z.n.points": 6, + "clast.pred": 0.526, + "half.life": 4.1731, + "span.ratio": 4.7926, + "aucinf.obs": 142.0334, + "aucinf.pred": 142.1897, + "aumcinf.obs": 1010.7007, + "aumcinf.pred": 1015.3927, + "cl.obs": 3.5203, + "mrt.obs": 7.1159, + "mrt.iv.obs": 6.1159, + "vss.obs": 25.0502 + } + }, + "c0_logslope": { + "id": "c0_logslope", + "name": "C0 back-extrapolation test", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "c0": 9.8462, + "cmax": 8, + "tmax": 0.5, + "tlast": 8, + "clast.obs": 0.35, + "tlag": 0, + "lambda.z": 0.4182, + "r.squared": 0.9999, + "adj.r.squared": 0.9999, + "lambda.z.time.first": 1, + "lambda.z.time.last": 8, + "lambda.z.n.points": 5, + "clast.pred": 0.3501, + "half.life": 1.6573, + "span.ratio": 4.2237 + } + }, + "span_ratio_test": { + "id": "span_ratio_test", + "name": "Span ratio quality metric", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": {}, + "parameters": { + "cmax": 12, + "tmax": 2, + "tlast": 48, + "clast.obs": 0.1, + "tlag": 0, + "lambda.z": 0.0924, + "r.squared": 0.9999, + "adj.r.squared": 0.9999, + "lambda.z.time.first": 12, + "lambda.z.time.last": 48, + "lambda.z.n.points": 3, + "clast.pred": 0.0995, + "half.life": 7.5002, + "span.ratio": 4.7999 + } + }, + "auc_all_terminal_blq": { + "id": "auc_all_terminal_blq", + "name": "AUCall with terminal BLQ", + "pknca_version": "0.12.1", + "auc_method": "lin up/log down", + "blq_rule": "exclude", + "parameters": { + "cmax": 10, + "tmax": 2, + "tlast": 8, + "clast.obs": 1.5, + "tlag": 0, + "lambda.z": 0.3466, + "r.squared": 1, + "adj.r.squared": 1, + "lambda.z.time.first": 4, + "lambda.z.time.last": 8, + "lambda.z.n.points": 3, + "clast.pred": 1.5, + "half.life": 2, + "span.ratio": 2 + } + } + } +} diff --git a/tests/nca/pknca/generate_expected.R b/tests/nca/pknca/generate_expected.R new file mode 100644 index 00000000..dc2f1491 --- /dev/null +++ b/tests/nca/pknca/generate_expected.R @@ -0,0 +1,250 @@ +#!/usr/bin/env Rscript +# ============================================================================= +# PKNCA Cross-Validation: Generate Expected Values +# ============================================================================= +# +# This script reads test scenarios from test_scenarios.json, runs PKNCA to +# compute NCA parameters, and saves the expected values to expected_values.json. +# +# Usage: Rscript generate_expected.R +# +# Requirements: R with PKNCA, jsonlite packages installed +# ============================================================================= + +library(PKNCA) +library(jsonlite) + +cat("PKNCA Cross-Validation - Generating Expected Values\n") +cat("====================================================\n\n") + +# Read test scenarios +scenarios_raw <- fromJSON("test_scenarios.json", simplifyVector = FALSE) +scenarios <- scenarios_raw$scenarios +cat(sprintf("Loaded %d test scenarios\n\n", length(scenarios))) + +# Helper function to map our route names to PKNCA expectations +get_route_for_pknca <- function(route) { + switch(route, + "extravascular" = "extravascular", + "iv_bolus" = "intravascular", + "iv_infusion" = "intravascular", + route + ) +} + +# Helper function to get AUC method name for PKNCA +get_auc_method <- function(method) { + if (is.null(method)) { + return("lin up/log down") + } + method +} + +# Process each scenario +results <- list() + +for (scenario in scenarios) { + cat(sprintf("Processing: %s (%s)\n", scenario$name, scenario$id)) + + tryCatch( + { + # Build concentration data frame - unlist JSON arrays + times <- unlist(scenario$times) + concs <- unlist(scenario$concentrations) + + conc_data <- data.frame( + ID = 1, + time = times, + conc = concs + ) + + # Handle BLQ if specified + if (!is.null(scenario$blq_indices)) { + # Mark BLQ as 0 (PKNCA convention) + # Note: blq_indices are 0-based from JSON + blq_idx <- unlist(scenario$blq_indices) + for (idx in blq_idx) { + conc_data$conc[idx + 1] <- 0 + } + } + + # Build dose data frame + dose_data <- data.frame( + ID = 1, + time = scenario$dose$time, + dose = scenario$dose$amount + ) + + # Add duration for infusions + if (scenario$route == "iv_infusion" && !is.null(scenario$dose$duration)) { + dose_data$duration <- scenario$dose$duration + } + + # Create PKNCA objects + conc_obj <- PKNCAconc(conc_data, conc ~ time | ID) + + if (scenario$route == "iv_infusion" && !is.null(scenario$dose$duration)) { + dose_obj <- PKNCAdose(dose_data, dose ~ time | ID, + route = "intravascular", + duration = "duration" + ) + } else { + dose_obj <- PKNCAdose(dose_data, dose ~ time | ID, + route = get_route_for_pknca(scenario$route) + ) + } + + # Set up intervals - request all parameters up to infinity + intervals <- data.frame( + start = 0, + end = Inf, + cmax = TRUE, + tmax = TRUE, + tlast = TRUE, + clast.obs = TRUE, + auclast = TRUE, + aucall = TRUE, + aumclast = TRUE, + half.life = TRUE, + lambda.z = TRUE, + r.squared = TRUE, + adj.r.squared = TRUE, + lambda.z.n.points = TRUE, + clast.pred = TRUE, + aucinf.obs = TRUE, + aucinf.pred = TRUE, + aumcinf.obs = TRUE, + aumcinf.pred = TRUE, + mrt.obs = TRUE, + tlag = TRUE, + span.ratio = TRUE + ) + + # Add steady-state parameters if tau is specified + if (!is.null(scenario$tau)) { + tau_val <- scenario$tau + intervals$end <- tau_val # Use tau as the interval end + intervals$cmin <- TRUE + intervals$cav <- TRUE + } + + # Add route-specific parameters + if (scenario$route == "iv_bolus") { + intervals$c0 <- TRUE + intervals$vz.obs <- TRUE + intervals$cl.obs <- TRUE + intervals$vss.obs <- TRUE + } else if (scenario$route == "iv_infusion") { + intervals$cl.obs <- TRUE + intervals$vss.obs <- TRUE + intervals$mrt.iv.obs <- TRUE + } else { + intervals$vz.obs <- TRUE + intervals$cl.obs <- TRUE + } + + # Add partial AUC if specified + if (!is.null(scenario$partial_auc_interval)) { + partial_int <- unlist(scenario$partial_auc_interval) + partial_interval <- data.frame( + start = partial_int[1], + end = partial_int[2], + auclast = TRUE + ) + } + + # Set PKNCA options + auc_method <- get_auc_method(scenario$auc_method) + + # Determine BLQ handling + blq_handling <- if (!is.null(scenario$blq_rule)) { + switch(scenario$blq_rule, + "exclude" = "drop", + "zero" = "keep", + "positional" = list(first = "keep", middle = "drop", last = "keep"), + "drop" + ) + } else { + "drop" + } + + # Create PKNCAdata with options + data_obj <- PKNCAdata( + conc_obj, dose_obj, + intervals = intervals, + options = list( + auc.method = auc_method, + conc.blq = blq_handling + ) + ) + + # Run NCA + nca_result <- pk.nca(data_obj) + + # Extract results + result_df <- as.data.frame(nca_result) + + # Convert to named list + param_values <- list() + for (i in 1:nrow(result_df)) { + param_name <- result_df$PPTESTCD[i] + param_value <- result_df$PPORRES[i] + if (!is.na(param_value)) { + param_values[[param_name]] <- param_value + } + } + + # Calculate partial AUC if requested + if (!is.null(scenario$partial_auc_interval)) { + partial_int <- unlist(scenario$partial_auc_interval) + start_t <- partial_int[1] + end_t <- partial_int[2] + partial_auc <- pk.calc.auc( + conc_data$conc, conc_data$time, + interval = c(start_t, end_t), + method = auc_method, + auc.type = "AUClast" + ) + param_values[["partial_auc"]] <- partial_auc + param_values[["partial_auc_start"]] <- start_t + param_values[["partial_auc_end"]] <- end_t + } + + # Store results + results[[scenario$id]] <- list( + id = scenario$id, + name = scenario$name, + pknca_version = as.character(packageVersion("PKNCA")), + auc_method = auc_method, + blq_rule = scenario$blq_rule, + parameters = param_values + ) + + cat(sprintf(" -> Computed %d parameters\n", length(param_values))) + }, + error = function(e) { + cat(sprintf(" -> ERROR: %s\n", e$message)) + results[[scenario$id]] <<- list( + id = scenario$id, + name = scenario$name, + error = e$message + ) + } + ) +} + +# Create output structure +output <- list( + generated_at = format(Sys.time(), "%Y-%m-%dT%H:%M:%S"), + r_version = R.version.string, + pknca_version = as.character(packageVersion("PKNCA")), + scenario_count = length(results), + results = results +) + +# Write to JSON +output_file <- "expected_values.json" +write_json(output, output_file, pretty = TRUE, auto_unbox = TRUE) + +cat(sprintf("\n✓ Generated expected values for %d scenarios\n", length(results))) +cat(sprintf("✓ Saved to: %s\n", output_file)) diff --git a/tests/nca/pknca/test_scenarios.json b/tests/nca/pknca/test_scenarios.json new file mode 100644 index 00000000..8523abd8 --- /dev/null +++ b/tests/nca/pknca/test_scenarios.json @@ -0,0 +1,333 @@ +{ + "version": "1.0", + "description": "Independent test scenarios for NCA cross-validation", + "scenarios": [ + { + "id": "basic_oral_01", + "name": "Basic single-dose oral absorption", + "description": "Standard oral PK profile with clear absorption and elimination phases", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 0.5, 1, 2, 3, 4, 6, 8, 12, 24], + "concentrations": [0, 2.5, 8.0, 12.0, 10.0, 7.5, 4.2, 2.3, 0.7, 0.05], + "test_params": [ + "cmax", + "tmax", + "auc_last", + "auc_inf", + "lambda_z", + "half_life", + "cl_f", + "vz_f" + ] + }, + { + "id": "basic_oral_02", + "name": "Oral with delayed Tmax", + "description": "Slower absorption with Tmax at 4 hours", + "route": "extravascular", + "dose": { "amount": 250, "time": 0 }, + "times": [0, 0.5, 1, 2, 4, 6, 8, 12, 24, 48], + "concentrations": [0, 0.5, 2.0, 5.5, 10.0, 8.5, 6.2, 3.1, 0.8, 0.05], + "test_params": [ + "cmax", + "tmax", + "auc_last", + "auc_inf", + "lambda_z", + "half_life", + "tlag" + ] + }, + { + "id": "iv_bolus_01", + "name": "IV bolus single compartment", + "description": "Monoexponential decline after IV bolus", + "route": "iv_bolus", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 0.25, 0.5, 1, 2, 4, 6, 8, 12], + "concentrations": [10.0, 8.8, 7.8, 6.1, 3.7, 1.4, 0.5, 0.2, 0.03], + "test_params": [ + "c0", + "cmax", + "auc_last", + "auc_inf", + "lambda_z", + "half_life", + "cl", + "vd", + "vss" + ] + }, + { + "id": "iv_bolus_02", + "name": "IV bolus two-compartment", + "description": "Biexponential decline showing distribution phase", + "route": "iv_bolus", + "dose": { "amount": 500, "time": 0 }, + "times": [0, 0.083, 0.25, 0.5, 1, 2, 4, 8, 12, 24], + "concentrations": [ + 50.0, 35.0, 22.0, 15.0, 10.0, 6.5, 3.8, 1.3, 0.45, 0.05 + ], + "test_params": [ + "c0", + "cmax", + "auc_last", + "auc_inf", + "lambda_z", + "half_life" + ] + }, + { + "id": "iv_infusion_01", + "name": "1-hour IV infusion", + "description": "IV infusion over 1 hour", + "route": "iv_infusion", + "dose": { "amount": 200, "time": 0, "duration": 1.0 }, + "times": [0, 0.5, 1, 1.5, 2, 4, 6, 8, 12], + "concentrations": [0, 8.0, 15.0, 12.5, 10.0, 5.0, 2.5, 1.25, 0.3], + "test_params": [ + "cmax", + "tmax", + "auc_last", + "auc_inf", + "lambda_z", + "half_life", + "cl", + "vss" + ] + }, + { + "id": "auc_method_linear", + "name": "AUC comparison - Linear method", + "description": "Profile for comparing AUC calculation methods", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 3, 4, 6, 8, 12], + "concentrations": [0, 5.0, 10.0, 8.0, 6.0, 3.0, 1.5, 0.4], + "auc_method": "linear", + "test_params": ["auc_last", "aumc_last"] + }, + { + "id": "auc_method_linuplogdown", + "name": "AUC comparison - Lin up/log down", + "description": "Same profile with lin-up/log-down method", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 3, 4, 6, 8, 12], + "concentrations": [0, 5.0, 10.0, 8.0, 6.0, 3.0, 1.5, 0.4], + "auc_method": "lin up/log down", + "test_params": ["auc_last", "aumc_last"] + }, + { + "id": "auc_method_linlog", + "name": "AUC comparison - Lin-log method", + "description": "Same profile with lin-log method (linear pre-Tmax, log post-Tmax)", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 3, 4, 6, 8, 12], + "concentrations": [0, 5.0, 10.0, 8.0, 6.0, 3.0, 1.5, 0.4], + "auc_method": "lin-log", + "test_params": ["auc_last", "aumc_last"] + }, + { + "id": "lambda_z_short", + "name": "Lambda-z with minimum points", + "description": "Short terminal phase with 3 points", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 6, 8], + "concentrations": [0, 10.0, 8.0, 4.0, 2.0, 1.0], + "test_params": ["lambda_z", "half_life", "r_squared", "n_points_lambda_z"] + }, + { + "id": "lambda_z_long", + "name": "Lambda-z with many points", + "description": "Extended terminal phase with 8 points", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 6, 8, 12, 16, 24, 36, 48], + "concentrations": [ + 0, 10.0, 12.0, 8.0, 5.5, 3.8, 1.8, 0.85, 0.19, 0.02, 0.002 + ], + "test_params": [ + "lambda_z", + "half_life", + "r_squared", + "adj_r_squared", + "n_points_lambda_z" + ] + }, + { + "id": "blq_middle", + "name": "BLQ in middle of profile", + "description": "Profile with BLQ values between positive concentrations", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 3, 4, 6, 8, 12], + "concentrations": [0, 5.0, 10.0, 0, 6.0, 3.0, 1.5, 0.4], + "blq_indices": [0, 3], + "loq": 0.1, + "blq_rule": "exclude", + "test_params": ["cmax", "tmax", "auc_last", "tlast"] + }, + { + "id": "blq_positional", + "name": "BLQ with positional handling", + "description": "BLQ at start, middle, and end with positional rule", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 8, 12], + "concentrations": [0, 10.0, 0, 4.0, 2.0, 0], + "blq_indices": [0, 2, 5], + "loq": 0.1, + "blq_rule": "positional", + "test_params": ["cmax", "tmax", "auc_last", "tlast", "clast"] + }, + { + "id": "sparse_profile", + "name": "Sparse sampling profile", + "description": "Only 4 concentration time points", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 2, 8, 24], + "concentrations": [0, 12.0, 3.0, 0.2], + "test_params": ["cmax", "tmax", "auc_last"] + }, + { + "id": "flat_cmax", + "name": "Multiple Tmax candidates", + "description": "Profile where Cmax is reached at multiple time points", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 3, 4, 6, 8], + "concentrations": [0, 5.0, 10.0, 10.0, 10.0, 6.0, 3.0], + "test_params": ["cmax", "tmax"] + }, + { + "id": "high_extrapolation", + "name": "High AUC extrapolation percentage", + "description": "Profile where extrapolated portion is significant", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 6], + "concentrations": [0, 10.0, 8.0, 5.0, 3.0], + "test_params": ["auc_last", "auc_inf", "auc_extrap_pct"] + }, + { + "id": "clast_pred_comparison", + "name": "Clast observed vs predicted", + "description": "Compare AUCinf,obs vs AUCinf,pred", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 6, 8, 12], + "concentrations": [0, 8.0, 12.0, 7.0, 4.0, 2.3, 0.8], + "test_params": ["clast_obs", "clast_pred", "auc_inf_obs", "auc_inf_pred"] + }, + { + "id": "partial_auc", + "name": "Partial AUC calculation", + "description": "AUC over specific time interval", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 6, 8, 12, 24], + "concentrations": [0, 5.0, 10.0, 8.0, 5.5, 3.5, 1.5, 0.3], + "partial_auc_interval": [2, 8], + "test_params": ["auc_last", "partial_auc"] + }, + { + "id": "mrt_calculation", + "name": "MRT and related parameters", + "description": "Mean residence time calculation", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 0.5, 1, 2, 4, 6, 8, 12, 24], + "concentrations": [0, 3.0, 8.0, 10.0, 6.5, 4.0, 2.5, 1.0, 0.15], + "test_params": ["auc_inf", "aumc_inf", "mrt"] + }, + { + "id": "tlag_detection", + "name": "Lag time detection", + "description": "Profile with absorption lag", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 0.25, 0.5, 1, 2, 4, 6, 8], + "concentrations": [0, 0, 0, 5.0, 10.0, 6.0, 3.0, 1.5], + "test_params": ["tlag", "cmax", "tmax"] + }, + { + "id": "numerical_precision", + "name": "Numerical precision test", + "description": "Values requiring high precision", + "route": "extravascular", + "dose": { "amount": 1000, "time": 0 }, + "times": [0, 0.5, 1, 2, 4, 8, 12, 24, 48, 72, 96], + "concentrations": [ + 0, 15.234, 45.678, 67.891, 52.345, 28.123, 15.067, 4.321, 0.354, 0.029, + 0.002 + ], + "test_params": ["auc_last", "auc_inf", "lambda_z", "half_life"] + }, + { + "id": "steady_state_oral", + "name": "Steady-state oral dosing", + "description": "Profile at steady state with tau=12h for cmin, cavg, fluctuation, swing", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "tau": 12, + "times": [0, 0.5, 1, 2, 4, 6, 8, 10, 12], + "concentrations": [1.5, 5.0, 10.0, 12.0, 8.0, 5.5, 3.5, 2.2, 1.5], + "test_params": ["cmax", "cmin", "cavg", "auc_tau", "fluctuation", "swing"] + }, + { + "id": "steady_state_iv", + "name": "Steady-state IV infusion", + "description": "IV infusion at steady state with tau=24h", + "route": "iv_infusion", + "dose": { "amount": 500, "time": 0, "duration": 2.0 }, + "tau": 24, + "times": [0, 1, 2, 4, 6, 8, 12, 18, 24], + "concentrations": [2.0, 12.0, 18.0, 14.0, 10.5, 7.5, 4.0, 1.5, 0.5], + "test_params": ["cmax", "cmin", "cavg", "auc_tau", "mrt_iv"] + }, + { + "id": "c0_logslope", + "name": "C0 back-extrapolation test", + "description": "IV bolus with C0 estimated via log-linear back-extrapolation", + "route": "iv_bolus", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 0.5, 1, 2, 4, 6, 8], + "concentrations": [0, 8.0, 6.5, 4.3, 1.9, 0.8, 0.35], + "test_params": ["c0", "auc_last", "auc_inf", "vd", "vss"] + }, + { + "id": "span_ratio_test", + "name": "Span ratio quality metric", + "description": "Test span ratio calculation for lambda-z regression", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 8, 12, 24, 48], + "concentrations": [0, 8.0, 12.0, 9.0, 5.0, 2.8, 0.9, 0.1], + "test_params": [ + "lambda_z", + "half_life", + "span_ratio", + "r_squared", + "n_points_lambda_z" + ] + }, + { + "id": "auc_all_terminal_blq", + "name": "AUCall with terminal BLQ", + "description": "Profile with BLQ values at end to test AUCall vs AUClast", + "route": "extravascular", + "dose": { "amount": 100, "time": 0 }, + "times": [0, 1, 2, 4, 6, 8, 10, 12], + "concentrations": [0, 5.0, 10.0, 6.0, 3.0, 1.5, 0, 0], + "blq_indices": [0, 6, 7], + "loq": 0.5, + "blq_rule": "exclude", + "test_params": ["auc_last", "auc_all", "tlast", "clast"] + } + ] +} diff --git a/tests/nca/test_auc.rs b/tests/nca/test_auc.rs new file mode 100644 index 00000000..a4811755 --- /dev/null +++ b/tests/nca/test_auc.rs @@ -0,0 +1,185 @@ +//! Comprehensive tests for AUC calculation algorithms +//! +//! Tests cover: +//! - Linear trapezoidal rule +//! - Linear up / log down +//! - Edge cases (zeros, single points, etc.) +//! - Partial AUC intervals +//! +//! Note: These tests use the public NCA API via Subject::builder().nca() + +use approx::assert_relative_eq; +use pharmsol::data::Subject; +use pharmsol::nca::{AUCMethod, NCAOptions, NCA}; +use pharmsol::SubjectBuilderExt; + +/// Helper to create a subject from time/concentration arrays +fn build_subject(times: &[f64], concs: &[f64]) -> Subject { + let mut builder = Subject::builder("test").bolus(0.0, 100.0, 0); + for (&t, &c) in times.iter().zip(concs.iter()) { + builder = builder.observation(t, c, 0); + } + builder.build() +} + +#[test] +fn test_linear_trapezoidal_simple_decreasing() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0]; + let concs = vec![10.0, 8.0, 6.0, 4.0, 2.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Manual calculation: (10+8)/2*1 + (8+6)/2*1 + (6+4)/2*2 + (4+2)/2*4 = 38.0 + assert_relative_eq!(result.exposure.auc_last, 38.0, epsilon = 1e-6); +} + +#[test] +fn test_linear_trapezoidal_exponential_decay() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let concs = vec![100.0, 90.48, 81.87, 67.03, 44.93, 30.12, 9.07]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // For exponential decay with lambda = 0.1, true AUC to 24h is around 909 + assert!( + result.exposure.auc_last > 900.0 && result.exposure.auc_last < 950.0, + "AUClast = {} not in expected range", + result.exposure.auc_last + ); +} + +#[test] +fn test_linear_up_log_down() { + let times = vec![0.0, 0.5, 1.0, 2.0, 4.0, 8.0]; + let concs = vec![0.0, 5.0, 8.0, 6.0, 3.0, 1.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::LinUpLogDown); + + let result = subject.nca(&options).expect("NCA should succeed"); + + assert!(result.exposure.auc_last > 0.0); + assert!(result.exposure.auc_last < 50.0); +} + +#[test] +fn test_auc_with_zero_concentration() { + let times = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let concs = vec![10.0, 5.0, 0.0, 0.0, 0.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // NCA calculates AUC to Tlast (last positive concentration) + // Tlast = 1.0 (concentration 5.0), so AUC is only segment 1: (10+5)/2*1 = 7.5 + assert_relative_eq!(result.exposure.auc_last, 7.5, epsilon = 1e-6); + assert!(result.exposure.auc_last.is_finite()); +} + +#[test] +fn test_auc_two_points() { + let times = vec![0.0, 4.0]; + let concs = vec![10.0, 6.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // (10+6)/2 * 4 = 32.0 + assert_relative_eq!(result.exposure.auc_last, 32.0, epsilon = 1e-6); +} + +#[test] +fn test_auc_plateau() { + let times = vec![0.0, 1.0, 2.0, 3.0, 4.0]; + let concs = vec![5.0, 5.0, 5.0, 5.0, 5.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // 5.0 * 4.0 = 20.0 + assert_relative_eq!(result.exposure.auc_last, 20.0, epsilon = 1e-6); +} + +#[test] +fn test_auc_unequal_spacing() { + let times = vec![0.0, 0.25, 1.0, 2.5, 8.0]; + let concs = vec![100.0, 95.0, 80.0, 55.0, 20.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_auc_method(AUCMethod::Linear); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Total: 397.5 + assert_relative_eq!(result.exposure.auc_last, 397.5, epsilon = 1e-6); +} + +#[test] +fn test_auc_methods_comparison() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = vec![100.0, 86.07, 74.08, 54.88, 30.12, 16.53]; + + let subject = build_subject(×, &concs); + + let options_linear = NCAOptions::default().with_auc_method(AUCMethod::Linear); + let options_linlog = NCAOptions::default().with_auc_method(AUCMethod::LinUpLogDown); + + let result_linear = subject.nca(&options_linear).unwrap(); + let result_linlog = subject.nca(&options_linlog).unwrap(); + + let auc_linear = result_linear.exposure.auc_last; + let auc_linlog = result_linlog.exposure.auc_last; + + // Both should be reasonably close (within 5%) + let true_auc = 555.6; + assert!((auc_linear - true_auc).abs() / true_auc < 0.05); + assert!((auc_linlog - true_auc).abs() / true_auc < 0.05); +} + +#[test] +fn test_partial_auc() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = vec![100.0, 90.0, 80.0, 60.0, 35.0, 20.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default() + .with_auc_method(AUCMethod::Linear) + .with_auc_interval(2.0, 8.0); + + let result = subject.nca(&options).expect("NCA should succeed"); + + if let Some(auc_partial) = result.exposure.auc_partial { + // (80+60)/2*2 + (60+35)/2*4 = 330 + assert_relative_eq!(auc_partial, 330.0, epsilon = 1.0); + } +} + +#[test] +fn test_auc_inf_calculation() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.1; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + if let Some(auc_inf) = result.exposure.auc_inf_obs { + assert!(auc_inf > result.exposure.auc_last); + // True AUCinf = C0/lambda = 100/0.1 = 1000 + assert_relative_eq!(auc_inf, 1000.0, epsilon = 50.0); + } +} diff --git a/tests/nca/test_params.rs b/tests/nca/test_params.rs new file mode 100644 index 00000000..d5285de3 --- /dev/null +++ b/tests/nca/test_params.rs @@ -0,0 +1,211 @@ +//! Tests for NCA parameter calculations +//! +//! Tests all derived parameters via the public API: +//! - Clearance +//! - Volume of distribution +//! - Half-life +//! - Mean residence time +//! - Steady-state parameters +//! +//! Note: These tests use the public NCA API via Subject::builder().nca() + +use approx::assert_relative_eq; +use pharmsol::data::Subject; +use pharmsol::nca::{LambdaZOptions, NCAOptions, NCA}; +use pharmsol::SubjectBuilderExt; + +/// Helper to create a subject from time/concentration arrays with a specific dose +fn build_subject_with_dose(times: &[f64], concs: &[f64], dose: f64) -> Subject { + let mut builder = Subject::builder("test").bolus(0.0, dose, 0); + for (&t, &c) in times.iter().zip(concs.iter()) { + builder = builder.observation(t, c, 0); + } + builder.build() +} + +#[test] +fn test_clearance_calculation() { + // IV-like profile with known parameters + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.1; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + let dose = 1000.0; + + let subject = build_subject_with_dose(×, &concs, dose); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // If we have clearance, verify it's reasonable + // CL = Dose / AUCinf, for this profile AUCinf should be around 1000 + if let Some(ref clearance) = result.clearance { + // CL = 1000 / 1000 = 1.0 L/h (approximately) + assert!(clearance.cl_f > 0.5 && clearance.cl_f < 2.0); + } +} + +#[test] +fn test_volume_distribution() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.1; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + let dose = 1000.0; + + let subject = build_subject_with_dose(×, &concs, dose); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Vz = CL / lambda_z + // If CL ~ 1.0 and lambda ~ 0.1, then Vz ~ 10 L + if let Some(ref clearance) = result.clearance { + assert!(clearance.vz_f > 5.0 && clearance.vz_f < 20.0); + } +} + +#[test] +fn test_half_life() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.0693; // ln(2)/10 = half-life of 10h + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + + let subject = build_subject_with_dose(×, &concs, 100.0); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.90, + min_span_ratio: 1.0, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + if let Some(ref terminal) = result.terminal { + // Half-life should be close to 10 hours + assert_relative_eq!(terminal.half_life, 10.0, epsilon = 1.0); + } +} + +#[test] +fn test_cmax_tmax() { + // Typical oral PK profile + let times = vec![0.0, 0.5, 1.0, 2.0, 4.0, 8.0]; + let concs = vec![0.0, 50.0, 80.0, 90.0, 60.0, 30.0]; + + let subject = build_subject_with_dose(×, &concs, 100.0); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + assert_relative_eq!(result.exposure.cmax, 90.0, epsilon = 0.001); + assert_relative_eq!(result.exposure.tmax, 2.0, epsilon = 0.001); +} + +#[test] +fn test_iv_bolus_cmax_at_first_point() { + // IV bolus - Cmax at t=0 + let times = vec![0.0, 1.0, 2.0, 4.0]; + let concs = vec![100.0, 80.0, 60.0, 40.0]; + + let subject = build_subject_with_dose(×, &concs, 100.0); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + assert_relative_eq!(result.exposure.cmax, 100.0, epsilon = 0.001); + assert_relative_eq!(result.exposure.tmax, 0.0, epsilon = 0.001); +} + +#[test] +fn test_clast_tlast() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0]; + let concs = vec![100.0, 80.0, 60.0, 30.0, 10.0]; + + let subject = build_subject_with_dose(×, &concs, 100.0); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Last positive concentration + assert_relative_eq!(result.exposure.clast, 10.0, epsilon = 0.001); + assert_relative_eq!(result.exposure.tlast, 8.0, epsilon = 0.001); +} + +#[test] +fn test_steady_state_parameters() { + // Steady-state profile with dosing interval + let times = vec![0.0, 1.0, 2.0, 4.0, 6.0, 8.0, 12.0]; + let concs = vec![50.0, 80.0, 70.0, 55.0, 48.0, 45.0, 50.0]; + let tau = 12.0; + + let subject = build_subject_with_dose(×, &concs, 100.0); + let options = NCAOptions::default().with_tau(tau); + + let result = subject.nca(&options).expect("NCA should succeed"); + + if let Some(ref ss) = result.steady_state { + // Cmin should be around 45-50 + assert!(ss.cmin > 40.0 && ss.cmin < 55.0); + // Cavg = AUC_tau / tau + assert!(ss.cavg > 50.0 && ss.cavg < 70.0); + // Fluctuation should be moderate + assert!(ss.fluctuation > 0.0); + } +} + +#[test] +fn test_extrapolation_percent() { + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = vec![100.0, 80.0, 65.0, 45.0, 25.0, 15.0]; + + let subject = build_subject_with_dose(×, &concs, 100.0); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Extrapolation percent should be reasonable for good data + if let Some(extrap_pct) = result.exposure.auc_pct_extrap_obs { + // For well-sampled data, extrapolation should be under 30% + assert!(extrap_pct < 50.0, "Extrapolation too high: {}", extrap_pct); + } +} + +#[test] +fn test_complete_parameter_workflow() { + // Complete workflow: all parameters from raw data + let times = vec![0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let concs = vec![100.0, 91.0, 83.0, 70.0, 49.0, 24.0, 12.0, 1.5]; + let dose = 1000.0; + + let subject = build_subject_with_dose(×, &concs, dose); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Verify basic parameters exist + assert_eq!(result.exposure.cmax, 100.0); + assert_eq!(result.exposure.tmax, 0.0); + assert!(result.exposure.auc_last > 400.0 && result.exposure.auc_last < 600.0); + + // If terminal phase estimated + if let Some(ref terminal) = result.terminal { + assert!(terminal.lambda_z > 0.05 && terminal.lambda_z < 0.20); + assert!(terminal.half_life > 3.0 && terminal.half_life < 15.0); + } + + // If clearance calculated + if let Some(ref clearance) = result.clearance { + assert!(clearance.cl_f > 0.0); + assert!(clearance.vz_f > 0.0); + } + + println!("Complete parameter set:"); + println!(" Cmax: {:.2}", result.exposure.cmax); + println!(" Tmax: {:.2}", result.exposure.tmax); + println!(" AUClast: {:.2}", result.exposure.auc_last); + if let Some(auc_inf) = result.exposure.auc_inf_obs { + println!(" AUCinf: {:.2}", auc_inf); + } + if let Some(ref terminal) = result.terminal { + println!(" Lambda_z: {:.4}", terminal.lambda_z); + println!(" Half-life: {:.2}", terminal.half_life); + } +} diff --git a/tests/nca/test_pknca.rs b/tests/nca/test_pknca.rs new file mode 100644 index 00000000..dea294d2 --- /dev/null +++ b/tests/nca/test_pknca.rs @@ -0,0 +1,523 @@ +//! PKNCA Cross-Validation Tests +//! +//! This module validates pharmsol's NCA implementation against expected values +//! generated by PKNCA (the gold-standard R package for NCA). +//! +//! The validation uses a clean-room approach: +//! 1. Test scenarios are independently designed based on PK principles +//! 2. PKNCA computes expected values (run `Rscript generate_expected.R`) +//! 3. This module compares pharmsol's results against those expected values +//! +//! Run with: `cargo test pknca_validation` + +use pharmsol::nca::{AUCMethod, BLQRule, NCAOptions, Route, RouteParams}; +use pharmsol::{prelude::*, Censor}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Tolerance for floating-point comparisons +/// NCA calculations should match within 0.1% for most parameters +const RELATIVE_TOLERANCE: f64 = 0.001; + +/// Absolute tolerance for very small values (near zero) +const ABSOLUTE_TOLERANCE: f64 = 1e-10; + +// ============================================================================= +// JSON Structures for Test Data +// ============================================================================= + +#[derive(Debug, Deserialize)] +struct TestScenarios { + scenarios: Vec, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Scenario { + id: String, + name: String, + route: String, + dose: DoseInfo, + times: Vec, + concentrations: Vec, + #[serde(default)] + auc_method: Option, + #[serde(default)] + blq_rule: Option, + #[serde(default)] + blq_indices: Option>, + #[serde(default)] + loq: Option, + #[serde(default)] + partial_auc_interval: Option>, + #[serde(default)] + tau: Option, + test_params: Vec, +} + +#[derive(Debug, Deserialize)] +struct DoseInfo { + amount: f64, + time: f64, + #[serde(default)] + duration: Option, +} + +#[derive(Debug, Deserialize)] +struct ExpectedValues { + generated_at: String, + pknca_version: String, + results: HashMap, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct ScenarioResult { + id: String, + name: String, + #[serde(default)] + parameters: HashMap, + #[serde(default)] + error: Option, +} + +// ============================================================================= +// Test Utilities +// ============================================================================= + +/// Check if two floating-point values are approximately equal +fn approx_eq(a: f64, b: f64, rel_tol: f64, abs_tol: f64) -> bool { + if a.is_nan() && b.is_nan() { + return true; // Both NaN is considered equal for our purposes + } + if a.is_nan() || b.is_nan() { + return false; + } + if a.is_infinite() && b.is_infinite() { + return a.signum() == b.signum(); + } + if a.is_infinite() || b.is_infinite() { + return false; + } + + let diff = (a - b).abs(); + let max_val = a.abs().max(b.abs()); + + diff <= abs_tol || diff <= rel_tol * max_val +} + +/// Map PKNCA parameter names to pharmsol field names +fn map_param_name(pknca_name: &str) -> &str { + match pknca_name { + "cmax" => "cmax", + "tmax" => "tmax", + "tlast" => "tlast", + "clast.obs" => "clast", + "auclast" => "auc_last", + "aucall" => "auc_all", + "aumclast" => "aumc_last", + "aucinf.obs" => "auc_inf_obs", + "aucinf.pred" => "auc_inf_pred", + "aumcinf.obs" => "aumc_inf", + "lambda.z" => "lambda_z", + "half.life" => "half_life", + "r.squared" => "r_squared", + "adj.r.squared" => "adj_r_squared", + "lambda.z.n.points" => "n_points", + "span.ratio" => "span_ratio", + "clast.pred" => "clast_pred", + "mrt.obs" => "mrt", + "mrt.iv.obs" => "mrt_iv", + "tlag" => "tlag", + "c0" => "c0", + "cl.obs" => "cl", + "vd.obs" => "vd", + "vz.obs" => "vz", + "vss.obs" => "vss", + "auc_extrap_pct" => "auc_pct_extrap", + "cmin" => "cmin", + "cav" => "cavg", + "auc_tau" => "auc_tau", + "fluctuation" => "fluctuation", + "swing" => "swing", + _ => pknca_name, + } +} + +/// Convert scenario route string to pharmsol Route +#[allow(dead_code)] +fn parse_route(route: &str) -> Route { + match route { + "iv_bolus" => Route::IVBolus, + "iv_infusion" => Route::IVInfusion, + _ => Route::Extravascular, + } +} + +/// Convert AUC method string to pharmsol AUCMethod +fn parse_auc_method(method: Option<&str>) -> AUCMethod { + match method { + Some("linear") => AUCMethod::Linear, + Some("lin-log") => AUCMethod::LinLog, + _ => AUCMethod::LinUpLogDown, + } +} + +/// Convert BLQ rule string to pharmsol BLQRule +fn parse_blq_rule(rule: Option<&str>) -> BLQRule { + match rule { + Some("zero") => BLQRule::Zero, + Some("loq_over_2") => BLQRule::LoqOver2, + Some("positional") => BLQRule::Positional, + _ => BLQRule::Exclude, + } +} + +// ============================================================================= +// Main Validation Function +// ============================================================================= + +/// Run validation for a single scenario +fn validate_scenario( + scenario: &Scenario, + expected: &ScenarioResult, +) -> Result, String> { + // Skip if PKNCA had an error + if let Some(err) = &expected.error { + return Err(format!("PKNCA error: {}", err)); + } + + // Build pharmsol Subject + let mut builder = Subject::builder(&scenario.id); + + // Add dose based on route + match scenario.route.as_str() { + "iv_bolus" => { + builder = builder.bolus(scenario.dose.time, scenario.dose.amount, 0); + } + "iv_infusion" => { + let duration = scenario.dose.duration.unwrap_or(1.0); + builder = builder.infusion(scenario.dose.time, scenario.dose.amount, 0, duration); + } + _ => { + builder = builder.bolus(scenario.dose.time, scenario.dose.amount, 0); + } + } + + // Add observations + let loq = scenario.loq.unwrap_or(0.1); + let blq_indices: Vec = scenario.blq_indices.clone().unwrap_or_default(); + + for (i, (&time, &conc)) in scenario + .times + .iter() + .zip(&scenario.concentrations) + .enumerate() + { + if blq_indices.contains(&i) { + builder = builder.censored_observation(time, loq, 0, Censor::BLOQ); + } else { + builder = builder.observation(time, conc, 0); + } + } + + let subject = builder.build(); + + // Configure NCA options + let mut options = NCAOptions::default() + .with_auc_method(parse_auc_method(scenario.auc_method.as_deref())) + .with_blq_rule(parse_blq_rule(scenario.blq_rule.as_deref())); + + if let Some(interval) = &scenario.partial_auc_interval { + if interval.len() == 2 { + options = options.with_auc_interval(interval[0], interval[1]); + } + } + + // Add tau for steady-state analysis + if let Some(tau) = scenario.tau { + options = options.with_tau(tau); + } + + // Run NCA + let result = subject + .nca(&options) + .map_err(|e| format!("NCA failed: {e}"))?; + + // Compare parameters + let mut comparisons = Vec::new(); + + for (pknca_name, &expected_val) in &expected.parameters { + let pharmsol_name = map_param_name(pknca_name); + + // Extract pharmsol value based on parameter name + let pharmsol_val = match pharmsol_name { + "cmax" => Some(result.exposure.cmax), + "tmax" => Some(result.exposure.tmax), + "tlast" => Some(result.exposure.tlast), + "clast" => Some(result.exposure.clast), + "auc_last" => Some(result.exposure.auc_last), + "aumc_last" => result.exposure.aumc_last, + "auc_inf" | "auc_inf_obs" => result.exposure.auc_inf_obs, + "auc_inf_pred" => result.exposure.auc_inf_pred, + "aumc_inf" => result.exposure.aumc_inf, + "auc_pct_extrap" | "auc_pct_extrap_obs" => result.exposure.auc_pct_extrap_obs, + "auc_pct_extrap_pred" => result.exposure.auc_pct_extrap_pred, + "lambda_z" => result.terminal.as_ref().map(|t| t.lambda_z), + "half_life" => result.terminal.as_ref().map(|t| t.half_life), + "mrt" => result.terminal.as_ref().and_then(|t| t.mrt), + "mrt_iv" => result.route_params.as_ref().and_then(|rp| match rp { + RouteParams::IVInfusion(ref iv) => iv.mrt_iv, + _ => None, + }), + "r_squared" => result + .terminal + .as_ref() + .and_then(|t| t.regression.as_ref()) + .map(|r| r.r_squared), + "adj_r_squared" => result + .terminal + .as_ref() + .and_then(|t| t.regression.as_ref()) + .map(|r| r.adj_r_squared), + "n_points" => result + .terminal + .as_ref() + .and_then(|t| t.regression.as_ref()) + .map(|r| r.n_points as f64), + "span_ratio" => result + .terminal + .as_ref() + .and_then(|t| t.regression.as_ref()) + .map(|r| r.span_ratio), + "tlag" => result.exposure.tlag, + "c0" => result.route_params.as_ref().and_then(|rp| match rp { + RouteParams::IVBolus(ref iv) => Some(iv.c0), + _ => None, + }), + "vd" => result.route_params.as_ref().and_then(|rp| match rp { + RouteParams::IVBolus(ref iv) => Some(iv.vd), + _ => None, + }), + "vss" => result.clearance.as_ref().and_then(|c| c.vss), + "cl" | "cl_f" => result.clearance.as_ref().map(|c| c.cl_f), + "vz" | "vz_f" => result.clearance.as_ref().map(|c| c.vz_f), + // Steady-state parameters + "cmin" => result.steady_state.as_ref().map(|ss| ss.cmin), + "cavg" => result.steady_state.as_ref().map(|ss| ss.cavg), + "auc_tau" => result.steady_state.as_ref().map(|ss| ss.auc_tau), + "fluctuation" => result.steady_state.as_ref().map(|ss| ss.fluctuation), + "swing" => result.steady_state.as_ref().map(|ss| ss.swing), + _ => None, + }; + + if let Some(pv) = pharmsol_val { + let matches = approx_eq(pv, expected_val, RELATIVE_TOLERANCE, ABSOLUTE_TOLERANCE); + comparisons.push((pknca_name.clone(), expected_val, pv, matches)); + } + } + + Ok(comparisons) +} + +// ============================================================================= +// Test Entry Point +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + /// Load test scenarios and expected values, run validation + #[test] + fn validate_against_pknca() { + let base_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/nca/pknca/"); + + // Load scenarios + let scenarios_path = base_path.join("test_scenarios.json"); + let scenarios_json = fs::read_to_string(&scenarios_path).expect(&format!( + "Failed to read test_scenarios.json from {:?}", + scenarios_path + )); + let scenarios: TestScenarios = + serde_json::from_str(&scenarios_json).expect("Failed to parse test_scenarios.json"); + + // Try to load expected values (may not exist if R script hasn't been run) + let expected_path = base_path.join("expected_values.json"); + let expected_values: Option = fs::read_to_string(&expected_path) + .ok() + .and_then(|json| serde_json::from_str(&json).ok()); + + if expected_values.is_none() { + println!("\n⚠️ Expected values not found!"); + println!(" Run: cd tests/pknca_validation && Rscript generate_expected.R"); + println!(" Skipping cross-validation tests.\n"); + return; + } + + let expected = expected_values.unwrap(); + println!("\n═══════════════════════════════════════════════════════════════"); + println!("PKNCA Cross-Validation Results"); + println!("Generated: {}", expected.generated_at); + println!("PKNCA Version: {}", expected.pknca_version); + println!("═══════════════════════════════════════════════════════════════\n"); + + // Known differences: currently empty - all differences have been resolved! + // Keeping this infrastructure in case future differences are discovered. + let known_differences: Vec<(&str, &str, &str)> = vec![]; + + let mut total_params = 0; + let mut passed_params = 0; + let mut known_diff_params = 0; + let mut failed_scenarios = Vec::new(); + + for scenario in &scenarios.scenarios { + print!("Testing: {} ... ", scenario.name); + + if let Some(expected_result) = expected.results.get(&scenario.id) { + match validate_scenario(scenario, expected_result) { + Ok(comparisons) => { + let mut scenario_passed = 0; + let mut scenario_known_diff = 0; + let scenario_total = comparisons.len(); + total_params += scenario_total; + + let mut failures = Vec::new(); + let mut known_diffs = Vec::new(); + + for (name, expected_val, actual_val, matched) in &comparisons { + if *matched { + scenario_passed += 1; + } else { + // Check if this is a known difference + let is_known = known_differences.iter().any(|(sid, pname, _)| { + *sid == scenario.id && *pname == name.as_str() + }); + if is_known { + scenario_known_diff += 1; + let reason = known_differences + .iter() + .find(|(sid, pname, _)| { + *sid == scenario.id && *pname == name.as_str() + }) + .map(|(_, _, r)| *r) + .unwrap_or("convention difference"); + known_diffs.push(( + name.clone(), + *expected_val, + *actual_val, + reason, + )); + } else { + failures.push((name.clone(), *expected_val, *actual_val)); + } + } + } + + passed_params += scenario_passed; + known_diff_params += scenario_known_diff; + + if failures.is_empty() { + if known_diffs.is_empty() { + println!("✓ ({}/{} params)", scenario_passed, scenario_total); + } else { + println!( + "✓ ({}/{} params, {} known diffs)", + scenario_passed, + scenario_total, + known_diffs.len() + ); + for (name, expected_val, actual_val, reason) in &known_diffs { + println!( + " [known] {} - expected: {:.6}, got: {:.6} ({})", + name, expected_val, actual_val, reason + ); + } + } + } else { + println!( + "✗ ({}/{} params, {} failures)", + scenario_passed, + scenario_total, + failures.len() + ); + for (name, expected_val, actual_val) in &failures { + println!( + " {} - expected: {:.6}, got: {:.6}", + name, expected_val, actual_val + ); + } + if !known_diffs.is_empty() { + for (name, expected_val, actual_val, reason) in &known_diffs { + println!( + " [known] {} - expected: {:.6}, got: {:.6} ({})", + name, expected_val, actual_val, reason + ); + } + } + failed_scenarios.push(scenario.id.clone()); + } + } + Err(e) => { + println!("⚠ {}", e); + } + } + } else { + println!("⚠ No expected values"); + } + } + + println!("\n═══════════════════════════════════════════════════════════════"); + println!( + "Summary: {}/{} parameters matched ({:.1}%)", + passed_params, + total_params, + (passed_params as f64 / total_params as f64) * 100.0 + ); + if known_diff_params > 0 { + println!( + "Known differences: {} (documented convention differences)", + known_diff_params + ); + } + if !failed_scenarios.is_empty() { + println!("Failed scenarios: {:?}", failed_scenarios); + } + println!("═══════════════════════════════════════════════════════════════\n"); + + // Fail test only for unexpected failures (not known differences) + assert!( + failed_scenarios.is_empty(), + "Some scenarios failed validation with unexpected differences" + ); + } + + /// Quick sanity test that runs without PKNCA expected values + #[test] + fn basic_nca_sanity_check() { + // Simple IV bolus test + let subject = Subject::builder("sanity") + .bolus(0.0, 100.0, 0) + .observation(0.0, 10.0, 0) + .observation(1.0, 6.0, 0) + .observation(2.0, 3.6, 0) + .observation(4.0, 1.3, 0) + .observation(8.0, 0.17, 0) + .build(); + + let options = NCAOptions::default(); + let result = subject.nca(&options).expect("NCA should succeed"); + + // Basic sanity checks + assert_eq!(result.exposure.cmax, 10.0); + assert_eq!(result.exposure.tmax, 0.0); + assert!(result.exposure.auc_last > 0.0); + assert!(result.terminal.is_some()); + + let terminal = result.terminal.as_ref().unwrap(); + assert!(terminal.lambda_z > 0.0); + assert!(terminal.half_life > 0.0); + } +} diff --git a/tests/nca/test_quality.rs b/tests/nca/test_quality.rs new file mode 100644 index 00000000..1f149324 --- /dev/null +++ b/tests/nca/test_quality.rs @@ -0,0 +1,207 @@ +//! Tests for quality assessment and acceptance criteria +//! +//! Tests verify that the NCA module properly flags quality issues like: +//! - Poor R-squared for lambda_z regression +//! - High AUC extrapolation percentage +//! - Insufficient span ratio +//! +//! Note: These tests use the public NCA API via Subject::builder().nca() + +use pharmsol::data::Subject; +use pharmsol::nca::{LambdaZOptions, NCAOptions, Warning, NCA}; +use pharmsol::SubjectBuilderExt; + +/// Helper to create a subject from time/concentration arrays +fn build_subject(times: &[f64], concs: &[f64]) -> Subject { + let mut builder = Subject::builder("test").bolus(0.0, 100.0, 0); + for (&t, &c) in times.iter().zip(concs.iter()) { + builder = builder.observation(t, c, 0); + } + builder.build() +} + +#[test] +fn test_quality_good_data_no_warnings() { + // Well-behaved exponential decay + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.1; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Good data should have few or no warnings + // (may have some due to extrapolation) + println!("Warnings for good data: {:?}", result.quality.warnings); +} + +#[test] +fn test_quality_high_extrapolation_warning() { + // Short sampling - will have high extrapolation + let times = vec![0.0, 1.0, 2.0, 4.0]; + let concs = vec![100.0, 80.0, 60.0, 40.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.80, + min_span_ratio: 1.0, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // May have high extrapolation warning + let has_high_extrap = result + .quality + .warnings + .iter() + .any(|w| matches!(w, Warning::HighExtrapolation { .. })); + println!( + "Has high extrapolation warning: {}, warnings: {:?}", + has_high_extrap, result.quality.warnings + ); +} + +#[test] +fn test_quality_lambda_z_not_estimable() { + // Too few points for lambda_z + let times = vec![0.0, 1.0]; + let concs = vec![100.0, 50.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Should not have terminal phase + assert!(result.terminal.is_none()); + + // Should have warning about lambda_z not estimable + let has_lz_warning = result + .quality + .warnings + .iter() + .any(|w| matches!(w, Warning::LambdaZNotEstimable)); + assert!(has_lz_warning, "Expected LambdaZNotEstimable warning"); +} + +#[test] +fn test_quality_poor_fit_warning() { + // Noisy data that should give poor fit + let times = vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]; + let concs = vec![100.0, 60.0, 80.0, 40.0, 50.0, 30.0]; // Very noisy + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.70, // Very lenient + min_span_ratio: 0.5, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + println!( + "Terminal phase: {:?}, Warnings: {:?}", + result.terminal, result.quality.warnings + ); +} + +#[test] +fn test_quality_short_terminal_phase() { + // Very short terminal phase span + let times = vec![0.0, 0.5, 1.0, 1.5, 2.0]; + let concs = vec![100.0, 90.0, 80.0, 70.0, 60.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.80, + min_span_ratio: 0.5, // Very lenient + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Check for short terminal phase warning + let has_short_warning = result + .quality + .warnings + .iter() + .any(|w| matches!(w, Warning::ShortTerminalPhase { .. })); + println!( + "Has short terminal phase warning: {}, warnings: {:?}", + has_short_warning, result.quality.warnings + ); +} + +#[test] +fn test_regression_stats_available() { + // Good data should have regression statistics + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.1; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + if let Some(ref terminal) = result.terminal { + if let Some(ref stats) = terminal.regression { + // Good fit should have high R-squared + assert!( + stats.r_squared > 0.95, + "R-squared too low: {}", + stats.r_squared + ); + assert!(stats.adj_r_squared > 0.95); + assert!(stats.n_points >= 3); + assert!(stats.span_ratio > 2.0); + } + } +} + +#[test] +fn test_bioequivalence_preset_quality() { + // Test BE preset quality thresholds + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let lambda: f64 = 0.1; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + + let subject = build_subject(×, &concs); + let options = NCAOptions::bioequivalence(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // BE preset should have stricter quality requirements + // Good data should still pass + if let Some(ref terminal) = result.terminal { + if let Some(ref stats) = terminal.regression { + assert!( + stats.r_squared >= 0.90, + "BE threshold requires R-squared >= 0.90" + ); + } + } +} + +#[test] +fn test_sparse_preset_quality() { + // Sparse preset should be more lenient + let times = vec![0.0, 2.0, 8.0, 24.0]; + let concs = vec![100.0, 70.0, 35.0, 10.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::sparse(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Sparse preset should still be able to estimate terminal phase + // with fewer points + println!( + "Sparse data - Terminal: {:?}, Warnings: {:?}", + result.terminal.is_some(), + result.quality.warnings + ); +} diff --git a/tests/nca/test_terminal.rs b/tests/nca/test_terminal.rs new file mode 100644 index 00000000..7199eb21 --- /dev/null +++ b/tests/nca/test_terminal.rs @@ -0,0 +1,271 @@ +//! Tests for terminal phase (lambda_z) calculations +//! +//! Tests various methods using the public NCA API: +//! - Adjusted R² +//! - R² +//! - Manual point selection +//! +//! Note: Tests use Subject::builder() with .nca() as the entry point, +//! which internally computes lambda_z via regression on the terminal phase. + +use approx::assert_relative_eq; +use pharmsol::data::Subject; +use pharmsol::nca::{LambdaZMethod, LambdaZOptions, NCAOptions, NCA}; +use pharmsol::SubjectBuilderExt; + +/// Helper to create a subject from time/concentration arrays +fn build_subject(times: &[f64], concs: &[f64]) -> Subject { + let mut builder = Subject::builder("test").bolus(0.0, 100.0, 0); // Dose at depot + for (&t, &c) in times.iter().zip(concs.iter()) { + builder = builder.observation(t, c, 0); + } + builder.build() +} + +#[test] +fn test_lambda_z_simple_exponential() { + // Perfect exponential decay: C = 100 * e^(-0.1*t) + // lambda_z should be exactly 0.1 + let times = vec![0.0, 4.0, 8.0, 12.0, 16.0, 24.0]; + let concs = vec![ + 100.0, 67.03, // 100 * e^(-0.1*4) + 44.93, // 100 * e^(-0.1*8) + 30.12, // 100 * e^(-0.1*12) + 20.19, // 100 * e^(-0.1*16) + 9.07, // 100 * e^(-0.1*24) + ]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.90, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Terminal params should exist + let terminal = result + .terminal + .as_ref() + .expect("Terminal phase should be estimated"); + + // Lambda_z should be very close to 0.1 + assert_relative_eq!(terminal.lambda_z, 0.1, epsilon = 0.01); + + // R² should be high (check regression stats in terminal params) + if let Some(ref stats) = terminal.regression { + assert!(stats.r_squared > 0.99); + assert!(stats.adj_r_squared > 0.99); + } +} + +#[test] +fn test_lambda_z_with_noise() { + // Exponential decay with some realistic noise + let times = vec![0.0, 4.0, 6.0, 8.0, 12.0, 24.0]; + let concs = vec![100.0, 65.0, 52.0, 43.0, 29.5, 9.5]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.90, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + let terminal = result + .terminal + .as_ref() + .expect("Terminal phase should be estimated"); + + // Lambda should be around 0.09-0.11 + assert!( + terminal.lambda_z > 0.08 && terminal.lambda_z < 0.12, + "lambda_z = {} not in expected range", + terminal.lambda_z + ); + + // R² should still be reasonable + if let Some(ref stats) = terminal.regression { + assert!(stats.r_squared > 0.95); + } +} + +#[test] +fn test_lambda_z_manual_points() { + // Test using manual N points method + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let concs = vec![0.0, 80.0, 100.0, 80.0, 50.0, 30.0, 10.0]; + + let subject = build_subject(×, &concs); + + // Use manual 3 points + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + method: LambdaZMethod::Manual(3), + min_r_squared: 0.80, + min_span_ratio: 1.0, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + if let Some(ref terminal) = result.terminal { + if let Some(ref stats) = terminal.regression { + // Should use exactly 3 points + assert_eq!(stats.n_points, 3); + // Should use terminal points + assert_eq!(stats.time_last, 24.0); + } + } +} + +#[test] +fn test_lambda_z_insufficient_points() { + // Only 2 points - insufficient for terminal phase + let times = vec![0.0, 2.0]; + let concs = vec![100.0, 50.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Terminal params should be None due to insufficient data + assert!( + result.terminal.is_none(), + "Terminal phase should not be estimated with only 2 points" + ); +} + +#[test] +fn test_adjusted_r2_vs_r2_method() { + let times = vec![0.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]; + let concs = vec![100.0, 70.0, 55.0, 45.0, 30.0, 22.0, 10.0]; + + let subject = build_subject(×, &concs); + + // Test with AdjR2 method (default) + let options_adj = NCAOptions::default().with_lambda_z(LambdaZOptions { + method: LambdaZMethod::AdjR2, + min_r_squared: 0.90, + ..Default::default() + }); + + let result_adj = subject.nca(&options_adj).expect("NCA should succeed"); + + if let Some(ref terminal) = result_adj.terminal { + if let Some(ref stats) = terminal.regression { + // Adjusted R² should be ≤ R² + assert!(stats.adj_r_squared <= stats.r_squared); + // For good fit, they should be close + assert!((stats.r_squared - stats.adj_r_squared) < 0.05); + } + } +} + +#[test] +fn test_half_life_from_lambda_z() { + // Build a subject with known lambda_z ≈ 0.0693 (half-life = 10h) + let lambda: f64 = 0.0693; + let times = vec![0.0, 5.0, 10.0, 15.0, 20.0]; + let concs: Vec = times.iter().map(|&t| 100.0 * (-lambda * t).exp()).collect(); + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.90, + min_span_ratio: 1.0, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + let terminal = result + .terminal + .as_ref() + .expect("Terminal phase should be estimated"); + + // Half-life should be close to 10.0 hours + assert_relative_eq!(terminal.half_life, 10.0, epsilon = 0.5); +} + +#[test] +fn test_lambda_z_quality_metrics() { + let times = vec![0.0, 4.0, 8.0, 12.0, 16.0, 24.0]; + let concs = vec![100.0, 80.0, 60.0, 45.0, 30.0, 12.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Check quality metrics in terminal.regression + if let Some(ref terminal) = result.terminal { + if let Some(ref stats) = terminal.regression { + assert!(stats.r_squared > 0.95, "R² too low: {}", stats.r_squared); + assert!( + stats.adj_r_squared > 0.95, + "Adjusted R² too low: {}", + stats.adj_r_squared + ); + assert!( + stats.span_ratio > 2.0, + "Span ratio too small: {}", + stats.span_ratio + ); + assert!(stats.n_points >= 3, "Too few points: {}", stats.n_points); + } + } +} + +#[test] +fn test_auc_inf_extrapolation() { + // Test that AUCinf is properly calculated + let times = vec![0.0, 1.0, 2.0, 4.0, 8.0, 12.0]; + let concs = vec![100.0, 90.0, 80.0, 65.0, 40.0, 25.0]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default().with_lambda_z(LambdaZOptions { + min_r_squared: 0.80, + min_span_ratio: 1.0, + ..Default::default() + }); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // AUClast should exist + assert!(result.exposure.auc_last > 0.0); + + // If terminal phase estimated, AUCinf should be > AUClast + if result.terminal.is_some() { + if let Some(auc_inf) = result.exposure.auc_inf_obs { + assert!( + auc_inf > result.exposure.auc_last, + "AUCinf should be > AUClast" + ); + } + } +} + +#[test] +fn test_terminal_phase_with_absorption() { + // Typical oral PK profile: absorption then elimination + let times = vec![0.0, 0.5, 1.0, 2.0, 4.0, 8.0, 12.0, 24.0]; + let concs = vec![0.0, 5.0, 10.0, 8.0, 4.0, 2.0, 1.0, 0.25]; + + let subject = build_subject(×, &concs); + let options = NCAOptions::default(); + + let result = subject.nca(&options).expect("NCA should succeed"); + + // Cmax should be at 1.0h + assert_eq!(result.exposure.cmax, 10.0); + assert_eq!(result.exposure.tmax, 1.0); + + // Terminal phase should be estimated from post-Tmax points + if let Some(ref terminal) = result.terminal { + if let Some(ref stats) = terminal.regression { + // Should not include Tmax by default + assert!(stats.time_first > 1.0); + } + } +}