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::{BestDosePosterior, 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//! # settings: pmcore::routines::settings::Settings)
20//! # -> anyhow::Result<()> {
21//! // Stage 1: Compute posterior from patient history
22//! let posterior = BestDosePosterior::compute(
23//! &population_theta, // Population support points from NPAG
24//! &population_weights, // Population probabilities
25//! Some(past_data), // Patient history (None = use prior)
26//! eq, // PK/PD model
27//! settings, // NPAG settings
28//! )?;
29//!
30//! // Stage 2 & 3: Optimize doses and get predictions
31//! let result = posterior.optimize(
32//! target, // Future template with targets
33//! None, // time_offset (None = standard mode)
34//! DoseRange::new(0.0, 1000.0), // Dose constraints (0-1000 mg)
35//! 0.5, // bias_weight: 0=personalized, 1=population
36//! Target::Concentration, // Target type
37//! )?;
38//!
39//! // Extract results
40//! println!("Optimal dose: {:?} mg", result.doses());
41//! println!("Final cost: {}", result.objf());
42//! println!("Method: {}", result.optimization_method());
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # Algorithm Overview (Three Stages)
48//!
49//! ```text
50//! ┌─────────────────────────────────────────────────────────────────┐
51//! │ STAGE 1: Posterior Density Calculation │
52//! │ │
53//! │ Prior (N points) │
54//! │ ↓ │
55//! │ Step 1.1: NPAGFULL11 - Bayesian Filtering │
56//! │ Calculate P(data|θᵢ) for each support point │
57//! │ Apply Bayes rule: P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ) │
58//! │ Filter: Keep points where P(θᵢ|data) > 1e-100 × max │
59//! │ ↓ │
60//! │ Filtered Posterior (M points, typically 5-50) │
61//! │ ↓ │
62//! │ Step 1.2: NPAGFULL - Local Refinement │
63//! │ For each filtered point: │
64//! │ Run full NPAG optimization │
65//! │ Find refined "daughter" point │
66//! │ ↓ │
67//! │ Refined Posterior (M points with NPAGFULL11 weights) │
68//! └─────────────────────────────────────────────────────────────────┘
69//!
70//! ┌─────────────────────────────────────────────────────────────────┐
71//! │ STAGE 2: Dual Optimization │
72//! │ │
73//! │ Optimization 1: Posterior Weights (Patient-Specific) │
74//! │ Minimize Cost = (1-λ)×Variance + λ×Bias² │
75//! │ Using NPAGFULL11 posterior weights │
76//! │ ↓ │
77//! │ Result 1: (doses₁, cost₁) │
78//! │ │
79//! │ Optimization 2: Uniform Weights (Population) │
80//! │ Minimize Cost = (1-λ)×Variance + λ×Bias² │
81//! │ Using uniform weights (1/M for all points) │
82//! │ ↓ │
83//! │ Result 2: (doses₂, cost₂) │
84//! │ │
85//! │ Select Best: min(cost₁, cost₂) │
86//! └─────────────────────────────────────────────────────────────────┘
87//!
88//! ┌─────────────────────────────────────────────────────────────────┐
89//! │ STAGE 3: Final Predictions │
90//! │ │
91//! │ Calculate predictions with optimal doses │
92//! │ For AUC targets: Use dense time grid + trapezoidal rule │
93//! │ - AUCFromZero: Cumulative from time 0 │
94//! │ - AUCFromLastDose: Interval from last dose │
95//! │ Return: Optimal doses, cost, predictions, method used │
96//! └─────────────────────────────────────────────────────────────────┘
97//! ```
98//!
99//! # Mathematical Foundation
100//!
101//! ## Bayesian Posterior
102//!
103//! The posterior density is calculated via Bayes' rule:
104//!
105//! ```text
106//! P(θ | data) = P(data | θ) × P(θ) / P(data)
107//! ```
108//!
109//! Where:
110//! - `P(θ | data)`: Posterior (patient-specific parameters)
111//! - `P(data | θ)`: Likelihood (from error model)
112//! - `P(θ)`: Prior (from population)
113//! - `P(data)`: Normalizing constant
114//!
115//! ## Cost Function
116//!
117//! The optimization minimizes a hybrid cost function:
118//!
119//! ```text
120//! Cost = (1-λ) × Variance + λ × Bias²
121//! ```
122//!
123//! **Variance Term** (Patient-Specific Performance):
124//! ```text
125//! Variance = Σᵢ P(θᵢ|data) × Σⱼ (target[j] - pred[i,j])²
126//! ```
127//! Expected squared error using posterior weights.
128//!
129//! **Bias Term** (Population-Level Performance):
130//! ```text
131//! Bias² = Σⱼ (target[j] - E[pred[j]])²
132//! where E[pred[j]] = Σᵢ P(θᵢ) × pred[i,j]
133//! ```
134//! Squared deviation from population mean prediction using prior weights.
135//!
136//! **Bias Weight Parameter (λ)**:
137//! - `λ = 0.0`: Fully personalized (minimize variance only)
138//! - `λ = 0.5`: Balanced hybrid approach
139//! - `λ = 1.0`: Population-based (minimize bias from population)
140//!
141//! # Examples
142//!
143//! ## Single Dose Optimization
144//!
145//! ```rust,no_run,ignore
146//! use pmcore::bestdose::{BestDosePosterior, Target, DoseRange};
147//! use pharmsol::prelude::Subject;
148//!
149//! # fn example(population_theta: pmcore::structs::theta::Theta,
150//! # population_weights: pmcore::structs::weights::Weights,
151//! # past: pharmsol::prelude::Subject,
152//! # eq: pharmsol::prelude::ODE,
153//! # settings: pmcore::routines::settings::Settings)
154//! # -> anyhow::Result<()> {
155//! // Define target: 5 mg/L at 24 hours
156//! let target = Subject::builder("patient_001")
157//! .bolus(0.0, 0.0, 0) // Dose placeholder (will be optimized)
158//! .observation(24.0, 5.0, 0) // Target: 5 mg/L at 24h
159//! .build();
160//!
161//! let posterior = BestDosePosterior::compute(
162//! &population_theta, &population_weights, Some(past), eq, settings,
163//! )?;
164//!
165//! let result = posterior.optimize(
166//! target, None,
167//! DoseRange::new(10.0, 500.0), // 10-500 mg allowed
168//! 0.3, // Slight population emphasis
169//! Target::Concentration,
170//! )?;
171//!
172//! println!("Optimal dose: {} mg", result.doses()[0]);
173//! # Ok(())
174//! # }
175//! ```
176//!
177//! ## Multiple Doses with AUC Target
178//!
179//! ```rust,no_run,ignore
180//! use pmcore::bestdose::{BestDosePosterior, 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//! # settings: pmcore::routines::settings::Settings)
188//! # -> anyhow::Result<()> {
189//! // Target: Achieve AUC₂₄ = 400 mg·h/L
190//! let target = Subject::builder("patient_002")
191//! .bolus(0.0, 0.0, 0) // Dose 1 placeholder (optimized)
192//! .bolus(12.0, 0.0, 0) // Dose 2 placeholder (optimized)
193//! .observation(24.0, 400.0, 0) // Target: AUC₂₄ = 400
194//! .build();
195//!
196//! let posterior = BestDosePosterior::compute(
197//! &population_theta, &population_weights, Some(past), eq, settings,
198//! )?;
199//!
200//! let result = posterior.optimize(
201//! target, None,
202//! DoseRange::new(50.0, 300.0),
203//! 0.0, // Full personalization
204//! Target::AUCFromZero, // Cumulative AUC target!
205//! )?;
206//!
207//! println!("Dose 1: {} mg at t=0", result.doses()[0]);
208//! println!("Dose 2: {} mg at t=12", result.doses()[1]);
209//! if let Some(auc) = result.auc_predictions() {
210//! println!("Predicted AUC₂₄: {} mg·h/L", auc[0].1);
211//! }
212//! # Ok(())
213//! # }
214//! ```
215//!
216//! ## Population-Only Optimization
217//!
218//! ```rust,no_run,ignore
219//! # use pmcore::bestdose::{BestDosePosterior, Target, DoseRange};
220//! # fn example(population_theta: pmcore::structs::theta::Theta,
221//! # population_weights: pmcore::structs::weights::Weights,
222//! # target: pharmsol::prelude::Subject,
223//! # eq: pharmsol::prelude::ODE,
224//! # settings: pmcore::routines::settings::Settings)
225//! # -> anyhow::Result<()> {
226//! // No patient history - use population prior directly
227//! let posterior = BestDosePosterior::compute(
228//! &population_theta, &population_weights,
229//! None, // No past data → use prior
230//! eq, settings,
231//! )?;
232//!
233//! let result = posterior.optimize(
234//! target, None,
235//! DoseRange::new(0.0, 1000.0),
236//! 1.0, // Full population weighting
237//! Target::Concentration,
238//! )?;
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//! # See Also
265//!
266//! - [`BestDosePosterior`]: Two-stage API entry point (compute posterior, then optimize)
267//! - [`BestDoseResult`]: Output structure with optimal doses
268//! - [`Target`]: Enum for concentration vs AUC targets
269//! - [`DoseRange`]: Dose constraint specification
270
271pub(crate) mod cost;
272mod optimization;
273mod posterior;
274pub(crate) mod predictions;
275mod types;
276
277// Re-export public API
278pub use types::{
279 BestDosePosterior, BestDoseResult, BestDoseStatus, DoseRange, OptimalMethod, Target,
280};
281
282/// Helper function to concatenate past and future subjects (Option 3: Fortran MAKETMP approach)
283///
284/// This mimics Fortran's MAKETMP subroutine logic:
285/// 1. Takes doses (only doses, not observations) from past subject
286/// 2. Offsets all future subject event times by `effective_offset` (absolute)
287/// 3. Combines into single continuous subject
288///
289/// Note: This function receives the **effective** (absolute) offset, computed
290/// by `optimize()` as `max_past_time + time_offset` where `time_offset` is the
291/// user-facing gap parameter.
292///
293/// # Arguments
294///
295/// * `past` - Subject with past history (only doses will be used)
296/// * `future` - Subject template for future (all events: doses + observations)
297/// * `effective_offset` - Absolute time offset to apply to all future events
298///
299/// # Returns
300///
301/// Combined subject with:
302/// - Past doses at original times [0, effective_offset)
303/// - Future doses + observations at offset times [effective_offset, ∞)
304///
305/// # Example
306///
307/// ```rust,ignore
308/// // Past: dose at t=0, observation at t=6 (patient has been on therapy 6 hours)
309/// let past = Subject::builder("patient")
310/// .bolus(0.0, 500.0, 0)
311/// .observation(6.0, 15.0, 0) // 15 mg/L at 6 hours (max_past_time = 6)
312/// .build();
313///
314/// // Future: dose at t=0 (relative), target at t=24 (relative)
315/// let future = Subject::builder("patient")
316/// .bolus(0.0, 100.0, 0) // At absolute t=6 (with gap=0)
317/// .observation(24.0, 10.0, 0) // At absolute t=30 (with gap=0)
318/// .build();
319///
320/// // effective_offset = max_past_time(6) + gap(0) = 6
321/// let combined = concatenate_past_and_future(&past, &future, 6.0);
322/// // Result: dose at t=0 (fixed, 500mg), dose at t=6 (optimizable),
323/// // observation target at t=30 (10 mg/L)
324/// ```
325fn concatenate_past_and_future(
326 past: &pharmsol::prelude::Subject,
327 future: &pharmsol::prelude::Subject,
328 effective_offset: f64,
329) -> pharmsol::prelude::Subject {
330 use pharmsol::prelude::*;
331
332 let mut builder = Subject::builder(past.id());
333
334 // Add past doses only (skip observations from past)
335 for occasion in past.occasions() {
336 for event in occasion.events() {
337 match event {
338 Event::Bolus(bolus) => {
339 builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input());
340 }
341 Event::Infusion(inf) => {
342 builder =
343 builder.infusion(inf.time(), inf.amount(), inf.input(), inf.duration());
344 }
345 Event::Observation(_) => {
346 // Skip observations from past (they were already used for posterior)
347 }
348 }
349 }
350 }
351
352 // Add future events with effective offset
353 for occasion in future.occasions() {
354 for event in occasion.events() {
355 match event {
356 Event::Bolus(bolus) => {
357 builder = builder.bolus(
358 bolus.time() + effective_offset,
359 bolus.amount(),
360 bolus.input(),
361 );
362 }
363 Event::Infusion(inf) => {
364 builder = builder.infusion(
365 inf.time() + effective_offset,
366 inf.amount(),
367 inf.input(),
368 inf.duration(),
369 );
370 }
371 Event::Observation(obs) => {
372 builder = match obs.value() {
373 Some(val) => {
374 builder.observation(obs.time() + effective_offset, val, obs.outeq())
375 }
376 None => builder,
377 };
378 }
379 }
380 }
381 }
382
383 builder.build()
384}
385
386use anyhow::Result;
387use pharmsol::prelude::*;
388use pharmsol::ODE;
389
390use crate::routines::settings::Settings;
391use crate::structs::theta::Theta;
392use crate::structs::weights::Weights;
393
394use types::BestDoseProblem;
395
396// ═════════════════════════════════════════════════════════════════════════════
397// BestDosePosterior: Public two-stage API
398// ═════════════════════════════════════════════════════════════════════════════
399
400impl BestDosePosterior {
401 /// **Stage 1**: Compute the Bayesian posterior density from population prior and patient data
402 ///
403 /// This performs the expensive posterior calculation (NPAGFULL11 filtering + NPAGFULL refinement)
404 /// and returns a reusable `BestDosePosterior` that can be optimized multiple times.
405 ///
406 /// # Algorithm
407 ///
408 /// ```text
409 /// Prior (N support points)
410 /// ↓
411 /// NPAGFULL11: Bayesian filtering
412 /// P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ)
413 /// ↓
414 /// Filtered posterior (M points)
415 /// ↓
416 /// NPAGFULL: Local refinement (max_cycles iterations)
417 /// ↓
418 /// Refined posterior (M points with updated weights)
419 /// ```
420 ///
421 /// # Arguments
422 ///
423 /// * `population_theta` - Population support points from NPAG
424 /// * `population_weights` - Population probabilities
425 /// * `past_data` - Patient history (`None` = use prior directly)
426 /// * `eq` - Pharmacokinetic/pharmacodynamic model
427 /// * `settings` - NPAG settings (includes error models and posterior refinement config)
428 ///
429 /// # Example
430 ///
431 /// ```rust,no_run,ignore
432 /// let posterior = BestDosePosterior::compute(
433 /// &theta, &weights,
434 /// Some(past_subject),
435 /// eq, settings,
436 /// )?;
437 /// println!("Posterior has {} support points", posterior.n_support_points());
438 /// ```
439 pub fn compute(
440 population_theta: &Theta,
441 population_weights: &Weights,
442 past_data: Option<Subject>,
443 eq: ODE,
444 settings: Settings,
445 ) -> Result<Self> {
446 tracing::info!("╔══════════════════════════════════════════════════════════╗");
447 tracing::info!("║ BestDose Algorithm: STAGE 1 ║");
448 tracing::info!("║ Posterior Density Calculation ║");
449 tracing::info!("╚══════════════════════════════════════════════════════════╝");
450
451 let (posterior_theta, posterior_weights, filtered_population_weights, _past_subject) =
452 calculate_posterior_density(
453 population_theta,
454 population_weights,
455 past_data.as_ref(),
456 &eq,
457 &settings.errormodels,
458 &settings,
459 )?;
460
461 tracing::info!("╔══════════════════════════════════════════════════════════╗");
462 tracing::info!("║ Stage 1 Complete - Posterior Ready ║");
463 tracing::info!("╚══════════════════════════════════════════════════════════╝");
464 tracing::info!(" Support points: {}", posterior_theta.matrix().nrows());
465
466 Ok(BestDosePosterior {
467 theta: posterior_theta,
468 posterior: posterior_weights,
469 population_weights: filtered_population_weights,
470 past_data,
471 eq,
472 settings,
473 })
474 }
475
476 /// **Stage 2**: Optimize doses for target outcomes using the computed posterior
477 ///
478 /// This runs the dual optimization (posterior weights vs uniform weights) and
479 /// returns the best dosing regimen. Can be called multiple times on the same
480 /// posterior with different parameters.
481 ///
482 /// # Arguments
483 ///
484 /// * `target` - Future dosing template with target observations
485 /// * `time_offset` - Optional gap (in hours) between the last past event and the start of
486 /// the future target. 0 means the future starts immediately after the last past event.
487 /// The effective absolute offset is `max_past_time + time_offset`.
488 /// * `dose_range` - Allowable dose constraints
489 /// * `bias_weight` - λ in \[0,1\]: 0=personalized, 1=population
490 /// * `target_type` - Concentration or AUC targets
491 ///
492 /// # Example
493 ///
494 /// ```rust,no_run,ignore
495 /// // Try different bias weights
496 /// for &bw in &[0.0, 0.25, 0.5, 0.75, 1.0] {
497 /// let result = posterior.optimize(
498 /// target.clone(),
499 /// None,
500 /// DoseRange::new(0.0, 300.0),
501 /// bw,
502 /// Target::Concentration,
503 /// )?;
504 /// println!("λ={}: dose={:.1}", bw, result.doses()[0]);
505 /// }
506 /// ```
507 pub fn optimize(
508 &self,
509 target: Subject,
510 time_offset: Option<f64>,
511 dose_range: DoseRange,
512 bias_weight: f64,
513 target_type: Target,
514 ) -> Result<BestDoseResult> {
515 tracing::info!("╔══════════════════════════════════════════════════════════╗");
516 tracing::info!("║ BestDose Algorithm: STAGE 2 & 3 ║");
517 tracing::info!("║ Dual Optimization + Final Predictions ║");
518 tracing::info!("╚══════════════════════════════════════════════════════════╝");
519 tracing::info!(" Target type: {:?}", target_type);
520 tracing::info!(" Bias weight (λ): {}", bias_weight);
521
522 // Validate and compute effective time_offset
523 // time_offset is a gap relative to the last past event:
524 // effective_offset = max_past_time + time_offset
525 // So time_offset=0 means "future starts right after last past event"
526 if let Some(t) = time_offset {
527 if t < 0.0 {
528 return Err(anyhow::anyhow!(
529 "Invalid time_offset: {} is negative. \
530 time_offset must be >= 0 (it represents the gap after the last past event).",
531 t
532 ));
533 }
534 }
535
536 // Compute the absolute offset for concatenation
537 let effective_offset = time_offset.map(|t| {
538 let max_past_time = self
539 .past_data
540 .as_ref()
541 .map(|past| {
542 past.occasions()
543 .iter()
544 .flat_map(|occ| occ.events())
545 .map(|event| match event {
546 Event::Bolus(b) => b.time(),
547 Event::Infusion(i) => i.time(),
548 Event::Observation(o) => o.time(),
549 })
550 .fold(0.0_f64, |max, time| max.max(time))
551 })
552 .unwrap_or(0.0);
553 max_past_time + t
554 });
555
556 // Handle past/future concatenation if needed
557 // When time_offset is provided, offset all target event times by the
558 // effective offset (max_past_time + gap) and prepend past doses.
559 let final_target = match effective_offset {
560 None => target,
561 Some(eff) => {
562 tracing::info!(
563 " Time offset gap: {:?} hours (effective absolute offset: {} hours)",
564 time_offset,
565 eff
566 );
567 match &self.past_data {
568 Some(past) => {
569 tracing::info!(" Concatenating past doses with offset target events");
570 concatenate_past_and_future(past, &target, eff)
571 }
572 None => {
573 tracing::info!(" No past data stored — offsetting target events only");
574 concatenate_past_and_future(
575 &Subject::builder("empty").build(),
576 &target,
577 eff,
578 )
579 }
580 }
581 }
582 };
583
584 // Validate that the target has observations
585 let has_observations = final_target
586 .occasions()
587 .iter()
588 .flat_map(|occ| occ.events())
589 .any(|event| matches!(event, Event::Observation(_)));
590 if !has_observations {
591 return Err(anyhow::anyhow!(
592 "Target subject has no observations. At least one observation is required for dose optimization."
593 ));
594 }
595
596 // Build the internal optimization problem
597 let problem = BestDoseProblem {
598 target: final_target,
599 target_type,
600 population_weights: self.population_weights.clone(),
601 theta: self.theta.clone(),
602 posterior: self.posterior.clone(),
603 eq: self.eq.clone(),
604 settings: self.settings.clone(),
605 doserange: dose_range,
606 bias_weight,
607 };
608
609 // Run dual optimization + final predictions
610 optimization::dual_optimization(&problem)
611 }
612}
613
614// ═════════════════════════════════════════════════════════════════════════════
615// Helper Functions for STAGE 1: Posterior Density Calculation
616// ═════════════════════════════════════════════════════════════════════════════
617
618/// Calculate posterior density (STAGE 1: Two-step process)
619///
620/// # Algorithm Flow (Matches Diagram)
621///
622/// ```text
623/// Prior Density (N points)
624/// ↓
625/// Has past data with observations?
626/// ↓ Yes ↓ No
627/// Step 1.1: Use prior
628/// NPAGFULL11 directly
629/// (Bayesian Filter)
630/// ↓
631/// Filtered Posterior (M points)
632/// ↓
633/// Step 1.2:
634/// NPAGFULL
635/// (Refine each point)
636/// ↓
637/// Refined Posterior
638/// (M points with NPAGFULL11 weights)
639/// ```
640///
641/// # Returns
642///
643/// Tuple: (posterior_theta, posterior_weights, filtered_population_weights, past_subject)
644fn calculate_posterior_density(
645 population_theta: &Theta,
646 population_weights: &Weights,
647 past_data: Option<&Subject>,
648 eq: &ODE,
649 error_models: &AssayErrorModels,
650 settings: &Settings,
651) -> Result<(Theta, Weights, Weights, Subject)> {
652 match past_data {
653 None => {
654 tracing::info!(" No past data → using prior directly");
655 Ok((
656 population_theta.clone(),
657 population_weights.clone(),
658 population_weights.clone(),
659 Subject::builder("Empty").build(),
660 ))
661 }
662 Some(past_subject) => {
663 // Check if past data has observations
664 let has_observations = !past_subject.occasions().is_empty()
665 && past_subject.occasions().iter().any(|occ| {
666 occ.events()
667 .iter()
668 .any(|e| matches!(e, Event::Observation(_)))
669 });
670
671 if !has_observations {
672 tracing::info!(" Past data has no observations → using prior directly");
673 Ok((
674 population_theta.clone(),
675 population_weights.clone(),
676 population_weights.clone(),
677 past_subject.clone(),
678 ))
679 } else {
680 // Two-step posterior calculation
681 tracing::info!(" Past data with observations → calculating two-step posterior");
682 tracing::info!(" Step 1.1: NPAGFULL11 (Bayesian filtering)");
683 tracing::info!(" Step 1.2: NPAGFULL (local refinement)");
684
685 let past_data_obj = Data::new(vec![past_subject.clone()]);
686
687 let (posterior_theta, posterior_weights, filtered_population_weights) =
688 posterior::calculate_two_step_posterior(
689 population_theta,
690 population_weights,
691 &past_data_obj,
692 eq,
693 error_models,
694 settings,
695 )?;
696
697 Ok((
698 posterior_theta,
699 posterior_weights,
700 filtered_population_weights,
701 past_subject.clone(),
702 ))
703 }
704 }
705 }
706}