Skip to content
1 change: 1 addition & 0 deletions components/nonmem/src/output_files/ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::str::FromStr;
use super::parsing::{self, ParseContext};
use crate::estimation::{EstimationMethod, extract_estimation_method};
use crate::output_files::shk::ShkTable;

use anyhow::{Result, bail};
use fs_err as fs;
use rayon::prelude::*;
Expand Down
135 changes: 107 additions & 28 deletions components/nonmem/src/output_files/lst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,55 @@ pub struct RunHeuristics {
pub minimization_terminated: Option<bool>,
}

impl RunHeuristics {
/// Create heuristics with `Some(false)` defaults for checks that are
/// applicable given the model structure. Inapplicable checks stay `None`.
pub fn defaults_for(model: &Model) -> Self {
let mut h = Self::default();

let is_sim_only = model.simulation.as_ref().is_some_and(|s| s.is_only_sim());
let has_estimation = !model.estimations.is_empty() && !is_sim_only;

if model.covariance.is_some() {
h.covariance_step_aborted = Some(false);
h.eigenvalue_issues = Some(false);
}
if has_estimation {
h.minimization_terminated = Some(false);
h.hessian_reset = Some(false);
h.parameter_near_boundary = Some(false);
}

h
}

/// Apply heuristic signals found by scanning lst file lines.
/// This is the final layer and overrides any previously set values.
fn apply_lst_signals(&mut self, lines: &[&str]) {
for (idx, line) in lines.iter().enumerate() {
if line.contains("0MINIMIZATION TERMINATED") {
self.minimization_terminated = Some(true);
} else if line.contains("RESET HESSIAN") {
self.hessian_reset = Some(true);
} else if line.contains("PARAMETER ESTIMATE IS NEAR ITS BOUNDARY") {
self.parameter_near_boundary = Some(true);
} else if line.contains("EIGENVALUES OF COR MATRIX OF ESTIMAT") {
if let Some(has_issues) = parse_eigenvalue_issues(&lines[idx + 1..]) {
self.eigenvalue_issues = Some(has_issues);
}
} else if line.contains("COVARIANCE STEP ABORTED") {
self.covariance_step_aborted = Some(true);
self.eigenvalue_issues = Some(true);
} else if line.contains("Forcing positive definiteness") {
self.eigenvalue_issues = Some(true);
} else if line.contains("BEFORE THE COVARIANCE STEP CAN BE IMPLEMENTED") {
self.covariance_step_aborted = None;
self.eigenvalue_issues = None;
}
}
}
}

/// RunDetails contains key information about logistics of the model run
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RunDetails {
Expand All @@ -46,6 +95,23 @@ pub struct LstSummary {
pub run_heuristics: RunHeuristics,
}

impl LstSummary {
pub fn from_run(lst_path: impl AsRef<Path>) -> AnyhowResult<Self> {
let content = fs::read_to_string(lst_path.as_ref())?;
let model = extract_model_from_contents(&content)?;
let mut run_heuristics = RunHeuristics::defaults_for(&model);

let lines: Vec<&str> = content.lines().collect();
run_heuristics.apply_lst_signals(&lines);

let run_details = parse_run_details(&content);
Ok(LstSummary {
run_details,
run_heuristics,
})
}
}

fn parse_timing(line: &str) -> f64 {
if let Some(captures) = SIGNED_NUMBER_RE.find(line) {
captures.as_str().parse::<f64>().unwrap_or(0.0)
Expand Down Expand Up @@ -102,40 +168,47 @@ fn parse_run_details(content: &str) -> RunDetails {
run_details
}

fn parse_run_heuristics(content: &str) -> RunHeuristics {
let mut run_heuristics = RunHeuristics::default();
/// Parse eigenvalues from LST lines following an "EIGENVALUES OF COR MATRIX" header.
/// Returns `Some(true)` if any eigenvalue ≤ 0, `Some(false)` if all positive, `None` if none found.
fn parse_eigenvalue_issues(lines: &[&str]) -> Option<bool> {
let mut values = Vec::new();
let mut found_values = false;

for line in content.lines() {
if line.contains("0MINIMIZATION TERMINATED") {
run_heuristics.minimization_terminated = Some(true);
} else if line.contains("RESET HESSIAN") {
run_heuristics.hessian_reset = Some(true);
} else if line.contains("PARAMETER ESTIMATE IS NEAR ITS BOUNDARY") {
run_heuristics.parameter_near_boundary = Some(true);
} else if line.contains("COVARIANCE STEP ABORTED")
|| line.contains("Forcing positive definiteness")
{
run_heuristics.covariance_step_aborted = Some(true);
for line in lines {
let trimmed = line.trim();

if trimmed.is_empty() {
if found_values {
break;
}
continue;
}
}

run_heuristics
}
// Skip asterisk formatting lines
if trimmed.starts_with('*') {
continue;
}

pub fn parse_lst(content: &str) -> LstSummary {
// This way we read the file multiple times but it's tiny and easier to understand for the dev
let run_heuristics = parse_run_heuristics(content);
let run_details = parse_run_details(content);
// Check if this line contains scientific notation (eigenvalue data)
if trimmed.contains('E') || trimmed.contains('e') {
for token in trimmed.split_whitespace() {
if let Ok(v) = token.parse::<f64>() {
values.push(v);
}
}
found_values = true;
}
// Otherwise it's an integer index line — skip it
}

LstSummary {
run_details,
run_heuristics,
if values.is_empty() {
None
} else {
Some(values.iter().any(|&v| v <= 0.0))
}
}

pub fn extract_model(path: impl AsRef<Path>) -> AnyhowResult<Model> {
let contents = fs::read_to_string(path)?;

pub fn extract_model_from_contents(contents: &str) -> AnyhowResult<Model> {
// lst starts with timestamp, then model content, then NM-TRAN MESSAGES
let model_content = contents
.lines()
Expand All @@ -148,6 +221,13 @@ pub fn extract_model(path: impl AsRef<Path>) -> AnyhowResult<Model> {
Ok(model)
}

pub fn extract_model(path: impl AsRef<Path>) -> AnyhowResult<Model> {
let contents = fs::read_to_string(path)?;
let model = extract_model_from_contents(&contents)?;

Ok(model)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -159,8 +239,7 @@ mod tests {
use std::path::PathBuf;
let test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data/lst");
glob!(test_dir, "*.lst", |path| {
let input = fs::read_to_string(path).unwrap();
assert_debug_snapshot!(parse_lst(&input));
assert_debug_snapshot!(LstSummary::from_run(path).unwrap())
});
}

Expand Down
4 changes: 2 additions & 2 deletions components/nonmem/src/output_files/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::output_files::cor::{CorReader, CorrelationMatrix};
use crate::output_files::ext::{
ExtReader, MinimizationResults, ParameterType, TableParameters, get_estimation_results,
};
use crate::output_files::lst::{LstSummary, parse_lst};
use crate::output_files::lst::LstSummary;
use crate::output_files::shk::ShkReader;
use crate::{Model, TERMINATION_FILENAME, Termination};
use anyhow::{Result, anyhow, bail};
Expand Down Expand Up @@ -91,7 +91,7 @@ pub fn get_summary(
let mut model = Model::parse(&fs::read_to_string(model_path)?)?;
let parameter_names = model.get_parameter_names(comment_type)?;

let lst_summary = parse_lst(&fs::read_to_string(&lst_path)?);
let lst_summary = LstSummary::from_run(&lst_path)?;

let shk_data = if shk_path.exists() {
Comment on lines 91 to 96
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_summary now calls LstSummary::from_run(&lst_path, &ext_path), and later parses the same .ext file again via get_estimation_results. If .ext files are large, this doubles I/O and parse cost for every summary. Consider plumbing the eigenvalue information from the existing get_estimation_results parsing (or otherwise avoiding a second .ext parse) so summaries only read/parse the file once.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I didn't notice it was parsing the ext file again later!

ShkReader.parse_file(shk_path)?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ LstSummary {
run_heuristics: RunHeuristics {
covariance_step_aborted: None,
eigenvalue_issues: None,
parameter_near_boundary: None,
hessian_reset: None,
minimization_terminated: None,
parameter_near_boundary: Some(
false,
),
hessian_reset: Some(
false,
),
minimization_terminated: Some(
false,
),
},
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: components/nonmem/src/output_files/lst.rs
expression: parse_lst(&input)
expression: parse_lst_content(&input).unwrap()
input_file: components/nonmem/test_data/lst/acop.lst
---
LstSummary {
Expand All @@ -24,10 +24,20 @@ LstSummary {
only_sim: false,
},
run_heuristics: RunHeuristics {
covariance_step_aborted: None,
eigenvalue_issues: None,
parameter_near_boundary: None,
hessian_reset: None,
minimization_terminated: None,
covariance_step_aborted: Some(
false,
),
eigenvalue_issues: Some(
false,
),
parameter_near_boundary: Some(
false,
),
hessian_reset: Some(
false,
),
minimization_terminated: Some(
false,
),
},
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: components/nonmem/src/output_files/lst.rs
expression: parse_lst(&input)
expression: parse_lst_content(&input).unwrap()
input_file: components/nonmem/test_data/lst/bql.lst
---
LstSummary {
Expand All @@ -24,10 +24,20 @@ LstSummary {
only_sim: false,
},
run_heuristics: RunHeuristics {
covariance_step_aborted: None,
eigenvalue_issues: None,
parameter_near_boundary: None,
hessian_reset: None,
minimization_terminated: None,
covariance_step_aborted: Some(
false,
),
eigenvalue_issues: Some(
false,
),
parameter_near_boundary: Some(
false,
),
hessian_reset: Some(
false,
),
minimization_terminated: Some(
false,
),
},
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: components/nonmem/src/output_files/lst.rs
expression: parse_lst(&input)
expression: "LstSummary::from_run(path).unwrap()"
input_file: components/nonmem/test_data/lst/saemimp.lst
---
LstSummary {
Expand Down Expand Up @@ -28,11 +28,19 @@ LstSummary {
},
run_heuristics: RunHeuristics {
covariance_step_aborted: Some(
false,
),
eigenvalue_issues: Some(
true,
),
eigenvalue_issues: None,
parameter_near_boundary: None,
hessian_reset: None,
minimization_terminated: None,
parameter_near_boundary: Some(
false,
),
hessian_reset: Some(
false,
),
minimization_terminated: Some(
false,
),
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,21 @@ Summary {
only_sim: false,
},
run_heuristics: RunHeuristics {
covariance_step_aborted: None,
eigenvalue_issues: None,
parameter_near_boundary: None,
hessian_reset: None,
minimization_terminated: None,
covariance_step_aborted: Some(
false,
),
eigenvalue_issues: Some(
false,
),
parameter_near_boundary: Some(
false,
),
hessian_reset: Some(
false,
),
minimization_terminated: Some(
false,
),
},
},
minimization_results: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,21 @@ Summary {
only_sim: false,
},
run_heuristics: RunHeuristics {
covariance_step_aborted: None,
eigenvalue_issues: None,
parameter_near_boundary: None,
hessian_reset: None,
minimization_terminated: None,
covariance_step_aborted: Some(
false,
),
eigenvalue_issues: Some(
false,
),
parameter_near_boundary: Some(
false,
),
hessian_reset: Some(
false,
),
minimization_terminated: Some(
false,
),
},
},
minimization_results: [
Expand Down
Loading