pmcore/bestdose/mod.rs
1//! # BestDose Algorithm
2//!
3//! Bayesian dose optimization algorithm that finds optimal dosing regimens to achieve
4//! target drug concentrations or cumulative AUC (Area Under the Curve) values.
5//!
6//! The BestDose algorithm combines Bayesian posterior estimation with dual optimization
7//! to balance patient-specific adaptation and population-level robustness.
8//!
9//! # Quick Start
10//!
11//! ```rust,no_run,ignore
12//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
13//!
14//! # fn example(population_theta: pmcore::structs::theta::Theta,
15//! # population_weights: pmcore::structs::weights::Weights,
16//! # past_data: pharmsol::prelude::Subject,
17//! # target: pharmsol::prelude::Subject,
18//! # eq: pharmsol::prelude::ODE,
19//! # error_models: pharmsol::prelude::ErrorModels,
20//! # settings: pmcore::routines::settings::Settings)
21//! # -> anyhow::Result<()> {
22//! // Create optimization problem
23//! let problem = BestDoseProblem::new(
24//! &population_theta, // Population support points from NPAG
25//! &population_weights, // Population probabilities
26//! Some(past_data), // Patient history (None = use prior)
27//! target, // Future template with targets
28//! None, // time_offset (None = standard mode)
29//! eq, // PK/PD model
30//! error_models, // Error specifications
31//! DoseRange::new(0.0, 1000.0), // Dose constraints (0-1000 mg)
32//! 0.5, // bias_weight: 0=personalized, 1=population
33//! settings, // NPAG settings
34//! Target::Concentration, // Target type
35//! )?;
36//!
37//! // Run optimization
38//! let result = problem.optimize()?;
39//!
40//! // Extract results
41//! println!("Optimal dose: {:?} mg", result.dose);
42//! println!("Final cost: {}", result.objf);
43//! println!("Method: {}", result.optimization_method); // "posterior" or "uniform"
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! # Algorithm Overview (Three Stages)
49//!
50//! ```text
51//! ┌─────────────────────────────────────────────────────────────────┐
52//! │ STAGE 1: Posterior Density Calculation │
53//! │ │
54//! │ Prior (N points) │
55//! │ ↓ │
56//! │ Step 1.1: NPAGFULL11 - Bayesian Filtering │
57//! │ Calculate P(data|θᵢ) for each support point │
58//! │ Apply Bayes rule: P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ) │
59//! │ Filter: Keep points where P(θᵢ|data) > 1e-100 × max │
60//! │ ↓ │
61//! │ Filtered Posterior (M points, typically 5-50) │
62//! │ ↓ │
63//! │ Step 1.2: NPAGFULL - Local Refinement │
64//! │ For each filtered point: │
65//! │ Run full NPAG optimization │
66//! │ Find refined "daughter" point │
67//! │ ↓ │
68//! │ Refined Posterior (M points with NPAGFULL11 weights) │
69//! └─────────────────────────────────────────────────────────────────┘
70//!
71//! ┌─────────────────────────────────────────────────────────────────┐
72//! │ STAGE 2: Dual Optimization │
73//! │ │
74//! │ Optimization 1: Posterior Weights (Patient-Specific) │
75//! │ Minimize Cost = (1-λ)×Variance + λ×Bias² │
76//! │ Using NPAGFULL11 posterior weights │
77//! │ ↓ │
78//! │ Result 1: (doses₁, cost₁) │
79//! │ │
80//! │ Optimization 2: Uniform Weights (Population) │
81//! │ Minimize Cost = (1-λ)×Variance + λ×Bias² │
82//! │ Using uniform weights (1/M for all points) │
83//! │ ↓ │
84//! │ Result 2: (doses₂, cost₂) │
85//! │ │
86//! │ Select Best: min(cost₁, cost₂) │
87//! └─────────────────────────────────────────────────────────────────┘
88//!
89//! ┌─────────────────────────────────────────────────────────────────┐
90//! │ STAGE 3: Final Predictions │
91//! │ │
92//! │ Calculate predictions with optimal doses │
93//! │ For AUC targets: Use dense time grid + trapezoidal rule │
94//! │ - AUCFromZero: Cumulative from time 0 │
95//! │ - AUCFromLastDose: Interval from last dose │
96//! │ Return: Optimal doses, cost, predictions, method used │
97//! └─────────────────────────────────────────────────────────────────┘
98//! ```
99//!
100//! # Mathematical Foundation
101//!
102//! ## Bayesian Posterior
103//!
104//! The posterior density is calculated via Bayes' rule:
105//!
106//! ```text
107//! P(θ | data) = P(data | θ) × P(θ) / P(data)
108//! ```
109//!
110//! Where:
111//! - `P(θ | data)`: Posterior (patient-specific parameters)
112//! - `P(data | θ)`: Likelihood (from error model)
113//! - `P(θ)`: Prior (from population)
114//! - `P(data)`: Normalizing constant
115//!
116//! ## Cost Function
117//!
118//! The optimization minimizes a hybrid cost function:
119//!
120//! ```text
121//! Cost = (1-λ) × Variance + λ × Bias²
122//! ```
123//!
124//! **Variance Term** (Patient-Specific Performance):
125//! ```text
126//! Variance = Σᵢ P(θᵢ|data) × Σⱼ (target[j] - pred[i,j])²
127//! ```
128//! Expected squared error using posterior weights.
129//!
130//! **Bias Term** (Population-Level Performance):
131//! ```text
132//! Bias² = Σⱼ (target[j] - E[pred[j]])²
133//! where E[pred[j]] = Σᵢ P(θᵢ) × pred[i,j]
134//! ```
135//! Squared deviation from population mean prediction using prior weights.
136//!
137//! **Bias Weight Parameter (λ)**:
138//! - `λ = 0.0`: Fully personalized (minimize variance only)
139//! - `λ = 0.5`: Balanced hybrid approach
140//! - `λ = 1.0`: Population-based (minimize bias from population)
141//!
142//! # Examples
143//!
144//! ## Single Dose Optimization
145//!
146//! ```rust,no_run,ignore
147//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
148//! use pharmsol::prelude::Subject;
149//!
150//! # fn example(population_theta: pmcore::structs::theta::Theta,
151//! # population_weights: pmcore::structs::weights::Weights,
152//! # past: pharmsol::prelude::Subject,
153//! # eq: pharmsol::prelude::ODE,
154//! # error_models: pharmsol::prelude::ErrorModels,
155//! # settings: pmcore::routines::settings::Settings)
156//! # -> anyhow::Result<()> {
157//! // Define target: 5 mg/L at 24 hours
158//! let target = Subject::builder("patient_001")
159//! .bolus(0.0, 100.0, 0) // Initial dose (will be optimized)
160//! .observation(24.0, 5.0, 0) // Target: 5 mg/L at 24h
161//! .build();
162//!
163//! let problem = BestDoseProblem::new(
164//! &population_theta, &population_weights, Some(past), target, None,
165//! eq, error_models,
166//! DoseRange::new(10.0, 500.0), // 10-500 mg allowed
167//! 0.3, // Slight population emphasis
168//! settings, Target::Concentration,
169//! )?;
170//!
171//! let result = problem.optimize()?;
172//! println!("Optimal dose: {} mg", result.dose[0]);
173//! # Ok(())
174//! # }
175//! ```
176//!
177//! ## Multiple Doses with AUC Target
178//!
179//! ```rust,no_run,ignore
180//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
181//! use pharmsol::prelude::Subject;
182//!
183//! # fn example(population_theta: pmcore::structs::theta::Theta,
184//! # population_weights: pmcore::structs::weights::Weights,
185//! # past: pharmsol::prelude::Subject,
186//! # eq: pharmsol::prelude::ODE,
187//! # error_models: pharmsol::prelude::ErrorModels,
188//! # settings: pmcore::routines::settings::Settings)
189//! # -> anyhow::Result<()> {
190//! // Target: Achieve AUC₂₄ = 400 mg·h/L
191//! let target = Subject::builder("patient_002")
192//! .bolus(0.0, 100.0, 0) // Dose 1 (optimized)
193//! .bolus(12.0, 100.0, 0) // Dose 2 (optimized)
194//! .observation(24.0, 400.0, 0) // Target: AUC₂₄ = 400
195//! .build();
196//!
197//! let problem = BestDoseProblem::new(
198//! &population_theta, &population_weights, Some(past), target, None,
199//! eq, error_models,
200//! DoseRange::new(50.0, 300.0),
201//! 0.0, // Full personalization
202//! settings, Target::AUCFromZero, // Cumulative AUC target!
203//! )?;
204//!
205//! let result = problem.optimize()?;
206//! println!("Dose 1: {} mg at t=0", result.dose[0]);
207//! println!("Dose 2: {} mg at t=12", result.dose[1]);
208//! if let Some(auc) = result.auc_predictions {
209//! println!("Predicted AUC₂₄: {} mg·h/L", auc[0].1);
210//! }
211//! # Ok(())
212//! # }
213//! ```
214//!
215//! ## Population-Only Optimization
216//!
217//! ```rust,no_run,ignore
218//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
219//! # fn example(population_theta: pmcore::structs::theta::Theta,
220//! # population_weights: pmcore::structs::weights::Weights,
221//! # target: pharmsol::prelude::Subject,
222//! # eq: pharmsol::prelude::ODE,
223//! # error_models: pharmsol::prelude::ErrorModels,
224//! # settings: pmcore::routines::settings::Settings)
225//! # -> anyhow::Result<()> {
226//! // No patient history - use population prior directly
227//! let problem = BestDoseProblem::new(
228//! &population_theta, &population_weights,
229//! None, // No past data
230//! target, None, // time_offset
231//! eq, error_models,
232//! DoseRange::new(0.0, 1000.0),
233//! 1.0, // Full population weighting
234//! settings,
235//! Target::Concentration,
236//! )?;
237//!
238//! let result = problem.optimize()?;
239//! // Returns population-typical dose
240//! # Ok(())
241//! # }
242//! ```
243//!
244//! # Configuration
245//!
246//! ## Key Parameters
247//!
248//! - **`bias_weight` (λ)**: Controls personalization level
249//! - `0.0`: Minimize patient-specific variance (full personalization)
250//! - `1.0`: Minimize deviation from population (robustness)
251//!
252//! - **`max_cycles`**: NPAGFULL refinement iterations
253//! - `0`: Skip refinement (use filtered points directly)
254//! - `100-500`: Typical range for refinement
255//!
256//! - **`doserange`**: Dose constraints
257//! - Set clinically appropriate bounds for your drug
258//!
259//! - **`target_type`**: Optimization target
260//! - `Target::Concentration`: Direct concentration targets
261//! - `Target::AUCFromZero`: Cumulative AUC from time 0
262//! - `Target::AUCFromLastDose`: Interval AUC from last dose
263//!
264//! ## Performance Tuning
265//!
266//! For faster optimization:
267//! ```rust,no_run,ignore
268//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
269//! # fn example(population_theta: pmcore::structs::theta::Theta,
270//! # population_weights: pmcore::structs::weights::Weights,
271//! # target: pharmsol::prelude::Subject,
272//! # eq: pharmsol::ODE,
273//! # error_models: pharmsol::prelude::ErrorModels,
274//! # mut settings: pmcore::routines::settings::Settings)
275//! # -> anyhow::Result<()> {
276//! // Reduce refinement cycles
277//! let problem = BestDoseProblem::new(
278//! &population_theta, &population_weights, None, target, None,
279//! eq, error_models,
280//! DoseRange::new(0.0, 1000.0), 0.5,
281//! settings.clone(),
282//! Target::Concentration,
283//! )?;
284//!
285//! // For AUC: use coarser time grid
286//! settings.predictions().idelta = 30.0; // 30-minute intervals
287//! # Ok(())
288//! # }
289//! ```
290//!
291//! # See Also
292//!
293//! - [`BestDoseProblem`]: Main entry point for optimization
294//! - [`BestDoseResult`]: Output structure with optimal doses
295//! - [`Target`]: Enum for concentration vs AUC targets
296//! - [`DoseRange`]: Dose constraint specification
297
298pub mod cost;
299mod optimization;
300mod posterior;
301pub mod predictions;
302mod types;
303
304// Re-export public API
305pub use types::{BestDoseProblem, BestDoseResult, DoseRange, Target};
306
307/// Helper function to concatenate past and future subjects (Option 3: Fortran MAKETMP approach)
308///
309/// This mimics Fortran's MAKETMP subroutine logic:
310/// 1. Takes doses (only doses, not observations) from past subject
311/// 2. Offsets all future subject event times by `time_offset`
312/// 3. Combines into single continuous subject
313///
314/// # Arguments
315///
316/// * `past` - Subject with past history (only doses will be used)
317/// * `future` - Subject template for future (all events: doses + observations)
318/// * `time_offset` - Time offset to apply to all future events
319///
320/// # Returns
321///
322/// Combined subject with:
323/// - Past doses at original times [0, time_offset)
324/// - Future doses + observations at offset times [time_offset, ∞)
325///
326/// # Example
327///
328/// ```rust,ignore
329/// // Past: dose at t=0, observation at t=6 (patient has been on therapy 6 hours)
330/// let past = Subject::builder("patient")
331/// .bolus(0.0, 500.0, 0)
332/// .observation(6.0, 15.0, 0) // 15 mg/L at 6 hours
333/// .build();
334///
335/// // Future: dose at t=0 (relative), target at t=24 (relative)
336/// let future = Subject::builder("patient")
337/// .bolus(0.0, 100.0, 0) // Dose to optimize, will be at t=6 absolute
338/// .observation(24.0, 10.0, 0) // Target at t=30 absolute
339/// .build();
340///
341/// // Concatenate with time_offset = 6.0
342/// let combined = concatenate_past_and_future(&past, &future, 6.0);
343/// // Result: dose at t=0 (fixed, 500mg), dose at t=6 (optimizable, 100mg initial),
344/// // observation target at t=30 (10 mg/L)
345/// ```
346fn concatenate_past_and_future(
347 past: &pharmsol::prelude::Subject,
348 future: &pharmsol::prelude::Subject,
349 time_offset: f64,
350) -> pharmsol::prelude::Subject {
351 use pharmsol::prelude::*;
352
353 let mut builder = Subject::builder(past.id());
354
355 // Add past doses only (skip observations from past)
356 for occasion in past.occasions() {
357 for event in occasion.events() {
358 match event {
359 Event::Bolus(bolus) => {
360 builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input());
361 }
362 Event::Infusion(inf) => {
363 builder =
364 builder.infusion(inf.time(), inf.amount(), inf.input(), inf.duration());
365 }
366 Event::Observation(_) => {
367 // Skip observations from past (they were already used for posterior)
368 }
369 }
370 }
371 }
372
373 // Add future events with time offset
374 for occasion in future.occasions() {
375 for event in occasion.events() {
376 match event {
377 Event::Bolus(bolus) => {
378 builder =
379 builder.bolus(bolus.time() + time_offset, bolus.amount(), bolus.input());
380 }
381 Event::Infusion(inf) => {
382 builder = builder.infusion(
383 inf.time() + time_offset,
384 inf.amount(),
385 inf.input(),
386 inf.duration(),
387 );
388 }
389 Event::Observation(obs) => {
390 builder = match obs.value() {
391 Some(val) => {
392 builder.observation(obs.time() + time_offset, val, obs.outeq())
393 }
394 None => builder,
395 };
396 }
397 }
398 }
399 }
400
401 builder.build()
402}
403
404/// Calculate which doses are optimizable based on dose amounts
405///
406/// Returns a boolean mask where:
407/// - `true` = dose amount is 0 (placeholder, optimizable)
408/// - `false` = dose amount > 0 (fixed past dose)
409///
410/// This allows users to specify a combined subject with:
411/// - Non-zero doses for past doses (e.g., 500 mg at t=0) - these are fixed
412/// - Zero doses as placeholders for future doses (e.g., 0 mg at t=6) - these are optimized
413///
414/// # Arguments
415///
416/// * `subject` - The subject with both fixed and placeholder doses
417///
418/// # Returns
419///
420/// Vector of booleans, one per dose in the subject
421///
422/// # Example
423///
424/// ```rust,ignore
425/// let subject = Subject::builder("patient")
426/// .bolus(0.0, 500.0, 0) // Past dose (fixed) - mask[0] = false
427/// .bolus(6.0, 0.0, 0) // Future dose (optimize) - mask[1] = true
428/// .observation(30.0, 10.0, 0)
429/// .build();
430/// let mask = calculate_dose_optimization_mask(&subject);
431/// assert_eq!(mask, vec![false, true]);
432/// ```
433fn calculate_dose_optimization_mask(subject: &pharmsol::prelude::Subject) -> Vec<bool> {
434 use pharmsol::prelude::*;
435
436 let mut mask = Vec::new();
437
438 for occasion in subject.occasions() {
439 for event in occasion.events() {
440 match event {
441 Event::Bolus(bolus) => {
442 // Dose is optimizable if amount is 0 (placeholder)
443 mask.push(bolus.amount() == 0.0);
444 }
445 Event::Infusion(infusion) => {
446 // Infusion is optimizable if amount is 0 (placeholder)
447 mask.push(infusion.amount() == 0.0);
448 }
449 Event::Observation(_) => {
450 // Observations don't go in the mask
451 }
452 }
453 }
454 }
455
456 mask
457}
458
459use anyhow::Result;
460use pharmsol::prelude::*;
461use pharmsol::ODE;
462
463use crate::routines::settings::Settings;
464use crate::structs::theta::Theta;
465use crate::structs::weights::Weights;
466
467// ═════════════════════════════════════════════════════════════════════════════
468// Helper Functions for STAGE 1: Posterior Density Calculation
469// ═════════════════════════════════════════════════════════════════════════════
470
471/// Validate time_offset parameter for past/future separation mode
472fn validate_time_offset(time_offset: f64, past_data: &Option<Subject>) -> Result<()> {
473 if let Some(past_subject) = past_data {
474 let max_past_time = past_subject
475 .occasions()
476 .iter()
477 .flat_map(|occ| occ.events())
478 .map(|event| match event {
479 Event::Bolus(b) => b.time(),
480 Event::Infusion(i) => i.time(),
481 Event::Observation(o) => o.time(),
482 })
483 .fold(0.0_f64, |max, time| max.max(time));
484
485 if time_offset < max_past_time {
486 return Err(anyhow::anyhow!(
487 "Invalid time_offset: {} is before the last past_data event at time {}. \
488 time_offset must be >= the maximum time in past_data to avoid time travel!",
489 time_offset,
490 max_past_time
491 ));
492 }
493 }
494 Ok(())
495}
496
497/// Calculate posterior density (STAGE 1: Two-step process)
498///
499/// # Algorithm Flow (Matches Diagram)
500///
501/// ```text
502/// Prior Density (N points)
503/// ↓
504/// Has past data with observations?
505/// ↓ Yes ↓ No
506/// Step 1.1: Use prior
507/// NPAGFULL11 directly
508/// (Bayesian Filter)
509/// ↓
510/// Filtered Posterior (M points)
511/// ↓
512/// Step 1.2:
513/// NPAGFULL
514/// (Refine each point)
515/// ↓
516/// Refined Posterior
517/// (M points with NPAGFULL11 weights)
518/// ```
519///
520/// # Returns
521///
522/// Tuple: (posterior_theta, posterior_weights, filtered_population_weights, past_subject)
523fn calculate_posterior_density(
524 population_theta: &Theta,
525 population_weights: &Weights,
526 past_data: Option<&Subject>,
527 eq: &ODE,
528 error_models: &ErrorModels,
529 settings: &Settings,
530) -> Result<(Theta, Weights, Weights, Subject)> {
531 match past_data {
532 None => {
533 tracing::info!(" No past data → using prior directly");
534 Ok((
535 population_theta.clone(),
536 population_weights.clone(),
537 population_weights.clone(),
538 Subject::builder("Empty").build(),
539 ))
540 }
541 Some(past_subject) => {
542 // Check if past data has observations
543 let has_observations = !past_subject.occasions().is_empty()
544 && past_subject.occasions().iter().any(|occ| {
545 occ.events()
546 .iter()
547 .any(|e| matches!(e, Event::Observation(_)))
548 });
549
550 if !has_observations {
551 tracing::info!(" Past data has no observations → using prior directly");
552 Ok((
553 population_theta.clone(),
554 population_weights.clone(),
555 population_weights.clone(),
556 past_subject.clone(),
557 ))
558 } else {
559 // Two-step posterior calculation
560 tracing::info!(" Past data with observations → calculating two-step posterior");
561 tracing::info!(" Step 1.1: NPAGFULL11 (Bayesian filtering)");
562 tracing::info!(" Step 1.2: NPAGFULL (local refinement)");
563
564 let past_data_obj = Data::new(vec![past_subject.clone()]);
565
566 let (posterior_theta, posterior_weights, filtered_population_weights) =
567 posterior::calculate_two_step_posterior(
568 population_theta,
569 population_weights,
570 &past_data_obj,
571 eq,
572 error_models,
573 settings,
574 )?;
575
576 Ok((
577 posterior_theta,
578 posterior_weights,
579 filtered_population_weights,
580 past_subject.clone(),
581 ))
582 }
583 }
584 }
585}
586
587/// Prepare target subject by handling past/future concatenation if needed
588///
589/// # Returns
590///
591/// Tuple: (final_target, final_past_data)
592fn prepare_target_subject(
593 past_subject: Subject,
594 target: Subject,
595 time_offset: Option<f64>,
596) -> Result<(Subject, Subject)> {
597 match time_offset {
598 None => {
599 tracing::info!(" Mode: Standard (single subject)");
600 Ok((target, past_subject))
601 }
602 Some(t) => {
603 tracing::info!(" Mode: Past/Future separation (Fortran MAKETMP approach)");
604 tracing::info!(" Current time boundary: {} hours", t);
605 tracing::info!(" Concatenating past and future subjects...");
606
607 let combined = concatenate_past_and_future(&past_subject, &target, t);
608
609 // Log dose structure
610 let mask = calculate_dose_optimization_mask(&combined);
611 let num_fixed = mask.iter().filter(|&&x| !x).count();
612 let num_optimizable = mask.iter().filter(|&&x| x).count();
613 tracing::info!(" Fixed doses (from past): {}", num_fixed);
614 tracing::info!(" Optimizable doses (from future): {}", num_optimizable);
615
616 Ok((combined, past_subject))
617 }
618 }
619}
620
621// ═════════════════════════════════════════════════════════════════════════════
622
623impl BestDoseProblem {
624 /// Create a new BestDose problem with automatic posterior calculation
625 ///
626 /// This is the main entry point for the BestDose algorithm.
627 ///
628 /// # Algorithm Structure (Matches Flowchart)
629 ///
630 /// ```text
631 /// ┌─────────────────────────────────────────┐
632 /// │ STAGE 1: Posterior Density Calculation │
633 /// │ │
634 /// │ Prior Density (N points) │
635 /// │ ↓ │
636 /// │ Has past data with observations? │
637 /// │ ↓ Yes ↓ No │
638 /// │ Step 1.1: Use prior │
639 /// │ NPAGFULL11 directly │
640 /// │ (Filter) │
641 /// │ ↓ │
642 /// │ Step 1.2: │
643 /// │ NPAGFULL │
644 /// │ (Refine) │
645 /// │ ↓ │
646 /// │ Posterior Density │
647 /// └─────────────────────────────────────────┘
648 /// ```
649 ///
650 /// # Parameters
651 ///
652 /// * `population_theta` - Population support points from NPAG
653 /// * `population_weights` - Population probabilities
654 /// * `past_data` - Patient history (None = use prior directly)
655 /// * `target` - Future dosing template with targets
656 /// * `time_offset` - Optional time offset for concatenation (None = standard mode, Some(t) = Fortran mode)
657 /// * `eq` - Pharmacokinetic/pharmacodynamic model
658 /// * `error_models` - Error model specifications
659 /// * `doserange` - Allowable dose constraints
660 /// * `bias_weight` - λ ∈ [0,1]: 0=personalized, 1=population
661 /// * `settings` - NPAG settings for posterior refinement
662 /// * `max_cycles` - NPAGFULL cycles (0=skip refinement, 500=default)
663 /// * `target_type` - Concentration or AUC targets
664 ///
665 /// # Returns
666 ///
667 /// BestDoseProblem ready for `optimize()`
668 #[allow(clippy::too_many_arguments)]
669 pub fn new(
670 population_theta: &Theta,
671 population_weights: &Weights,
672 past_data: Option<Subject>,
673 target: Subject,
674 time_offset: Option<f64>,
675 eq: ODE,
676 doserange: DoseRange,
677 bias_weight: f64,
678 settings: Settings,
679 target_type: Target,
680 ) -> Result<Self> {
681 tracing::info!("╔══════════════════════════════════════════════════════════╗");
682 tracing::info!("║ BestDose Algorithm: STAGE 1 ║");
683 tracing::info!("║ Posterior Density Calculation ║");
684 tracing::info!("╚══════════════════════════════════════════════════════════╝");
685
686 // Validate input if using past/future separation mode
687 if let Some(t) = time_offset {
688 validate_time_offset(t, &past_data)?;
689 }
690
691 // ═════════════════════════════════════════════════════════════
692 // STAGE 1: Calculate Posterior Density
693 // ═════════════════════════════════════════════════════════════
694 let (posterior_theta, posterior_weights, filtered_population_weights, past_subject) =
695 calculate_posterior_density(
696 population_theta,
697 population_weights,
698 past_data.as_ref(),
699 &eq,
700 &settings.errormodels,
701 &settings,
702 )?;
703
704 // Handle past/future concatenation if needed
705 let (final_target, _) = prepare_target_subject(past_subject, target, time_offset)?;
706
707 tracing::info!("╔══════════════════════════════════════════════════════════╗");
708 tracing::info!("║ Stage 1 Complete - Ready for Optimization ║");
709 tracing::info!("╚══════════════════════════════════════════════════════════╝");
710 tracing::info!(" Support points: {}", posterior_theta.matrix().nrows());
711 tracing::info!(" Target type: {:?}", target_type);
712 tracing::info!(" Bias weight (λ): {}", bias_weight);
713
714 Ok(BestDoseProblem {
715 target: final_target,
716 target_type,
717 population_weights: filtered_population_weights,
718 theta: posterior_theta,
719 posterior: posterior_weights,
720 eq,
721 settings,
722 doserange,
723 bias_weight,
724 })
725 }
726
727 /// Run the complete BestDose optimization algorithm
728 ///
729 /// # Algorithm Flow (Matches Diagram!)
730 ///
731 /// ```text
732 /// ┌─────────────────────────────────────────┐
733 /// │ STAGE 1: Posterior Calculation │
734 /// │ [COMPLETED in new()] │
735 /// └────────────┬────────────────────────────┘
736 /// ↓
737 /// ┌─────────────────────────────────────────┐
738 /// │ STAGE 2: Dual Optimization │
739 /// │ │
740 /// │ Optimization 1: Posterior Weights │
741 /// │ (Patient-specific) │
742 /// │ ↓ │
743 /// │ Result 1: (doses₁, cost₁) │
744 /// │ │
745 /// │ Optimization 2: Uniform Weights │
746 /// │ (Population-based) │
747 /// │ ↓ │
748 /// │ Result 2: (doses₂, cost₂) │
749 /// │ │
750 /// │ Select: min(cost₁, cost₂) │
751 /// └────────────┬────────────────────────────┘
752 /// ↓
753 /// ┌─────────────────────────────────────────┐
754 /// │ STAGE 3: Final Predictions │
755 /// │ │
756 /// │ Calculate predictions with │
757 /// │ optimal doses and winning weights │
758 /// └─────────────────────────────────────────┘
759 /// ```
760 ///
761 /// # Returns
762 ///
763 /// `BestDoseResult` containing:
764 /// - `dose`: Optimal dose amount(s)
765 /// - `objf`: Final cost function value
766 /// - `preds`: Concentration-time predictions
767 /// - `auc_predictions`: AUC values (if target_type is AUC)
768 /// - `optimization_method`: "posterior" or "uniform"
769 pub fn optimize(self) -> Result<BestDoseResult> {
770 tracing::info!("╔══════════════════════════════════════════════════════════╗");
771 tracing::info!("║ BestDose Algorithm: STAGE 2 & 3 ║");
772 tracing::info!("║ Dual Optimization + Final Predictions ║");
773 tracing::info!("╚══════════════════════════════════════════════════════════╝");
774
775 // STAGE 2 & 3: Dual optimization + predictions
776 optimization::dual_optimization(&self)
777 }
778
779 /// Set the bias weight (lambda parameter)
780 ///
781 /// - λ = 0.0 (default): Full personalization (minimize patient-specific variance)
782 /// - λ = 0.5: Balanced between individual and population
783 /// - λ = 1.0: Population-based (minimize deviation from population mean)
784 pub fn with_bias_weight(mut self, weight: f64) -> Self {
785 self.bias_weight = weight;
786 self
787 }
788
789 /// Get a reference to the refined posterior support points (Θ)
790 pub fn posterior_theta(&self) -> &Theta {
791 &self.theta
792 }
793
794 /// Get the posterior probability weights
795 pub fn posterior_weights(&self) -> &Weights {
796 &self.posterior
797 }
798
799 /// Get the filtered population weights used for the bias term
800 pub fn population_weights(&self) -> &Weights {
801 &self.population_weights
802 }
803
804 /// Get the prepared target subject
805 pub fn target_subject(&self) -> &Subject {
806 &self.target
807 }
808
809 /// Get the currently configured bias weight (λ)
810 pub fn bias_weight(&self) -> f64 {
811 self.bias_weight
812 }
813
814 /// Get the selected optimization target type
815 pub fn target_type(&self) -> Target {
816 self.target_type
817 }
818}