pharmsol 0.27.1

Rust library for solving analytic and ode-defined pharmacometric models.
Documentation
#![allow(dead_code)]

use std::collections::HashMap;
use std::error::Error;
use std::fmt;

#[cfg(feature = "dsl-core")]
use crate::dsl::NativeModelInfo;
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
use crate::simulator::equation::ValidatedModelMetadata;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ParameterOrderPlan {
    permutation: Vec<usize>,
    width: usize,
    identity: bool,
}

impl ParameterOrderPlan {
    pub(crate) fn from_names<M, S>(
        model_names: M,
        source_names: S,
    ) -> Result<Self, ParameterOrderError>
    where
        M: IntoIterator,
        M::Item: AsRef<str>,
        S: IntoIterator,
        S::Item: AsRef<str>,
    {
        let model_names = model_names
            .into_iter()
            .map(|name| name.as_ref().to_string())
            .collect::<Vec<_>>();
        let mut model_index_by_name = HashMap::with_capacity(model_names.len());
        for (index, name) in model_names.iter().enumerate() {
            model_index_by_name.insert(name.as_str(), index);
        }

        let mut permutation = vec![usize::MAX; model_names.len()];
        let mut width = 0;

        for source_name in source_names {
            let source_name = source_name.as_ref();
            let Some(&model_index) = model_index_by_name.get(source_name) else {
                return Err(ParameterOrderError::UnknownParameter {
                    name: source_name.to_string(),
                });
            };

            if permutation[model_index] != usize::MAX {
                return Err(ParameterOrderError::DuplicateParameter {
                    name: source_name.to_string(),
                });
            }

            permutation[model_index] = width;
            width += 1;
        }

        let missing = model_names
            .iter()
            .enumerate()
            .filter_map(|(index, name)| {
                if permutation[index] == usize::MAX {
                    Some(name.clone())
                } else {
                    None
                }
            })
            .collect::<Vec<_>>();
        if !missing.is_empty() {
            return Err(ParameterOrderError::MissingParameters { names: missing });
        }

        let identity = permutation
            .iter()
            .enumerate()
            .all(|(model_index, source_index)| model_index == *source_index);

        Ok(Self {
            permutation,
            width,
            identity,
        })
    }

    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
    pub(crate) fn from_metadata<S>(
        metadata: Option<&ValidatedModelMetadata>,
        source_names: S,
    ) -> Result<Self, ParameterOrderError>
    where
        S: IntoIterator,
        S::Item: AsRef<str>,
    {
        let Some(metadata) = metadata else {
            return Err(ParameterOrderError::MissingMetadata);
        };

        Self::from_names(
            metadata
                .parameters()
                .iter()
                .map(|parameter| parameter.name()),
            source_names,
        )
    }

    #[cfg(feature = "dsl-core")]
    pub(crate) fn from_runtime_info<S>(
        info: &NativeModelInfo,
        source_names: S,
    ) -> Result<Self, ParameterOrderError>
    where
        S: IntoIterator,
        S::Item: AsRef<str>,
    {
        Self::from_names(info.parameters.iter().map(String::as_str), source_names)
    }

    pub(crate) fn permutation(&self) -> &[usize] {
        &self.permutation
    }

    pub(crate) fn width(&self) -> usize {
        self.width
    }

    pub(crate) fn is_identity(&self) -> bool {
        self.identity
    }

    pub(crate) fn reorder_values(
        &self,
        source_values: &[f64],
    ) -> Result<Vec<f64>, ParameterOrderError> {
        if source_values.len() != self.width {
            return Err(ParameterOrderError::WidthMismatch {
                expected: self.width,
                got: source_values.len(),
            });
        }

        if self.identity {
            return Ok(source_values.to_vec());
        }

        Ok(self
            .permutation
            .iter()
            .map(|source_index| source_values[*source_index])
            .collect())
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ParameterOrderError {
    MissingMetadata,
    UnknownParameter { name: String },
    DuplicateParameter { name: String },
    MissingParameters { names: Vec<String> },
    WidthMismatch { expected: usize, got: usize },
}

impl fmt::Display for ParameterOrderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingMetadata => {
                f.write_str("named parameter ingress requires parameter metadata")
            }
            Self::UnknownParameter { name } => write!(f, "unknown parameter `{name}`"),
            Self::DuplicateParameter { name } => write!(f, "duplicate parameter `{name}`"),
            Self::MissingParameters { names } => {
                write!(f, "missing required parameter(s): {}", names.join(", "))
            }
            Self::WidthMismatch { expected, got } => {
                write!(f, "parameter order expects {expected} value(s), got {got}")
            }
        }
    }
}

impl Error for ParameterOrderError {}

#[cfg(test)]
mod tests {
    use super::{ParameterOrderError, ParameterOrderPlan};

    #[cfg(feature = "dsl-core")]
    use crate::dsl::NativeModelInfo;
    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
    use crate::{metadata, ModelKind};
    #[cfg(feature = "dsl-core")]
    use pharmsol_dsl::ModelKind as DslModelKind;

    #[test]
    fn builds_identity_permutation() {
        let plan = ParameterOrderPlan::from_names(["ka", "ke"], ["ka", "ke"]).unwrap();

        assert_eq!(plan.permutation(), &[0, 1]);
        assert_eq!(plan.width(), 2);
        assert!(plan.is_identity());
        assert_eq!(plan.reorder_values(&[0.1, 0.3]).unwrap(), vec![0.1, 0.3]);
    }

    #[test]
    fn builds_reordered_permutation() {
        let plan = ParameterOrderPlan::from_names(["ka", "ke"], ["ke", "ka"]).unwrap();

        assert_eq!(plan.permutation(), &[1, 0]);
        assert_eq!(plan.width(), 2);
        assert!(!plan.is_identity());
        assert_eq!(plan.reorder_values(&[0.3, 0.1]).unwrap(), vec![0.1, 0.3]);
    }

    #[test]
    fn rejects_unknown_parameter() {
        let error = ParameterOrderPlan::from_names(["ka", "ke"], ["ka", "kel"]).unwrap_err();

        assert_eq!(
            error,
            ParameterOrderError::UnknownParameter {
                name: "kel".to_string(),
            }
        );
        assert_eq!(error.to_string(), "unknown parameter `kel`");
    }

    #[test]
    fn rejects_duplicate_parameter() {
        let error = ParameterOrderPlan::from_names(["ka", "ke"], ["ka", "ka"]).unwrap_err();

        assert_eq!(
            error,
            ParameterOrderError::DuplicateParameter {
                name: "ka".to_string(),
            }
        );
        assert_eq!(error.to_string(), "duplicate parameter `ka`");
    }

    #[test]
    fn reports_all_missing_parameters_in_model_order() {
        let error = ParameterOrderPlan::from_names(["ka", "ke", "v"], ["v"]).unwrap_err();

        assert_eq!(
            error,
            ParameterOrderError::MissingParameters {
                names: vec!["ka".to_string(), "ke".to_string()],
            }
        );
        assert_eq!(error.to_string(), "missing required parameter(s): ka, ke");
    }

    #[test]
    fn rejects_width_mismatch_when_reordering_values() {
        let plan = ParameterOrderPlan::from_names(["ka", "ke"], ["ke", "ka"]).unwrap();
        let error = plan.reorder_values(&[0.3]).unwrap_err();

        assert_eq!(
            error,
            ParameterOrderError::WidthMismatch {
                expected: 2,
                got: 1,
            }
        );
        assert_eq!(
            error.to_string(),
            "parameter order expects 2 value(s), got 1"
        );
    }

    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
    #[test]
    fn metadata_wrapper_requires_metadata() {
        let error = ParameterOrderPlan::from_metadata(None, ["ka", "ke"]).unwrap_err();

        assert_eq!(error, ParameterOrderError::MissingMetadata);
        assert_eq!(
            error.to_string(),
            "named parameter ingress requires parameter metadata"
        );
    }

    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
    #[test]
    fn metadata_wrapper_uses_declared_parameter_order() {
        let metadata = metadata::new("one_cmt")
            .kind(ModelKind::Ode)
            .parameters(["ka", "ke"])
            .states(["central"])
            .outputs(["cp"])
            .route(metadata::Route::infusion("iv").to_state("central"))
            .validate()
            .unwrap();

        let plan = ParameterOrderPlan::from_metadata(Some(&metadata), ["ke", "ka"]).unwrap();

        assert_eq!(plan.permutation(), &[1, 0]);
        assert_eq!(plan.reorder_values(&[0.3, 0.1]).unwrap(), vec![0.1, 0.3]);
    }

    #[cfg(feature = "dsl-core")]
    #[test]
    fn runtime_info_wrapper_uses_declared_parameter_order() {
        let info = NativeModelInfo {
            name: "one_cmt".to_string(),
            kind: DslModelKind::Ode,
            parameters: vec!["ka".to_string(), "ke".to_string()],
            derived: Vec::new(),
            covariates: Vec::new(),
            states: Vec::new(),
            routes: Vec::new(),
            outputs: Vec::new(),
            state_len: 0,
            derived_len: 0,
            output_len: 0,
            route_len: 0,
            analytical: None,
            particles: None,
        };

        let plan = ParameterOrderPlan::from_runtime_info(&info, ["ke", "ka"]).unwrap();

        assert_eq!(plan.permutation(), &[1, 0]);
        assert_eq!(plan.reorder_values(&[0.3, 0.1]).unwrap(), vec![0.1, 0.3]);
    }
}