-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
🇬🇧 English | 🇫🇷 Français
The oxiflow development model enforces a strict three-pole separation:
graph TD
PM["PhysicalModel\n(computes)"]
SV["Solver\n(orchestrates)"]
CC["ContextCalculator\n(executes)"]
SC["Scenario\n— What —"]
CF["SolverConfiguration\n— How —"]
SR["SimulationResult"]
PM -->|contains| SC
SV -->|receives| SC
SV -->|produces| SR
SV -->|receives| CF
CC -->|contains| CF
| Pole | Type | Responsibility |
|---|---|---|
| What | Scenario |
Problem declaration: model, mesh, initial and boundary conditions, domains |
| How | SolverConfiguration |
Configuration: integrator, time step, calculators |
| Execution | Solver |
Orchestrates the time integration loop |
The architecture rests on three core components that carry the computation, connected by two linking elements that describe the problem and its resolution.
graph TD
subgraph "Core components"
PM["PhysicalModel\ndeclares requirements\ncomputes ∂u/∂t"]
SOL["Solver\norchestrates the time loop"]
CALC["ContextCalculator\nprovides a ContextValue\nto the solver"]
end
subgraph "Linking element — WHAT"
SCEN["Scenario\nVec<Domain>\naggregates · validates"]
end
subgraph "Linking element — HOW"
CFG["SolverConfiguration\nTimeConfiguration · integrator\ncalculators"]
end
PM -->|"RequiresContext\nrequired_variables()"| SCEN
CALC -->|"Vec<Box<dyn ContextCalculator>>"| CFG
SOL -->|"solve()"| SCEN
SOL -->|"solve()"| CFG
PhysicalModel — The computational core.
It declares the variables it needs (trait RequiresContext) and computes the time derivative compute_physics(). It is the concrete implementation of a physical domain (chromatography, heat transfer, etc.).
ContextCalculator — The context provider.
It produces a ContextValue (scalar, vector, nodal field…) from the current simulation state. Calculators are chained by the solver in a determined execution order: each calculator may depend on the result of another. See Calculator chain ordering below.
Solver — The orchestrator.
It receives a Scenario and a SolverConfiguration, runs the time loop, and produces a SimulationResult. It has no knowledge of the physics: it only knows in what order to call the components.
Scenario — What to solve.
Aggregates one or more Domain (model, mesh, boundary conditions). It validates the consistency of the problem before solving and exposes the context requirements across all domains.
SolverConfiguration — How to solve it.
Groups the time configuration (TimeConfiguration), the choice of integrator (IntegratorKind), and the list of ContextCalculator instances provided by the user. It is the control panel that the solver consults to orchestrate each time step.
Scenario is designed for multi-domain from the very first milestone. The single-domain case (Core Architecture) is the degenerate case: a single Domain in the vector. Domain coupling [INV-3] is introduced from v0.3.0 onwards.
Scenario
├── domains: Vec<Domain> ← 1 domain at v0.1.0, N domains from v0.3.0
│ └── Domain
│ ├── id: DomainId ← unique identifier (Hash + Eq)
│ ├── model: Box<dyn PhysicalModel>
│ ├── mesh: Box<dyn Mesh>
│ └── boundary_conditions Vec<Box<dyn BoundaryCondition>> (v0.2.0)
├── couplings: Vec<CouplingOp> (reserved v0.3.0, INV-3)
├── interfaces: Vec<Interface> (reserved v0.3.0)
└── t_start: f64
In multi-domain mode, domains are not independent: they exchange information at interfaces. The chaining follows a contractual protocol:
sequenceDiagram
participant S as Solver
participant D1 as Domain 1
participant D2 as Domain 2
participant COP as CouplingOperator
loop each step dt
S->>D1: context_calculators → compute_physics
S->>D2: context_calculators → compute_physics
S->>COP: exchange(interface, D1.state, D2.state)
note over COP: flux · continuity · constraint
S->>D1: apply coupling flux
S->>D2: apply coupling flux
S->>D1: integrate → u_next
S->>D2: integrate → u_next
end
Requirement aggregation. Scenario::context_requirements() walks all domains, collects required_variables() from each physical model (and, from v0.2.0, from each boundary condition), and deduplicates. The solver verifies that the calculators supplied in SolverConfiguration cover all requirements before starting the loop.
Validation. Scenario::validate() detects inconsistencies before execution: incompatible meshes, duplicate domain identifiers, unmet context requirements. An explicit OxiflowError is returned to the caller rather than silently incorrect results.
Ergonomic helpers.
Unifying single- and multi-domain scenarios into one type avoids the need for a separate MultiDomainScenario struct and eliminates a breaking API change at v0.3.0. The initial implementation cost is marginally higher, but pays off across subsequent milestones. Helper functions cover common single-domain usage without verbosity:
// v0.1.0 — single domain, t_start = 0
Scenario::single(model, mesh)
// v0.1.0 — with a custom start time
Scenario::single_from(model, mesh, t_start)
// v0.2.0 — add boundary conditions (via Domain builder)
Domain::new(id, model, mesh).with_boundary_conditions(bcs)
// v0.3.0 — add a coupling operator
scenario.with_coupling(op)SolverConfiguration is the interface between the user and the resolution algorithm. It answers three questions: how far to simulate, how to integrate, with what context.
pub struct SolverConfiguration {
pub time: TimeConfiguration, // how far and at what step
pub integrator: IntegratorKind, // Euler, RK4, DoPri45 (v0.4.0)…
pub calculators: Vec<Box<dyn ContextCalculator>>, // user-supplied context
}
pub struct TimeConfiguration {
pub t_end: f64,
pub step_control: StepControl, // Fixed { dt } at v0.1.0, Adaptive at v0.4.0
pub save_every: Option<usize>, // output sub-sampling
}No exposed dx/nx. Spatial discretisation details (grid spacing, node count) remain internal to ContextCalculator implementations and the Mesh. No public API of SolverConfiguration exposes these quantities [INV-1].
At each call to build_calculator_chain, the execution order of calculators is determined by a hybrid two-path algorithm (DD-009):
graph TD
A["build_calculator_chain()"] --> B{"any depends_on()\nnon-empty?"}
B -->|No| C["Priority path\nstable sort by priority()\nO(n log n)"]
B -->|Yes| D["Kahn path\ntopological sort\n+ priority tiebreaker"]
D --> E{"cycle\ndetected?"}
E -->|Yes| F["OxiflowError::\nCircularDependency"]
E -->|No| G["ordered chain"]
C --> G
When no calculator declares a non-empty depends_on(), calculators are sorted by ascending priority() (stable sort). This is the default path — no graph is built, no allocation beyond the sort. A total order by priority cannot contain a cycle.
Recommended priority ranges:
| Range | Purpose |
|---|---|
| 0–49 | System variables (Time, TimeStep) — injected directly by the solver |
| 50–99 | External data providers (experimental conditions, physical constants) |
| 100+ | Derived quantities (default) |
When at least one calculator declares a non-empty depends_on(), Kahn's algorithm runs on the full dependency graph:
- For each calculator C declaring
depends_on: [X], an edge is drawn from every calculator providing X to C. Multiple providers of X produce multiple edges — all must precede C. - Built-in variables (
Time,TimeStep) declared independs_on()are ignored when building the graph: they are always available inComputeContextbefore the chain runs. - Within each topological tier (nodes whose predecessors are all resolved), calculators are ordered by ascending
priority(). - A cycle returns
OxiflowError::CircularDependency(ContextVariable)naming the variable involved.
graph LR
TC["TimeCalculator\npriority: 0"]
GC["GradientCalculator\npriority: 100\ndepends_on: []"]
FC["FluxCalculator\npriority: 150\ndepends_on: [Gradient]"]
PM["PhysicalModel\nreads context"]
TC -->|"Time → Scalar(t)"| CTX["ComputeContext"]
GC -->|"Gradient → VectorField(∇u)"| CTX
FC -->|"Flux → VectorField(F)"| CTX
GC --> FC
CTX -->|"compute_physics(u, ctx)"| PM
The graph construction is O(n² · d), where n is the number of calculators and d the average number of declared dependencies. The sort-based tiebreaker within each tier adds O(n² log n) in the worst case (fully sequential graph).
These complexities are acceptable in practice because the number of calculators in a simulation engine is structurally bounded. A physically rich scenario typically registers 20 to 30 calculators: spatial gradients, fluxes, temperatures, viscosities, source terms, adsorption isotherms. Reaching 100 calculators would be an architectural warning sign — it signals that concerns are not properly separated or that quantities that should be computed inside PhysicalModel are leaking into the calculator chain.
The working limit is therefore n ≤ 100 calculators. Beyond this threshold, the algorithm remains correct but its quadratic cost becomes measurable. If this limit is ever approached, two targeted improvements suffice without any interface change: replacing the sort-based queue with a BinaryHeap (O(log n) per operation) and pre-indexing provides → Vec<usize> to make graph construction O(n · d).
At each time step, the following order is mandatory. Deviating from it produces silently incorrect results:
sequenceDiagram
participant S as Solver
participant CTX as ComputeContext
participant BC as Boundary conditions (v0.2.0)
participant M as PhysicalModel
participant I as Integrator
loop each step dt
S->>CTX: calculators → topological order
S->>BC: apply(u, ctx)
S->>M: compute_physics(u, ctx) → du/dt
S->>I: integrate(du/dt, dt) → u_next
end
src/mesh/
├── mod.rs ← Mesh trait + public re-exports
├── structured/
│ └── mod.rs ← UniformGrid1D (v0.1.0)
└── unstructured/ ← RESERVED v2.0.0 (FEM)
No public API exposes dx, nx, or raw indices [INV-1]. All spatial references go through the Mesh trait
(Mesh abstraction interface,
mesh module organisation).
| Axis | Variant | Active |
|---|---|---|
| Pointwise rank 0 | Scalar(f64) |
Core architecture |
| Boolean flag | Boolean(bool) |
Core architecture |
| Pointwise rank 1 | Vector(DVector) |
Core architecture |
| Pointwise rank 2 tensor | Matrix(DMatrix) |
Core architecture |
| Nodal scalar field | ScalarField(DVector) |
Core architecture |
| Nodal vector field | VectorField(DMatrix) |
Core architecture |
| Rank-4 tensor | Tensor4 |
Finite Element Methods |
| Tensor field | TensorField |
Finite Element Methods |
Tensors of rank higher than 4 are outside the project scope. They may be contributed by third-party execution frameworks.
Covariant transformation semantics (D'_ij = J_ik · D_kl · J_jl^T) belong to DiscreteOperator [INV-2], not to the data container.
| INV | Guarantee | Active from |
|---|---|---|
| INV-1 | No dx/nx in the public API |
v0.1.0 |
| INV-2 | Integrators decoupled from the spatial scheme | v0.5.0 |
| INV-3 | Cross-domain coupling only via CouplingOperator
|
v0.3.0 |
| INV-4 | All public traits are object-safe | v2.0.0 |
See GitHub issues — type: decision
🇬🇧 English | 🇫🇷 Français
Le modèle de développement d'oxiflow et son architecture impose une séparation stricte en trois pôles :
graph TD
PM["PhysicalModel\n(calcule)"]
SV["Solver\n(orchestre)"]
CC["ContextCalculator\n(exécute)"]
SC["Scenario\n— Quoi —"]
CF["SolverConfiguration\n— Comment —"]
SR["SimulationResult"]
PM -->|contient| SC
SV -->|reçoit| SC
SV -->|produit| SR
SV -->|reçoit| CF
CC -->|contient| CF
| Pôle | Type | Responsabilité |
|---|---|---|
| Quoi | Scenario |
Déclaration du problème : modèle, maillage, conditions initiales et aux limites, domaines |
| Comment | SolverConfiguration |
Configuration : intégrateur, pas de temps, calculateurs |
| Exécution | Solver |
Orchestre la boucle temporelle |
L'architecture repose sur trois composants fondamentaux qui portent le calcul, reliés par deux éléments de liaison qui décrivent le problème et sa résolution.
graph TD
subgraph "Composants fondamentaux"
PM["PhysicalModel\ndéclare les besoins\ncalcule ∂u/∂t"]
SOL["Solver\norchestre la boucle temporelle"]
CALC["ContextCalculator\nfournit une ContextValue\nau solveur"]
end
subgraph "Élément de liaison — WHAT"
SCEN["Scenario\nVec<Domain>\nagrège · valide"]
end
subgraph "Élément de liaison — HOW"
CFG["SolverConfiguration\nTimeConfiguration · intégrateur\ncalculateurs"]
end
PM -->|"RequiresContext\nrequired_variables()"| SCEN
CALC -->|"Vec<Box<dyn ContextCalculator>>"| CFG
SOL -->|"solve()"| SCEN
SOL -->|"solve()"| CFG
PhysicalModel — Le cœur du calcul.
Il déclare les variables dont il a besoin (trait RequiresContext) et calcule la dérivée temporelle compute_physics(). C'est l'implémentation concrète d'un domaine physique (chromatographie, transfert thermique, etc.).
ContextCalculator — Le fournisseur de contexte.
Il produit une ContextValue (scalaire, vecteur, champ nodal…) à partir de l'état courant de la simulation. Les calculateurs sont chaînés par le solveur dans un ordre d'exécution déterminé : chaque calculateur peut dépendre du résultat d'un autre. Voir Ordonnancement de la chaîne de calculateurs ci-dessous.
Solver — L'orchestrateur.
Il reçoit un Scenario et une SolverConfiguration, exécute la boucle temporelle, et produit un SimulationResult. Il ne connaît pas la physique : il sait seulement dans quel ordre appeler les composants.
Scenario — Quoi résoudre.
Agrège un ou plusieurs Domain (modèle, maillage, conditions aux limites). Il valide la cohérence du problème avant la résolution et expose les besoins en contexte de l'ensemble des domaines.
SolverConfiguration — Comment le résoudre.
Regroupe la configuration temporelle (TimeConfiguration), le choix de l'intégrateur (IntegratorKind), et la liste des ContextCalculator fournis par l'utilisateur. C'est la table de contrôle que le solveur consulte pour orchestrer chaque pas de temps.
Scenario est conçu pour le multi-domaine dès le premier jalon. Le cas mono-domaine (Core Architecture) est le cas dégénéré : un seul Domain dans le vecteur. Le couplage inter-domaines [INV-3] apparaît à partir de la version v0.3.0.
Scenario
├── domains: Vec<Domain> ← 1 domaine à v0.1.0, N domaines à partir de v0.3.0
│ └── Domain
│ ├── id: DomainId ← identifiant unique (Hash + Eq)
│ ├── model: Box<dyn PhysicalModel>
│ ├── mesh: Box<dyn Mesh>
│ └── boundary_conditions Vec<Box<dyn BoundaryCondition>> (v0.2.0)
├── couplings: Vec<CouplingOp> (réservé v0.3.0, INV-3)
├── interfaces: Vec<Interface> (réservé v0.3.0)
└── t_start: f64
En mode multi-domaine, les domaines ne sont pas indépendants : ils échangent de l'information aux interfaces. L'enchaînement suit un protocole contractuel :
sequenceDiagram
participant S as Solver
participant D1 as Domain 1
participant D2 as Domain 2
participant COP as CouplingOperator
loop chaque pas dt
S->>D1: context_calculators → compute_physics
S->>D2: context_calculators → compute_physics
S->>COP: exchange(interface, D1.state, D2.state)
note over COP: flux · continuité · contrainte
S->>D1: apply coupling flux
S->>D2: apply coupling flux
S->>D1: integrate → u_next
S->>D2: integrate → u_next
end
Agrégation des besoins. Scenario::context_requirements() parcourt tous les domaines, collecte les required_variables() de chaque modèle physique (et, à v0.2.0, de chaque condition aux limites), et déduplique. Le solveur vérifie que les calculateurs fournis dans SolverConfiguration couvrent l'intégralité de ces besoins avant de lancer la boucle.
Validation. Scenario::validate() détecte les incohérences avant l'exécution : maillages incompatibles, identifiants de domaine en double, besoins en contexte non couverts. Un OxiflowError explicite est retourné à l'utilisateur plutôt qu'un résultat silencieusement incorrect.
Helpers ergonomiques.
Ce choix initial évite d'avoir à concevoir plusieurs structures (Scenario, MultiDomainScenario) ; le coût est légèrement plus important à la première implémentation, mais il est rentable pour la suite des développements. Des fonctions de type helper réduisent le coût des usages simples :
// v0.1.0 — domaine unique, t_start = 0
Scenario::single(model, mesh)
// v0.1.0 — avec temps de départ personnalisé
Scenario::single_from(model, mesh, t_start)
// v0.2.0 — ajout de conditions aux limites (via le builder Domain)
Domain::new(id, model, mesh).with_boundary_conditions(bcs)
// v0.3.0 — ajout d'un opérateur de couplage
scenario.with_coupling(op)SolverConfiguration est l'interface entre l'utilisateur et l'algorithme de résolution. Elle répond à trois questions : jusqu'où simuler, comment intégrer, avec quels contextes.
pub struct SolverConfiguration {
pub time: TimeConfiguration, // jusqu'où et à quel pas
pub integrator: IntegratorKind, // Euler, RK4, DoPri45 (v0.4.0)…
pub calculators: Vec<Box<dyn ContextCalculator>>, // contextes fournis
}
pub struct TimeConfiguration {
pub t_end: f64,
pub step_control: StepControl, // Fixed { dt } à v0.1.0, Adaptive à v0.4.0
pub save_every: Option<usize>, // sous-échantillonnage des sorties
}Intégration sans dx/nx exposés. Les détails de discrétisation spatiale (pas de grille, nombre de nœuds) restent à l'intérieur des ContextCalculator et du Mesh. Aucune API publique de SolverConfiguration n'expose ces grandeurs [INV-1].
À chaque appel à build_calculator_chain, l'ordre d'exécution des calculateurs est déterminé par un algorithme hybride à deux chemins (DD-009) :
graph TD
A["build_calculator_chain()"] --> B{"un depends_on()\nnon vide ?"}
B -->|Non| C["Chemin priorité\ntri stable par priority()\nO(n log n)"]
B -->|Oui| D["Chemin Kahn\ntri topologique\n+ tiebreaker priorité"]
D --> E{"cycle\ndétecté ?"}
E -->|Oui| F["OxiflowError::\nCircularDependency"]
E -->|Non| G["chaîne ordonnée"]
C --> G
Lorsqu'aucun calculateur ne déclare un depends_on() non vide, les calculateurs sont triés par priority() croissant (tri stable). C'est le chemin par défaut — aucun graphe n'est construit. Un ordre total par priorité ne peut pas contenir de cycle.
Plages de priorité recommandées :
| Plage | Usage |
|---|---|
| 0–49 | Variables système (Time, TimeStep) — injectées directement par le solveur |
| 50–99 | Fournisseurs de données externes (conditions expérimentales, constantes physiques) |
| 100+ | Quantités dérivées (valeur par défaut) |
Dès qu'un calculateur déclare un depends_on() non vide, l'algorithme de Kahn s'applique sur le graphe de dépendances complet :
- Pour chaque calculateur C déclarant
depends_on: [X], une arête est tracée de chaque calculateur fournissant X vers C. Plusieurs fournisseurs de X produisent plusieurs arêtes — tous doivent précéder C. - Les variables built-in (
Time,TimeStep) déclarées dansdepends_on()sont ignorées lors de la construction du graphe : elles sont toujours disponibles dansComputeContextavant l'exécution de la chaîne. - Au sein de chaque tier topologique (nœuds dont tous les prédécesseurs sont résolus), les calculateurs sont triés par
priority()croissant. - Un cycle retourne
OxiflowError::CircularDependency(ContextVariable)en nommant la variable impliquée.
graph LR
TC["TimeCalculator\npriorité : 0"]
GC["GradientCalculator\npriorité : 100\ndepends_on : []"]
FC["FluxCalculator\npriorité : 150\ndepends_on : [Gradient]"]
PM["PhysicalModel\nlit le contexte"]
TC -->|"Time → Scalar(t)"| CTX["ComputeContext"]
GC -->|"Gradient → VectorField(∇u)"| CTX
FC -->|"Flux → VectorField(F)"| CTX
GC --> FC
CTX -->|"compute_physics(u, ctx)"| PM
La construction du graphe est en O(n² · d), où n est le nombre de calculateurs et d le nombre moyen de dépendances déclarées. Le tri à l'intérieur de chaque tier ajoute O(n² log n) dans le pire cas (graphe entièrement séquentiel).
Ces complexités sont acceptables en pratique car le nombre de calculateurs dans un moteur de simulation est structurellement borné. Un scénario physiquement riche en enregistre typiquement 20 à 30 : gradients spatiaux, flux, températures, viscosités, termes sources, isothermes d'adsorption. Atteindre 100 calculateurs constituerait un signal d'alerte architectural — cela indiquerait que les responsabilités ne sont pas correctement séparées, ou que des quantités qui devraient être calculées dans PhysicalModel fuient dans la chaîne de calculateurs.
La limite de bon fonctionnement est donc n ≤ 100 calculateurs. Au-delà, l'algorithme reste correct mais son coût quadratique devient mesurable. Si cette limite devait être approchée, deux améliorations ciblées suffiraient sans modifier l'interface : remplacer la file triée par un BinaryHeap (O(log n) par opération) et pré-indexer provides → Vec<usize> pour rendre la construction du graphe en O(n · d).
À chaque pas de temps, l'ordre suivant est imposé. Le déroger produit des résultats silencieusement incorrects :
sequenceDiagram
participant S as Solver
participant CTX as ComputeContext
participant BC as Conditions aux limites (v0.2.0)
participant M as PhysicalModel
participant I as Intégrateur
loop chaque pas dt
S->>CTX: calculateurs → ordre topologique
S->>BC: apply(u, ctx)
S->>M: compute_physics(u, ctx) → du/dt
S->>I: integrate(du/dt, dt) → u_next
end
src/mesh/
├── mod.rs ← trait Mesh + re-exports publics
├── structured/
│ └── mod.rs ← UniformGrid1D (v0.1.0)
└── unstructured/ ← RÉSERVÉ v2.0.0 (FEM)
Aucune API publique n'expose dx, nx ou des indices bruts [INV-1]. Toute référence spatiale passe par le trait Mesh (Interface d'abstraction du maillage, organisation du module de maillage).
| Axe | Variant | Actif |
|---|---|---|
| Objet ponctuel rang 0 | Scalar(f64) |
Core architecture |
| Flag logique | Boolean(bool) |
Core architecture |
| Objet ponctuel rang 1 | Vector(DVector) |
Core architecture |
| Tenseur ponctuel rang 2 | Matrix(DMatrix) |
Core architecture |
| Champ nodal scalaire | ScalarField(DVector) |
Core architecture |
| Champ nodal vectoriel | VectorField(DMatrix) |
Core architecture |
| Tenseur rang 4 | Tensor4 |
Finite Element Methods |
| Champ de tenseurs | TensorField |
Finite Element Methods |
Les tenseurs de rang supérieur à 4 ne sont pas dans le périmètre des travaux. Ils peuvent faire l'objet d'un apport en tierce partie dans des cadres d'exécution dédiés.
La sémantique de transformation covariante (D'_ij = J_ik · D_kl · J_jl^T) appartient à DiscreteOperator [INV-2], pas au conteneur de données.
| INV | Garantie | Actif depuis |
|---|---|---|
| INV-1 | Pas de dx/nx dans l'API publique |
v0.1.0 |
| INV-2 | Intégrateurs découplés du schéma spatial | v0.5.0 |
| INV-3 | Couplage inter-domaines uniquement via CouplingOperator
|
v0.3.0 |
| INV-4 | Tous les traits publics sont object-safe | v2.0.0 |