use crate::{CouplingEntry, EsmFile};
fn sorted_keys<V>(map: &indexmap::IndexMap<String, V>) -> Vec<&String> {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
keys
}
const DEFAULT_SYSTEM: &str = "default";
const NON_EQUATION_INDEX: i64 = -1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Both,
Incoming,
Outgoing,
}
fn neighbours<'a, I>(known: bool, edges: I, node: &str, direction: Direction) -> Vec<String>
where
I: Iterator<Item = (&'a str, &'a str)>,
{
if !known {
return Vec::new();
}
let mut out = std::collections::BTreeSet::new();
for (source, target) in edges {
if direction != Direction::Incoming && source == node {
out.insert(target.to_string());
}
if direction != Direction::Outgoing && target == node {
out.insert(source.to_string());
}
}
out.into_iter().collect()
}
fn render_json_graph<N, E>(
nodes: &[N],
node_key: impl Fn(&N) -> String,
edges: &[E],
edge_endpoints: impl Fn(&E) -> (String, String),
adjacency: impl Fn(&str) -> Vec<String>,
) -> String
where
N: serde::Serialize,
E: serde::Serialize,
{
let json_nodes: Vec<serde_json::Value> = nodes
.iter()
.map(|node| {
let key = node_key(node);
let mut value = serde_json::to_value(node).unwrap_or(serde_json::Value::Null);
if let Some(object) = value.as_object_mut() {
object.insert("id".to_string(), serde_json::Value::String(key));
}
value
})
.collect();
let json_edges: Vec<serde_json::Value> = edges
.iter()
.map(|edge| {
let (source, target) = edge_endpoints(edge);
serde_json::json!({
"source": source,
"target": target,
"data": serde_json::to_value(edge).unwrap_or(serde_json::Value::Null),
})
})
.collect();
let mut json_adjacency = serde_json::Map::new();
for node in nodes {
let key = node_key(node);
let neighbours = adjacency(&key);
json_adjacency.insert(key, serde_json::to_value(neighbours).unwrap_or_default());
}
let graph = serde_json::json!({
"nodes": json_nodes,
"edges": json_edges,
"adjacency": serde_json::Value::Object(json_adjacency),
});
serde_json::to_string_pretty(&graph).unwrap_or_else(|_| "{}".to_string())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ComponentGraph {
pub nodes: Vec<ComponentNode>,
pub edges: Vec<CouplingEdge>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct ComponentMetadata {
pub var_count: usize,
pub eq_count: usize,
pub species_count: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ComponentNode {
pub id: String,
pub component_type: ComponentType,
pub name: Option<String>,
pub metadata: ComponentMetadata,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentType {
Model,
ReactionSystem,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CouplingEdgeKind {
OperatorCompose,
Couple,
VariableMap,
}
impl std::fmt::Display for CouplingEdgeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
CouplingEdgeKind::OperatorCompose => "operator_compose",
CouplingEdgeKind::Couple => "couple",
CouplingEdgeKind::VariableMap => "variable_map",
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CouplingEdge {
pub from: String,
pub to: String,
pub coupling_type: CouplingEdgeKind,
pub label: String,
pub data: serde_json::Value,
}
pub fn component_graph(esm_file: &EsmFile) -> ComponentGraph {
let mut nodes = Vec::new();
let mut edges = Vec::new();
if let Some(ref models) = esm_file.models {
for id in sorted_keys(models) {
let model = &models[id];
nodes.push(ComponentNode {
id: id.clone(),
component_type: ComponentType::Model,
name: model.name.clone(),
metadata: ComponentMetadata {
var_count: model.variables.len(),
eq_count: model.equations.len(),
species_count: 0,
},
});
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for id in sorted_keys(reaction_systems) {
let rs = &reaction_systems[id];
nodes.push(ComponentNode {
id: id.clone(),
component_type: ComponentType::ReactionSystem,
name: None,
metadata: ComponentMetadata {
var_count: rs.parameters.len(),
eq_count: rs.reactions.len(),
species_count: rs.species.len(),
},
});
}
}
let node_ids: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
if let Some(ref coupling_entries) = esm_file.coupling {
for entry in coupling_entries {
let (from, to, kind, label) = match entry {
CouplingEntry::OperatorCompose { systems, .. } => {
if systems.len() >= 2 {
(
systems[0].clone(),
systems[1].clone(),
CouplingEdgeKind::OperatorCompose,
"compose".to_string(),
)
} else {
continue; }
}
CouplingEntry::Couple { systems, .. } => {
if systems.len() == 2 {
(
systems[0].clone(),
systems[1].clone(),
CouplingEdgeKind::Couple,
"couple".to_string(),
)
} else {
continue; }
}
CouplingEntry::VariableMap { from, to, .. } => {
let (Some((from_system, from_var)), Some((to_system, _))) =
(from.split_once('.'), to.split_once('.'))
else {
continue;
};
(
from_system.to_string(),
to_system.to_string(),
CouplingEdgeKind::VariableMap,
from_var.to_string(),
)
}
CouplingEntry::OperatorApply { .. }
| CouplingEntry::Callback { .. }
| CouplingEntry::Event { .. }
| CouplingEntry::CouplingImport { .. } => continue,
};
if node_ids.contains(from.as_str()) && node_ids.contains(to.as_str()) {
edges.push(CouplingEdge {
from,
to,
coupling_type: kind,
label,
data: serde_json::Value::Null,
});
}
}
}
ComponentGraph { nodes, edges }
}
pub fn component_exists(esm_file: &EsmFile, component_id: &str) -> bool {
get_component_type(esm_file, component_id).is_some()
}
pub fn get_component_type(esm_file: &EsmFile, component_id: &str) -> Option<ComponentType> {
fn contains<V>(map: &Option<indexmap::IndexMap<String, V>>, key: &str) -> bool {
map.as_ref().is_some_and(|m| m.contains_key(key))
}
if contains(&esm_file.models, component_id) {
Some(ComponentType::Model)
} else if contains(&esm_file.reaction_systems, component_id) {
Some(ComponentType::ReactionSystem)
} else {
None
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExpressionGraph {
pub nodes: Vec<VariableNode>,
pub edges: Vec<DependencyEdge>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VariableNode {
pub name: String,
pub kind: VariableKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
pub system: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VariableKind {
State,
Algebraic,
Parameter,
Observed,
Brownian,
Discrete,
Species,
}
fn variable_kind(class: &crate::classification::Classification, name: &str) -> VariableKind {
if class.is_observed(name) {
VariableKind::Observed
} else if class.is_brownian(name) {
VariableKind::Brownian
} else if class.is_discrete_parameter(name) {
VariableKind::Discrete
} else if class.ode_states.iter().any(|s| s == name) {
VariableKind::State
} else if class.algebraic_unknowns.iter().any(|s| s == name) {
VariableKind::Algebraic
} else {
VariableKind::Parameter
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DependencyEdge {
pub source: String,
pub target: String,
pub relationship: DependencyRelationship,
#[serde(skip_serializing_if = "Option::is_none")]
pub equation_index: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expression: Option<crate::Expr>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DependencyRelationship {
Additive,
Multiplicative,
Rate,
Stoichiometric,
}
#[derive(Debug, Clone, Default)]
pub struct ExpressionGraphOptions {
pub merge_coupled: bool,
}
pub fn expression_graph<T>(input: &T) -> ExpressionGraph
where
T: ExpressionGraphInput,
{
input.build_expression_graph()
}
pub fn expression_graph_with_options<T>(
input: &T,
options: &ExpressionGraphOptions,
) -> ExpressionGraph
where
T: ExpressionGraphInput,
{
input.build_expression_graph_with_options(options)
}
pub trait ExpressionGraphInput {
fn build_expression_graph(&self) -> ExpressionGraph;
fn build_expression_graph_with_options(
&self,
_options: &ExpressionGraphOptions,
) -> ExpressionGraph {
self.build_expression_graph()
}
}
struct ExprGraphBuilder {
nodes: Vec<VariableNode>,
edges: Vec<DependencyEdge>,
seen: std::collections::HashSet<String>,
}
impl ExprGraphBuilder {
fn new() -> Self {
ExprGraphBuilder {
nodes: Vec::new(),
edges: Vec::new(),
seen: std::collections::HashSet::new(),
}
}
fn add_node(
&mut self,
name: &str,
kind: VariableKind,
units: Option<String>,
system: &str,
) -> String {
let scoped = if system == DEFAULT_SYSTEM {
name.to_string()
} else {
format!("{system}.{name}")
};
if self.seen.insert(scoped.clone()) {
self.nodes.push(VariableNode {
name: scoped.clone(),
kind,
units,
system: system.to_string(),
});
}
scoped
}
fn add_dependency(
&mut self,
source: String,
target: String,
relationship: DependencyRelationship,
equation_index: i64,
expression: Option<crate::Expr>,
) {
self.edges.push(DependencyEdge {
source,
target,
relationship,
equation_index: Some(equation_index),
expression,
});
}
fn finish(self) -> ExpressionGraph {
ExpressionGraph {
nodes: self.nodes,
edges: self.edges,
}
}
}
impl ExpressionGraphInput for crate::EsmFile {
fn build_expression_graph(&self) -> ExpressionGraph {
self.build_expression_graph_with_options(&ExpressionGraphOptions::default())
}
fn build_expression_graph_with_options(
&self,
options: &ExpressionGraphOptions,
) -> ExpressionGraph {
let mut b = ExprGraphBuilder::new();
if let Some(ref models) = self.models {
for model_id in sorted_keys(models) {
process_model_tree(&mut b, &models[model_id], model_id);
}
}
if let Some(ref reaction_systems) = self.reaction_systems {
for rs_id in sorted_keys(reaction_systems) {
process_reaction_system_tree(&mut b, &reaction_systems[rs_id], rs_id);
}
}
if options.merge_coupled
&& let Some(ref coupling) = self.coupling
{
process_coupling(&mut b, coupling);
}
b.finish()
}
}
impl ExpressionGraphInput for crate::Model {
fn build_expression_graph(&self) -> ExpressionGraph {
let mut b = ExprGraphBuilder::new();
process_model_tree(&mut b, self, DEFAULT_SYSTEM);
b.finish()
}
}
impl ExpressionGraphInput for crate::ReactionSystem {
fn build_expression_graph(&self) -> ExpressionGraph {
let mut b = ExprGraphBuilder::new();
process_reaction_system_tree(&mut b, self, DEFAULT_SYSTEM);
b.finish()
}
}
impl ExpressionGraphInput for crate::Equation {
fn build_expression_graph(&self) -> ExpressionGraph {
let mut b = ExprGraphBuilder::new();
process_equation(&mut b, self, 0, DEFAULT_SYSTEM);
b.finish()
}
}
impl ExpressionGraphInput for crate::Reaction {
fn build_expression_graph(&self) -> ExpressionGraph {
let mut b = ExprGraphBuilder::new();
process_reaction(&mut b, self, 0, DEFAULT_SYSTEM);
b.finish()
}
}
impl ExpressionGraphInput for crate::Expr {
fn build_expression_graph(&self) -> ExpressionGraph {
let mut b = ExprGraphBuilder::new();
process_expression(
&mut b,
self,
"expr_result",
NON_EQUATION_INDEX,
DEFAULT_SYSTEM,
);
b.finish()
}
}
fn is_reference_stub(entry: &serde_json::Value) -> bool {
entry.get("ref").is_some()
}
fn as_model(entry: &serde_json::Value) -> Option<crate::Model> {
let mut value = entry.clone();
let object = value.as_object_mut()?;
object
.entry("variables")
.or_insert_with(|| serde_json::json!({}));
object
.entry("equations")
.or_insert_with(|| serde_json::json!([]));
serde_json::from_value(value).ok()
}
fn as_reaction_system(entry: &serde_json::Value) -> Option<crate::ReactionSystem> {
let mut value = entry.clone();
let object = value.as_object_mut()?;
object
.entry("species")
.or_insert_with(|| serde_json::json!({}));
object
.entry("parameters")
.or_insert_with(|| serde_json::json!({}));
object
.entry("reactions")
.or_insert_with(|| serde_json::json!([]));
serde_json::from_value(value).ok()
}
fn child_scope(system_id: &str, child_name: &str) -> String {
if system_id == DEFAULT_SYSTEM {
child_name.to_string()
} else {
format!("{system_id}.{child_name}")
}
}
fn process_model_tree(b: &mut ExprGraphBuilder, model: &crate::Model, system_id: &str) {
process_model(b, model, system_id);
let Some(ref subsystems) = model.subsystems else {
return;
};
for child_name in sorted_keys(subsystems) {
let entry = &subsystems[child_name];
if is_reference_stub(entry) {
continue;
}
if let Some(child) = as_model(entry) {
process_model_tree(b, &child, &child_scope(system_id, child_name));
}
}
}
fn process_reaction_system_tree(
b: &mut ExprGraphBuilder,
rs: &crate::ReactionSystem,
system_id: &str,
) {
process_reaction_system(b, rs, system_id);
let Some(ref subsystems) = rs.subsystems else {
return;
};
for child_name in sorted_keys(subsystems) {
let entry = &subsystems[child_name];
if is_reference_stub(entry) {
continue;
}
if let Some(child) = as_reaction_system(entry) {
process_reaction_system_tree(b, &child, &child_scope(system_id, child_name));
}
}
}
fn process_model(b: &mut ExprGraphBuilder, model: &crate::Model, system_id: &str) {
let class = crate::classification::Classification::of(model);
for var_name in sorted_keys(&model.variables) {
let kind = variable_kind(&class, var_name);
b.add_node(
var_name,
kind,
model.variables[var_name].units.clone(),
system_id,
);
}
for (eq_idx, equation) in model.equations.iter().enumerate() {
process_equation(b, equation, eq_idx as i64, system_id);
}
}
fn process_reaction_system(b: &mut ExprGraphBuilder, rs: &crate::ReactionSystem, system_id: &str) {
for species_name in sorted_keys(&rs.species) {
b.add_node(
species_name,
VariableKind::Species,
rs.species[species_name].units.clone(),
system_id,
);
}
for param_name in sorted_keys(&rs.parameters) {
b.add_node(
param_name,
VariableKind::Parameter,
rs.parameters[param_name].units.clone(),
system_id,
);
}
for (rxn_idx, reaction) in rs.reactions.iter().enumerate() {
process_reaction(b, reaction, rxn_idx as i64, system_id);
}
if let Some(ref constraints) = rs.constraint_equations {
for (idx, equation) in constraints.iter().enumerate() {
process_equation(b, equation, (idx + rs.reactions.len()) as i64, system_id);
}
}
}
fn lhs_target_name(lhs: &crate::Expr) -> Option<String> {
match lhs {
crate::Expr::Variable(name) => Some(name.clone()),
crate::Expr::Operator(node) => match node.op.as_str() {
"D" | "index" => node.args.first().and_then(lhs_target_name),
"faq" => node.expr.as_deref().and_then(lhs_target_name),
_ => None,
},
crate::Expr::Number(_) | crate::Expr::Integer(_) => None,
}
}
fn process_equation(
b: &mut ExprGraphBuilder,
equation: &crate::Equation,
eq_idx: i64,
system_id: &str,
) {
let Some(target_name) = lhs_target_name(&equation.lhs) else {
return;
};
let lhs_var = b.add_node(&target_name, VariableKind::State, None, system_id);
for rhs_var in extract_variables_from_expr(&equation.rhs) {
let source = b.add_node(&rhs_var, VariableKind::Parameter, None, system_id);
b.add_dependency(
source,
lhs_var.clone(),
DependencyRelationship::Additive,
eq_idx,
Some(equation.rhs.clone()),
);
}
}
fn process_reaction(
b: &mut ExprGraphBuilder,
reaction: &crate::Reaction,
rxn_idx: i64,
system_id: &str,
) {
let rate_vars = extract_variables_from_expr(&reaction.rate);
for substrate in reaction.substrates.iter().flatten() {
let substrate_var = b.add_node(&substrate.species, VariableKind::Species, None, system_id);
for rate_var in &rate_vars {
let param = b.add_node(rate_var, VariableKind::Parameter, None, system_id);
b.add_dependency(
param,
substrate_var.clone(),
DependencyRelationship::Rate,
rxn_idx,
Some(reaction.rate.clone()),
);
}
}
for product in reaction.products.iter().flatten() {
let product_var = b.add_node(&product.species, VariableKind::Species, None, system_id);
for rate_var in &rate_vars {
let param = b.add_node(rate_var, VariableKind::Parameter, None, system_id);
b.add_dependency(
param,
product_var.clone(),
DependencyRelationship::Rate,
rxn_idx,
Some(reaction.rate.clone()),
);
}
for substrate in reaction.substrates.iter().flatten() {
let substrate_var =
b.add_node(&substrate.species, VariableKind::Species, None, system_id);
b.add_dependency(
substrate_var,
product_var.clone(),
DependencyRelationship::Stoichiometric,
rxn_idx,
Some(reaction.rate.clone()),
);
}
}
}
fn process_expression(
b: &mut ExprGraphBuilder,
expr: &crate::Expr,
target_var: &str,
eq_idx: i64,
system_id: &str,
) {
let target = b.add_node(target_var, VariableKind::Observed, None, system_id);
for free_var in extract_variables_from_expr(expr) {
let source = b.add_node(&free_var, VariableKind::Parameter, None, system_id);
b.add_dependency(
source,
target.clone(),
DependencyRelationship::Multiplicative,
eq_idx,
Some(expr.clone()),
);
}
}
fn process_coupling(b: &mut ExprGraphBuilder, coupling: &[CouplingEntry]) {
for entry in coupling {
let CouplingEntry::VariableMap { from, to, .. } = entry else {
continue;
};
let (Some((from_system, from_var)), Some((to_system, to_var))) =
(from.split_once('.'), to.split_once('.'))
else {
continue;
};
let source = b.add_node(from_var, VariableKind::Parameter, None, from_system);
let target = b.add_node(to_var, VariableKind::Parameter, None, to_system);
b.add_dependency(
source,
target,
DependencyRelationship::Multiplicative,
NON_EQUATION_INDEX,
Some(crate::Expr::Variable(from.clone())),
);
}
}
fn graph_bound_index_symbols(node: &crate::types::ExpressionNode) -> Vec<String> {
let mut bound = Vec::new();
if let Some(ranges) = &node.ranges {
bound.extend(ranges.keys().cloned());
}
if let Some(idx) = &node.output_idx {
bound.extend(idx.iter().cloned());
}
if let Some(v) = &node.int_var {
bound.push(v.clone());
}
bound
}
fn collect_graph_variables(expr: &crate::Expr, out: &mut std::collections::HashSet<String>) {
match expr {
crate::Expr::Variable(name) => {
out.insert(name.clone());
}
crate::Expr::Operator(node) => {
let mut local = std::collections::HashSet::new();
node.for_each_child(&mut |child| collect_graph_variables(child, &mut local));
for symbol in graph_bound_index_symbols(node) {
local.remove(&symbol);
}
out.extend(local);
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {}
}
}
fn extract_variables_from_expr(expr: &crate::Expr) -> Vec<String> {
let mut set = std::collections::HashSet::new();
collect_graph_variables(expr, &mut set);
let mut vars: Vec<String> = set.into_iter().collect();
vars.sort();
vars
}
fn escape_dot(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn mermaid_label(s: &str) -> String {
if s.contains(['"', '(', ')', '[', ']', '{', '}', '<', '>', '|']) {
format!("\"{}\"", s.replace('"', "#quot;"))
} else {
s.to_string()
}
}
impl ComponentGraph {
fn has_node(&self, id: &str) -> bool {
self.nodes.iter().any(|n| n.id == id)
}
fn endpoints(&self) -> impl Iterator<Item = (&str, &str)> {
self.edges.iter().map(|e| (e.from.as_str(), e.to.as_str()))
}
pub fn adjacency(&self, id: &str) -> Vec<String> {
neighbours(self.has_node(id), self.endpoints(), id, Direction::Both)
}
pub fn predecessors(&self, id: &str) -> Vec<String> {
neighbours(self.has_node(id), self.endpoints(), id, Direction::Incoming)
}
pub fn successors(&self, id: &str) -> Vec<String> {
neighbours(self.has_node(id), self.endpoints(), id, Direction::Outgoing)
}
pub fn to_dot(&self) -> String {
let mut dot = String::from("digraph ComponentGraph {\n");
dot.push_str(" rankdir=LR;\n");
dot.push_str(" node [shape=box];\n\n");
for node in &self.nodes {
let shape = match node.component_type {
ComponentType::Model => "ellipse",
ComponentType::ReactionSystem => "box",
};
let label = node.name.as_ref().unwrap_or(&node.id);
dot.push_str(&format!(
" \"{}\" [label=\"{}\" shape={}];\n",
escape_dot(&node.id),
escape_dot(label),
shape
));
}
dot.push('\n');
for edge in &self.edges {
dot.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"];\n",
escape_dot(&edge.from),
escape_dot(&edge.to),
escape_dot(&edge.coupling_type.to_string())
));
}
dot.push_str("}\n");
dot
}
pub fn to_mermaid(&self) -> String {
let mut mermaid = String::from("graph TD\n");
for node in &self.nodes {
let shape = match node.component_type {
ComponentType::Model => ("(", ")"),
ComponentType::ReactionSystem => ("[", "]"),
};
let label = node.name.as_ref().unwrap_or(&node.id);
mermaid.push_str(&format!(
" {}{}{}{}\n",
node.id,
shape.0,
mermaid_label(label),
shape.1
));
}
for edge in &self.edges {
mermaid.push_str(&format!(
" {} -->|{}| {}\n",
edge.from,
mermaid_label(&edge.coupling_type.to_string()),
edge.to
));
}
mermaid
}
pub fn to_json_graph(&self) -> String {
render_json_graph(
&self.nodes,
|node| node.id.clone(),
&self.edges,
|edge| (edge.from.clone(), edge.to.clone()),
|id| self.adjacency(id),
)
}
}
impl ExpressionGraph {
fn has_node(&self, name: &str) -> bool {
self.nodes.iter().any(|n| n.name == name)
}
fn endpoints(&self) -> impl Iterator<Item = (&str, &str)> {
self.edges
.iter()
.map(|e| (e.source.as_str(), e.target.as_str()))
}
pub fn adjacency(&self, name: &str) -> Vec<String> {
neighbours(self.has_node(name), self.endpoints(), name, Direction::Both)
}
pub fn predecessors(&self, name: &str) -> Vec<String> {
neighbours(
self.has_node(name),
self.endpoints(),
name,
Direction::Incoming,
)
}
pub fn successors(&self, name: &str) -> Vec<String> {
neighbours(
self.has_node(name),
self.endpoints(),
name,
Direction::Outgoing,
)
}
pub fn to_dot(&self) -> String {
let mut dot = String::from("digraph ExpressionGraph {\n");
dot.push_str(" rankdir=TB;\n");
dot.push_str(" node [shape=ellipse];\n\n");
for node in &self.nodes {
let shape = match node.kind {
VariableKind::State | VariableKind::Algebraic => "ellipse",
VariableKind::Parameter => "box",
VariableKind::Observed => "diamond",
VariableKind::Brownian => "doubleoctagon",
VariableKind::Discrete => "hexagon",
VariableKind::Species => "circle",
};
dot.push_str(&format!(
" \"{}\" [label=\"{}\" shape={}];\n",
escape_dot(&node.name),
escape_dot(&node.name),
shape
));
}
dot.push('\n');
for edge in &self.edges {
let label = match edge.relationship {
DependencyRelationship::Additive => "additive",
DependencyRelationship::Multiplicative => "mult",
DependencyRelationship::Rate => "rate",
DependencyRelationship::Stoichiometric => "stoich",
};
dot.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"];\n",
escape_dot(&edge.source),
escape_dot(&edge.target),
label
));
}
dot.push_str("}\n");
dot
}
pub fn to_mermaid(&self) -> String {
let mut mermaid = String::from("graph TD\n");
for node in &self.nodes {
let (shape_start, shape_end) = match node.kind {
VariableKind::State | VariableKind::Algebraic => ("(", ")"),
VariableKind::Parameter => ("[", "]"),
VariableKind::Observed => ("{", "}"),
VariableKind::Brownian => ("{{", "}}"),
VariableKind::Discrete => ("[/", "/]"),
VariableKind::Species => ("((", "))"),
};
mermaid.push_str(&format!(
" {}{}{}{}\n",
node.name,
shape_start,
mermaid_label(&node.name),
shape_end
));
}
for edge in &self.edges {
mermaid.push_str(&format!(" {} --> {}\n", edge.source, edge.target));
}
mermaid
}
pub fn to_json_graph(&self) -> String {
render_json_graph(
&self.nodes,
|node| node.name.clone(),
&self.edges,
|edge| (edge.source.clone(), edge.target.clone()),
|name| self.adjacency(name),
)
}
}
pub trait Graph {
fn render_dot(&self) -> String;
fn render_mermaid(&self) -> String;
fn render_json_graph(&self) -> String;
}
impl Graph for ComponentGraph {
fn render_dot(&self) -> String {
ComponentGraph::to_dot(self)
}
fn render_mermaid(&self) -> String {
ComponentGraph::to_mermaid(self)
}
fn render_json_graph(&self) -> String {
ComponentGraph::to_json_graph(self)
}
}
impl Graph for ExpressionGraph {
fn render_dot(&self) -> String {
ExpressionGraph::to_dot(self)
}
fn render_mermaid(&self) -> String {
ExpressionGraph::to_mermaid(self)
}
fn render_json_graph(&self) -> String {
ExpressionGraph::to_json_graph(self)
}
}
pub fn to_dot<G: Graph>(graph: &G) -> String {
graph.render_dot()
}
pub fn to_mermaid<G: Graph>(graph: &G) -> String {
graph.render_mermaid()
}
pub fn to_json_graph<G: Graph>(graph: &G) -> String {
graph.render_json_graph()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::test_file;
use crate::{Expr, ExpressionNode as ExprNode, Model, ReactionSystem};
use indexmap::IndexMap;
fn test_model(name: &str) -> Model {
Model {
name: Some(name.to_string()),
..Default::default()
}
}
#[test]
fn test_component_graph_empty() {
let esm_file = test_file();
let graph = component_graph(&esm_file);
assert_eq!(graph.nodes.len(), 0);
assert_eq!(graph.edges.len(), 0);
}
#[test]
fn test_component_graph_with_models() {
let mut models = IndexMap::new();
models.insert("model1".to_string(), test_model("Test Model 1"));
models.insert("model2".to_string(), test_model("Test Model 2"));
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let graph = component_graph(&esm_file);
assert_eq!(graph.nodes.len(), 2);
let node1 = graph.nodes.iter().find(|n| n.id == "model1").unwrap();
assert_eq!(node1.component_type, ComponentType::Model);
assert_eq!(node1.name, Some("Test Model 1".to_string()));
let node2 = graph.nodes.iter().find(|n| n.id == "model2").unwrap();
assert_eq!(node2.component_type, ComponentType::Model);
assert_eq!(node2.name, Some("Test Model 2".to_string()));
}
#[test]
fn test_component_exists() {
let mut models = IndexMap::new();
models.insert("test_model".to_string(), test_model("Test Model"));
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
assert!(component_exists(&esm_file, "test_model"));
assert!(!component_exists(&esm_file, "nonexistent"));
}
#[test]
fn test_get_component_type() {
let mut models = IndexMap::new();
models.insert("test_model".to_string(), test_model("Test Model"));
let mut reaction_systems = IndexMap::new();
reaction_systems.insert("test_rs".to_string(), ReactionSystem::default());
let esm_file = EsmFile {
models: Some(models),
reaction_systems: Some(reaction_systems),
..test_file()
};
assert_eq!(
get_component_type(&esm_file, "test_model"),
Some(ComponentType::Model)
);
assert_eq!(
get_component_type(&esm_file, "test_rs"),
Some(ComponentType::ReactionSystem)
);
assert_eq!(get_component_type(&esm_file, "nonexistent"), None);
}
#[test]
fn test_expression_graph() {
let expr = Expr::operator(ExprNode {
op: "+".to_string(),
args: vec![Expr::Variable("x".to_string()), Expr::Number(1.0)],
wrt: None,
dim: None,
..Default::default()
});
let graph = expression_graph(&expr);
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
let result = graph
.nodes
.iter()
.find(|n| n.name == "expr_result")
.expect("synthetic result node");
assert_eq!(result.kind, VariableKind::Observed);
assert_eq!(result.system, "default");
let x = graph
.nodes
.iter()
.find(|n| n.name == "x")
.expect("free variable node");
assert_eq!(x.kind, VariableKind::Parameter);
assert_eq!(graph.edges[0].source, "x");
assert_eq!(graph.edges[0].target, "expr_result");
assert_eq!(
graph.edges[0].relationship,
DependencyRelationship::Multiplicative
);
assert_eq!(graph.edges[0].equation_index, Some(NON_EQUATION_INDEX));
}
#[test]
fn test_component_graph_to_dot() {
let graph = ComponentGraph {
nodes: vec![ComponentNode {
id: "model1".to_string(),
component_type: ComponentType::Model,
name: Some("Test Model".to_string()),
metadata: ComponentMetadata::default(),
}],
edges: vec![],
};
let dot = graph.to_dot();
assert!(dot.contains("digraph ComponentGraph"));
assert!(dot.contains("model1"));
assert!(dot.contains("Test Model"));
}
#[test]
fn test_component_graph_to_mermaid() {
let graph = ComponentGraph {
nodes: vec![ComponentNode {
id: "model1".to_string(),
component_type: ComponentType::Model,
name: Some("Test Model".to_string()),
metadata: ComponentMetadata::default(),
}],
edges: vec![],
};
let mermaid = graph.to_mermaid();
assert!(mermaid.contains("graph TD"));
assert!(mermaid.contains("model1(Test Model)"));
}
#[test]
fn test_expression_graph_to_mermaid() {
let expr = Expr::operator(ExprNode {
op: "+".to_string(),
args: vec![Expr::Variable("x".to_string()), Expr::Number(1.0)],
wrt: None,
dim: None,
..Default::default()
});
let graph = expression_graph(&expr);
let mermaid = graph.to_mermaid();
assert!(mermaid.contains("graph TD"));
assert!(mermaid.contains("x[x]")); assert!(!mermaid.contains("const_")); assert!(!mermaid.contains("{+}")); assert!(mermaid.contains("expr_result{expr_result}"));
assert!(mermaid.contains("x --> expr_result"));
}
#[test]
fn test_component_graph_variable_map_edge_extraction() {
let mut models = IndexMap::new();
models.insert("source".to_string(), test_model("Source System"));
models.insert("target".to_string(), test_model("Target System"));
let coupling_entries = vec![crate::CouplingEntry::VariableMap {
from: "source.var".to_string(),
to: "target.param".to_string(),
transform: crate::types::VariableMapTransform::Named("identity".to_string()),
factor: None,
lifting: None,
description: None,
}];
let esm_file = EsmFile {
models: Some(models),
coupling: Some(coupling_entries),
..test_file()
};
let graph = component_graph(&esm_file);
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
let edge = &graph.edges[0];
assert_eq!(edge.from, "source"); assert_eq!(edge.to, "target"); assert_eq!(edge.coupling_type, CouplingEdgeKind::VariableMap);
assert_eq!(edge.label, "var");
}
#[test]
fn test_component_graph_variable_map_requires_scoped_endpoints() {
let mut models = IndexMap::new();
for id in ["source", "target"] {
models.insert(id.to_string(), test_model(id));
}
let esm_file = EsmFile {
coupling: Some(vec![crate::CouplingEntry::VariableMap {
from: "source".to_string(),
to: "target".to_string(),
transform: crate::types::VariableMapTransform::Named("identity".to_string()),
factor: None,
lifting: None,
description: None,
}]),
models: Some(models),
..test_file()
};
let graph = component_graph(&esm_file);
assert_eq!(graph.nodes.len(), 2);
assert!(graph.edges.is_empty());
}
#[test]
fn test_data_sources_contribute_no_nodes() {
let json = r#"{
"esm": "1.0.0",
"metadata": { "name": "ds" },
"data_sources": {
"GEOSFP": {
"kind": "grid",
"source": { "url_template": "file:///data/GEOSFP/{date:%Y%m%d_%H%M}.nc" }
}
}
}"#;
let esm_file: EsmFile = serde_json::from_str(json).expect("parses");
let graph = component_graph(&esm_file);
assert!(graph.nodes.is_empty());
assert!(graph.edges.is_empty());
assert_eq!(get_component_type(&esm_file, "GEOSFP"), None);
assert!(!component_exists(&esm_file, "GEOSFP"));
}
#[test]
fn test_component_graph_closure() {
let mut models = IndexMap::new();
for id in ["source", "target"] {
models.insert(id.to_string(), test_model(id));
}
let esm_file = EsmFile {
coupling: Some(vec![crate::CouplingEntry::VariableMap {
from: "source.var".to_string(),
to: "target.param".to_string(),
transform: crate::types::VariableMapTransform::Named("identity".to_string()),
factor: None,
lifting: None,
description: None,
}]),
models: Some(models),
..test_file()
};
let graph = component_graph(&esm_file);
assert_eq!(graph.adjacency("source"), vec!["target".to_string()]);
assert_eq!(graph.successors("source"), vec!["target".to_string()]);
assert!(graph.predecessors("source").is_empty());
assert_eq!(graph.adjacency("target"), vec!["source".to_string()]);
assert_eq!(graph.predecessors("target"), vec!["source".to_string()]);
assert!(graph.successors("target").is_empty());
assert!(graph.adjacency("nonexistent").is_empty());
}
#[test]
fn test_to_json_graph_is_an_adjacency_list() {
let mut models = IndexMap::new();
for id in ["source", "target"] {
models.insert(id.to_string(), test_model(id));
}
let esm_file = EsmFile {
coupling: Some(vec![crate::CouplingEntry::VariableMap {
from: "source.var".to_string(),
to: "target.param".to_string(),
transform: crate::types::VariableMapTransform::Named("identity".to_string()),
factor: None,
lifting: None,
description: None,
}]),
models: Some(models),
..test_file()
};
let json: serde_json::Value =
serde_json::from_str(&component_graph(&esm_file).to_json_graph()).expect("valid JSON");
let mut keys: Vec<&String> = json.as_object().expect("object").keys().collect();
keys.sort();
assert_eq!(keys, vec!["adjacency", "edges", "nodes"]);
let nodes = json["nodes"].as_array().expect("nodes array");
assert_eq!(nodes[0]["id"], "source");
let edges = json["edges"].as_array().expect("edges array");
assert_eq!(edges[0]["source"], "source");
assert_eq!(edges[0]["target"], "target");
assert_eq!(edges[0]["data"]["coupling_type"], "variable_map");
assert_eq!(json["adjacency"]["source"][0], "target");
assert_eq!(json["adjacency"]["target"][0], "source");
}
#[test]
fn canonical_renderers_match_the_inherent_methods() {
let esm_file = EsmFile {
models: Some(IndexMap::from([("m".to_string(), test_model("M"))])),
..test_file()
};
let cg = component_graph(&esm_file);
assert_eq!(super::to_dot(&cg), cg.to_dot());
assert_eq!(super::to_mermaid(&cg), cg.to_mermaid());
assert_eq!(super::to_json_graph(&cg), cg.to_json_graph());
let eg = expression_graph(&esm_file);
assert_eq!(super::to_dot(&eg), eg.to_dot());
assert_eq!(super::to_mermaid(&eg), eg.to_mermaid());
assert_eq!(super::to_json_graph(&eg), eg.to_json_graph());
}
}