A correct-by-construction process modeling framework that leverages Rust's type system to enforce structural correctness at compile time.
Nomata is an embedded domain-specific language (EDSL) for chemical process modeling that prevents invalid process models from being representable. Instead of catching modeling errors at runtime, Nomata makes illegal flowsheets fail to compile.
- Compile-time correctness: Invalid process models cannot be constructed
- Type-safe connections: Unit operations can only be connected if their stream types match
- Variable role enforcement: Algebraic and differential variables are distinguished at the type level
- Time domain consistency: Steady-state and dynamic models cannot be mixed incorrectly
- Zero runtime overhead: All validation happens at compile time
Add to your Cargo.toml:
[dependencies]
nomata = "0.1.0"Nomata provides optional features for extended functionality:
[dependencies]
nomata = { version = "0.1.0", features = ["autodiff", "solvers", "thermodynamics"] }Available features:
autodiff: Automatic differentiation for Jacobian computation usingnum-dualsolvers: Numerical solvers for DAE systems usingdifferential-equationsthermodynamics: CoolProp bindings for thermodynamic properties usingrfluidsall: Enable all features
Variables are typed by their role in the model, enforcing proper usage:
- Parameters: Time-invariant constants (rate constants, physical properties)
- Algebraic: Instantaneous relationships without time derivatives
- Differential: State variables with accumulation and time derivatives
use nomata::{VariableRegistry,Var,Parameter,Algebraic,Differential};// Create a registry to manage shared statelet registry = VariableRegistry::new();// Parameters: fixed constantslet rate_constant:Var<Parameter> = registry.create_parameter(0.5);let density:Var<Parameter> = registry.create_parameter(1000.0);// Algebraic: computed valueslet temperature:Var<Algebraic> = registry.create_algebraic(298.15);// Differential: state variableslet holdup:Var<Differential> = registry.create_differential(100.0);// This compiles - differential variables can have derivativeslet dhdt = holdup.derivative();// This would NOT compile - algebraic variables cannot have derivatives// let dtdt = temperature.derivative(); // Compile error!// Parameters also cannot have derivatives (not time-varying)// let dkdt = rate_constant.derivative(); // Compile error!Why distinguish parameters from algebraic variables?
While both store scalar values, the semantic distinction enables:
- Sensitivity analysis: Automatic differentiation w.r.t. parameters
- Parameter estimation: Optimization workflows that adjust parameters
- Model structure clarity: Separates physics (structure) from values (parameters)
- Future extensibility: Different numerical treatments during solution
Nomata uses an index-based variable system to enable solver integration:
use nomata::{VariableRegistry,Var,Algebraic};let registry = VariableRegistry::new();let temp = registry.create_algebraic(298.15);// Multiple references share the same statelet temp_ref = temp.clone();
temp.set(310.0);assert_eq!(temp_ref.get(),310.0);// State is shared!// Solvers can access variables as vectorsletmut state = registry.get_all_values();
state[temp.id().index()] = 320.0;
registry.set_all_values(&state);assert_eq!(temp.get(),320.0);// Solver updates visible!Models are typed by their time behavior:
- Steady-state: No time derivatives allowed
- Dynamic: Time derivatives required for differential variables
use nomata::{Model,Steady,Dynamic};let steady_model:Model<Steady> = Model::new();let dynamic_model:Model<Dynamic> = Model::new();Process streams track composition, temperature, and pressure. The unified Stream type uses phantom types to enforce that conditions are explicitly initialized:
use nomata::{Stream,MolarFlow};// Create multi-component stream with uninitialized conditionslet stream = Stream::<MolarFlow,_>::new(100.0,// total_flowvec!["Water".to_string(),"Ethanol".to_string()],).at_conditions(298.15,101325.0);// Must set T and P!// Or use with_composition for specific mole fractionslet air = Stream::<MolarFlow,_>::with_composition(1000.0,vec!["N2","O2","Ar"],vec![0.78,0.21,0.01],).expect("Composition fractions must sum to 1.0").at_conditions(298.15,101325.0);// Pure component streamslet pure_water = Stream::<MolarFlow,_>::pure(100.0,"Water".to_string(),298.15,// Temperature required101325.0// Pressure required);// Access stream propertiesprintln!("Total flow: {} mol/s", stream.total_flow);println!("Temperature: {} K", stream.temperature);println!("Pressure: {} Pa", stream.pressure);Balance equations are typed by conservation law:
use nomata::{Equation,MassBalance,EnergyBalance,Var,Differential};// Create differential variableslet holdup:Var<Differential> = Var::new(100.0);let energy:Var<Differential> = Var::new(5000.0);// Create typed balance equationslet mass_balance:Equation<MassBalance> = Equation::new("reactor_mass");let energy_balance:Equation<EnergyBalance> = Equation::new("reactor_energy");// Set residuals (computed from balance terms)
mass_balance.set_residual(0.0);// Check if satisfiedif mass_balance.is_satisfied(1e-6){println!("Mass balance satisfied!");}Build type-safe residual functions for DAE systems using EquationVars:
use nomata::{ResidualFunction,EquationVars};use std::collections::HashMap;// Define typed variables for a mass balance equationstructMassBalanceVars{accumulation:f64,flow_in:f64,flow_out:f64,}implEquationVarsforMassBalanceVars{fnbase_names() -> &'static[&'staticstr]{&["accumulation","flow_in","flow_out"]}fnfrom_map(vars:&HashMap<String,f64>,prefix:&str) -> Option<Self>{Some(Self{accumulation:*vars.get(&format!("{}_accumulation", prefix))?,flow_in:*vars.get(&format!("{}_flow_in", prefix))?,flow_out:*vars.get(&format!("{}_flow_out", prefix))?,})}}// Create type-safe residual: dN/dt = F_in - F_outlet residual = ResidualFunction::from_typed("mass_balance","reactor",
|v:MassBalanceVars| v.accumulation - v.flow_in + v.flow_out,);// For dynamic variable counts (e.g., N-inlet mixers), use from_dynamic:let var_names = vec!["inlet_0_F".into(),"inlet_1_F".into(),"outlet_F".into()];let mixer_balance = ResidualFunction::from_dynamic("mixer_mass_balance",
var_names,
|vars, names| {let outlet = vars.get(&names[names.len()-1]).copied().unwrap_or(0.0);let inlet_sum:f64 = names[..names.len()-1].iter().filter_map(|n| vars.get(n)).sum();
outlet - inlet_sum
},);With the autodiff feature enabled, all unit operation models support automatic differentiation for efficient Jacobian computation:
use nomata::{Scalar,EquationVarsGeneric};use std::collections::HashMap;// Define equation variables generic over scalar typepubstructArrheniusVars<S:Scalar>{pubk:S,pubk0:S,pubea:S,pubt:S,}impl<S:Scalar>EquationVarsGeneric<S>forArrheniusVars<S>{fnbase_names() -> &'static[&'staticstr]{&["k","k0","Ea","T"]}fnfrom_map(vars:&HashMap<String,S>,prefix:&str) -> Option<Self>{Some(Self{k: vars.get(&format!("{}_k", prefix))?.clone(),k0: vars.get(&format!("{}_k0", prefix))?.clone(),ea: vars.get(&format!("{}_Ea", prefix))?.clone(),t: vars.get(&format!("{}_T", prefix))?.clone(),})}}// The Scalar trait provides exp, ln, sqrt, powf, abs, sin, cos// that work with both f64 and Dual64 for automatic differentiationThe Scalar trait enables equations like the Arrhenius equation (k = k0 * exp(-Ea/RT)) to be automatically differentiated, providing exact Jacobians for Newton-based solvers.
Represent full differential-algebraic equation systems:
use nomata::{EquationSystem,Dynamic};letmut system:EquationSystem<Dynamic> = EquationSystem::new();// Add differential equations (balance equations)
system.add_differential(mass_balance_residual);
system.add_differential(energy_balance_residual);// Add algebraic equations (constitutive relations)
system.add_algebraic(concentration_definition);
system.add_algebraic(temperature_definition);println!("Total equations: {}", system.total_equations());println!("Differential: {}", system.differential_count());println!("Algebraic: {}", system.algebraic_count());Unit operations know their own physics and can automatically generate equations:
use nomata::{UnitOp,Flowsheet,Dynamic, models::CSTR,Stream,MolarFlow};// Generic simulation loopletmut flowsheet = Flowsheet::<Dynamic>::new();let reactor = CSTR::new(100.0,1.0,350.0);
flowsheet.add_unit("CSTR-101", reactor);
flowsheet.harvest_equations();// Automatic!// Equations are now ready for solvingassert_eq!(flowsheet.equations().total_equations(),5);Nomata provides a unified, type-safe connection system that enforces stream compatibility at both compile time and runtime.
Unit operations declare their ports using the PortSpec trait, which specifies port counts and stream types at compile time:
use nomata::{PortSpec,Stream,MolarFlow,MassFlow};// Single-port units (1 input, 1 output)implPortSpecforReactor{constINPUT_COUNT:usize = 1;constOUTPUT_COUNT:usize = 1;typeSTREAM_TYPE = MolarFlow;}// Multi-port units (N inputs, 1 output)implPortSpecforMixer{constINPUT_COUNT:usize = 3;// Can mix 3 inlet streamsconstOUTPUT_COUNT:usize = 1;typeSTREAM_TYPE = MassFlow;}The HasPorts trait provides runtime access to port information:
use nomata::HasPorts;let reactor = Reactor::new(/* ... */);println!("Reactor has {} inputs, {} outputs",
reactor.input_ports().len(),
reactor.output_ports().len());The unified connect() function performs compile-time bounds checking and runtime stream type validation:
use nomata::connect;// Compile-time: Checks that reactor has exactly 1 output and separator has exactly 1 input// Runtime: Validates that both units use compatible stream typesconnect(&reactor,&separator)?;// Returns Result<(), ConnectionError>// Multi-port connections use port indicesconnect_at(&mixer,0,&reactor,0)?;// Connect mixer's output 0 to reactor's input 0connect_at(&splitter,0,&separator,1)?;// Connect splitter's output 0 to separator's input 1Invalid connections are caught at compile time:
// ERROR: Reactor has only 1 output, cannot connect output index 2connect_at(&reactor,2,&separator,0);The connection system ensures that:
- Port bounds are checked at compile time - Invalid port indices won't compile
- Stream types are validated at runtime - Incompatible streams return a
ConnectionError - Zero runtime overhead - All validation logic is optimized away when successful
Install Rust toolchain using rustup.
Clone repository:
git clone https://github.com/GermanHeim/nomata.git cd nomataBuild the project:
cargo build --release
Distributed under the MIT License. See LICENSE.txt for more information.
