zhl16 0.1.0

A no_std Rust implementation of core Bühlmann ZHL-16 tissue loading primitives.
Documentation
use std::env;
use std::fs;
use std::path::Path;

const INPUT: &str = "data/zhl16c.csv";
const EXPECTED_COMPARTMENTS: usize = 16;
const EXPECTED_FIELDS: usize = 6;

struct TissueRow {
    line_number: usize,
    literals: Vec<String>,
    values: [f64; EXPECTED_FIELDS],
}

fn main() {
    println!("cargo:rerun-if-changed={INPUT}");

    let input = fs::read_to_string(INPUT).expect("failed to read ZHL-16 tissue constants");
    let rows = parse_rows(&input);

    if rows.len() != EXPECTED_COMPARTMENTS {
        panic!(
            "{INPUT} must contain exactly {EXPECTED_COMPARTMENTS} data rows, found {}",
            rows.len()
        );
    }

    let mut output =
        String::from("pub const TISSUE_CONSTANTS: [crate::body_model::TissueModel; 16] = [\n");

    validate_rows(&rows);

    for row in rows {
        output.push_str("    crate::body_model::TissueModel::new(\n");
        output.push_str("        crate::body_model::TissueModelPerGas::new(\n");
        output.push_str(&format!(
            "            crate::quantities::Time::new_unchecked({}, crate::quantities::TimeUnit::Minutes),\n",
            row.literals[0]
        ));
        output.push_str(&format!(
            "            crate::quantities::Pressure::new_unchecked({}, crate::quantities::PressureUnit::Bar),\n",
            row.literals[1]
        ));
        output.push_str(&format!("            {},\n", row.literals[2]));
        output.push_str("        ),\n");
        output.push_str("        crate::body_model::TissueModelPerGas::new(\n");
        output.push_str(&format!(
            "            crate::quantities::Time::new_unchecked({}, crate::quantities::TimeUnit::Minutes),\n",
            row.literals[3]
        ));
        output.push_str(&format!(
            "            crate::quantities::Pressure::new_unchecked({}, crate::quantities::PressureUnit::Bar),\n",
            row.literals[4]
        ));
        output.push_str(&format!("            {},\n", row.literals[5]));
        output.push_str("        ),\n");
        output.push_str("    ),\n");
    }

    output.push_str("];\n");

    let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR is not set");
    fs::write(Path::new(&out_dir).join("tissue_constants.rs"), output)
        .expect("failed to write generated tissue constants");
}

fn parse_rows(input: &str) -> Vec<TissueRow> {
    input
        .lines()
        .enumerate()
        .filter_map(|(line_idx, line)| {
            let line = line.split_once('#').map_or(line, |(data, _)| data).trim();

            if line.is_empty() {
                return None;
            }

            let literals = line
                .split(',')
                .map(str::trim)
                .collect::<Vec<_>>();

            if literals.len() != EXPECTED_FIELDS {
                panic!(
                    "{INPUT}: line {} must contain {EXPECTED_FIELDS} comma-separated fields, found {}",
                    line_idx + 1,
                    literals.len()
                );
            }

            let mut values = [0.0; EXPECTED_FIELDS];
            let literals = literals
                .into_iter()
                .enumerate()
                .map(|(field_idx, field)| {
                    let (literal, value) = parse_float_literal(field, line_idx + 1);
                    values[field_idx] = value;
                    literal
                })
                .collect::<Vec<_>>();

            Some(TissueRow {
                line_number: line_idx + 1,
                literals,
                values,
            })
        })
        .collect()
}

fn parse_float_literal(field: &str, line_number: usize) -> (String, f64) {
    let value = field.parse::<f64>().unwrap_or_else(|_| {
        panic!("{INPUT}: line {line_number} contains invalid float literal {field:?}")
    });

    if !value.is_finite() {
        panic!("{INPUT}: line {line_number} contains non-finite value {field:?}");
    }

    let literal = if field.contains(['.', 'e', 'E']) {
        field.to_owned()
    } else {
        format!("{field}.0")
    };

    (literal, value)
}

fn validate_rows(rows: &[TissueRow]) {
    let mut previous_n2_half_time = None;
    let mut previous_he_half_time = None;

    for row in rows {
        validate_positive(row, 0, "n2_half_time_min");
        validate_positive(row, 1, "n2_a_bar");
        validate_b(row, 2, "n2_b");
        validate_positive(row, 3, "he_half_time_min");
        validate_positive(row, 4, "he_a_bar");
        validate_b(row, 5, "he_b");

        validate_monotonic_half_time(row, 0, "n2_half_time_min", &mut previous_n2_half_time);
        validate_monotonic_half_time(row, 3, "he_half_time_min", &mut previous_he_half_time);
    }
}

fn validate_positive(row: &TissueRow, field_idx: usize, field_name: &str) {
    if row.values[field_idx] <= 0.0 {
        panic!(
            "{INPUT}: line {} {field_name} must be > 0.0, found {}",
            row.line_number, row.literals[field_idx]
        );
    }
}

fn validate_b(row: &TissueRow, field_idx: usize, field_name: &str) {
    validate_positive(row, field_idx, field_name);

    if row.values[field_idx] > 1.0 {
        panic!(
            "{INPUT}: line {} {field_name} must be <= 1.0, found {}",
            row.line_number, row.literals[field_idx]
        );
    }
}

fn validate_monotonic_half_time(
    row: &TissueRow,
    field_idx: usize,
    field_name: &str,
    previous: &mut Option<(f64, usize)>,
) {
    if let Some((previous_value, previous_line)) = previous {
        if row.values[field_idx] <= *previous_value {
            panic!(
                "{INPUT}: line {} {field_name} must grow monotonically through the compartments; found {} after {} on line {}",
                row.line_number, row.literals[field_idx], previous_value, previous_line
            );
        }
    }

    *previous = Some((row.values[field_idx], row.line_number));
}