Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

68 Commits

Repository files navigation

Nomata logo

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.

Key Features

  • 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

Installation

Add to your Cargo.toml:

[dependencies]
nomata = "0.1.0"

Optional Features

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 using num-dual
  • solvers: Numerical solvers for DAE systems using differential-equations
  • thermodynamics: CoolProp bindings for thermodynamic properties using rfluids
  • all: Enable all features

Core Concepts

Variable Roles

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

Shared State Architecture

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!

Time Domains

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();

Streams

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

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!");}

Residual Functions

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
},);

Automatic Differentiation

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 differentiation

The Scalar trait enables equations like the Arrhenius equation (k = k0 * exp(-Ea/RT)) to be automatically differentiated, providing exact Jacobians for Newton-based solvers.

Complete DAE Systems

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());

Equation Harvesting

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);

Connections Are Type-Checked

Nomata provides a unified, type-safe connection system that enforces stream compatibility at both compile time and runtime.

Port Specification

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;}

Runtime Port Discovery

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());

Type-Safe Connections

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 1

Invalid 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

Building from Source

  1. Install Rust toolchain using rustup.

  2. Clone repository:

    git clone https://github.com/GermanHeim/nomata.git
    cd nomata
  3. Build the project:

    cargo build --release

License

Distributed under the MIT License. See LICENSE.txt for more information.

About

A correct-by-construction process modeling framework that leverages Rust's type system to enforce structural correctness at compile time

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages