Changelog¶
Changelog¶
All notable changes to this project are documented here. Versions follow
semantic versioning; the version in pyproject.toml is the single
source of truth and is bumped automatically when shipped source changes.
Unreleased¶
- A parameter with no binding takes its
startvalue. The specification lets a parameter stand on itsstartattribute (with a tool warning) when nothing binds it, and the machines rely on it: a synchronous machine declaresparameter Boolean useDamperCage(start = true)with no binding and gates its damper-cage heat port on it, sostartis the value the condition must read. An unbound parameter now evaluates itsstartexpression against the known values and records the result, so a conditional component keyed on such a parameter resolves. - The elementwise operators
.*,./,.^(and.+,.-). Modelica's dotted operators are elementwise between two arrays:x .* yis the elementwise product*is not (a*between two arrays is a contraction), and./and.^are the elementwise divide and power that plain/and^refuse. The parser now accepts them (they were tokenised but rejected);.+/.-fold to+/-, which are already elementwise for arrays, and.*/.//.^keep their own operator that the flattener reads out elementwise -- each element is its plain scalar sibling, so causalization, the Jacobian and every emitter (the Rust backend included) see ordinary*///^and need no new rule. A plain/or^between two arrays now names its dotted form in the refusal. transpose(A)swaps a matrix's two indices.transpose(A)[i, j]isA[j, i], whether it is written whole (B = transpose(A)) or read through a product (transpose(A) * x, the linear form a state-space model and a quadratic form use). It is expanded where an array equation scalarises, so causalization, the Jacobian and every emitter (the Rust backend included) see the swapped element; a non-squareA[m, n]transposes toA[n, m].+and-between two arrays are elementwise.x + yandx - yfor two same-shaped arrays are the elementwise sum and difference, asder(x) = p - x(an array settling to a constant array) needs. The shape pass had refused any non-*operator between two arrays outright, so this had to be written as aforloop, even though the element pass already read it correctly; now only/and^(which Modelica spells./and.^) are refused between two arrays.*stays the contraction it was.- Array constructors work in an equation, not only a declaration.
der(x) = A * x + ones(n),der(x) = fill(2, n) - x: a constructor written in an equation is expanded to its literal array the same way a declaration's is, reading its shape off its own size arguments (structural, known at flatten). This completes the constructors added just above, which were scoped to declarations. - The array-constructor functions in a declaration:
zeros,ones,fill,identity,linspace.Real x[n](start = zeros(n)),parameter Real A[n, n] = identity(n),parameter Real g[n] = linspace(0, 1, n)-- the ordinary way to give an array declaration its start or its value. Each is array-valued and structural, and is now expanded at flatten into the literal array of the declared shape, exactly as a written{...}would be; before, the whole call reached the emitter as an untranslated function and the model would not build.zeros/ones/fillcover any shape (a vector or a matrix);identity(n)is the square unit matrix (a non-square shape is refused);linspace(a, b, n)steps evenly with the endpoints kept exact. The fold happens where an array declaration already scalarises, so causalization, the Jacobian and every emitter (the Rust backend included) see the plain per-element values. examples/MixingRing: a closed ring of stirred tanks, a conservation law written with array reductions. N tanks are pumped one way around a loop, each fed from its predecessor and draining to its successor at the same rate,der(c[i]) = k * (c[i-1] - c[i])with the predecessor wrapping around. Every row of the transport matrix sums to zero, so the totalsum(c)is conserved for all time; the model holds that total as an algebraic variable and it stays at its starting value. A tracer injected in one tank circulates around the loop as it disperses (the transport matrix has a complex-conjugate eigenvalue pair) and settles to the common mean. It is the first corpus model to put the array reductions on a physical law:sum(c)for the conserved total, andmin(c)in an assert that guards the model's positivity. Linear, so its reference points are exact (scipy DOP853 at 1e-12), and both backends hold the total to 1e-9.- Array reductions work in a
whencondition and an assert, not only an equation. A reduction is folded as an equation is scalarised, but awhencondition and an assert never pass through that split, sowhen sum(x) < 1reached the emitter as an untranslatedsumand failed, andassert(sum(x) < top, ...)was quietly skipped (its condition named the arrayx, which the run does not report as a variable, only its elementsx[1],x[2]). Now the fold runs in the flattener's expression rewrite, so a reduction in either context becomes the arithmetic it stands for: thewhenis an ordinary crossing ofx[1] + x[2], and the assert guards the real total and stops the run when it is violated. - Array reductions fold in a parameter binding and an attribute too.
parameter Real total = sum(x0),Real y(start = sum(x0))-- a leaf is not an equation, so it never passes through the equation split; the fold now runs where the leaf is built, once the array it reduces is declared and its shape is known.sum,minandproducteach reduce their array parameter to the scalar the binding stands for, in both backends. (An array declared after the leaf that reduces it is the one case left: its shape is not yet known there, so that reduction still needs an equation.) - Array reductions fold to a scalar:
sum,product,min,maxover a whole array.total = sum(x)for a vectorxisx[1] + x[2] + ... + x[n],product(x)the same with*, andmin(x)/max(x)fold through the pairwisemin(a, b)/max(a, b). Before this the scalariser treated a reduction as elementwise and mangledsum(x)intosum(x[1]), one copy per equation element, so a conservation sum or an extremum over an array could not be written. The fold happens at flatten, so causalization, the Jacobian and every emitter (the Rust backend included) see ordinary arithmetic and need no reduction rule of their own; a two-argumentmin(a, b)stays the pairwise operator it was. A 2-D array folds every element. This completes the array arc that began with whole-array equations and array products. - An inconsistent algebraic loop is named, not a
ZeroDivisionError.a = b + 1andb = a + 1saya - b = 1anda - b = -1at once: no solution. Alias elimination reduces this to a single equation whose unknown has a zero coefficient, and the emitted code used to divide by that literal zero, so a real model with a contradictory pair of equations failed at runtime with a bareZeroDivisionErrorand no clue which equations were at fault. Causalization now checks the pivot before it emits: a coefficient that folds to zero is refused with the model name, the offending equation as Modelica source, and the words "singular algebraic loop". A consistent loop (a = 2*b + x,b = a - 1) still solves. examples/WaveString: the wave equation on a string with fixed ends, the second PDE in the corpus. A string of length 1 held at both ends, discretised in space by the method of lines -- eight interior points, each obeyingu'' = c^2 * (second difference of its neighbours), the same recipeHeatBarandTorusHeatuse but for a hyperbolic equation, so the string oscillates instead of decaying. The initial shape is the fundamental modesin(pi*x), an exact eigenvector of the discrete Laplacian, so the string swings at one frequency: a standing wave with the exact solutionu_i(t) = sin(pi*i/9) cos(omega t),omega = 18 sin(pi/18). Linear and undamped, it has that closed form (the reference points are analytic) and a conserved energy, the wave analogue of the Hamiltonian benchmarks. At sixteen states with a tridiagonal Jacobian it is the second model on the sparse-Jacobian path, alongside the 2-DTorusHeat.examples/CoupledOscillators: two masses coupled by a spring, written as a state-space systemder(z) = A * z. The first corpus model in the matrix-ODE form the array-product feature added: the whole right-hand side is one matrix-vector multiply, the companion matrixAcarrying the springs. Started with one mass pulled aside, the motion is a beat -- the in-phase mode at frequency 1 and the out-of-phase mode atsqrt(2)interfere and pass the energy back and forth,x1 = (cos(t) + cos(sqrt(2) t))/2. Linear and undamped, so it has a closed-form solution (the reference points are analytic) and a conserved energy, a benchmark with an exact reference like the Hamiltonian ones. ThebuildDSL'sparameter/constantgained ashape=argument to declare the array parameter its twin needs.- Array products scalarise: the dot product, matrix-vector, and matrix-matrix. A
*between two arrays is a contraction, not an elementwise operation, and is now expanded into the sums it stands for:der(x) = A * x(the linear system the feature is named for) becomesder(x[i]) = sum_j A[i, j] * x[j], a dot producta * xbecomes a scalar sum, and a matrix-matrixA * Bbecomes the matrix product. The last dimension of the left runs against the first of the right and both drop out, so a non-conforming product is refused by name. Array/and^still have no elementwise reading and are refused with theforloop as the way to write them. This closes the last array-equation gap, leaving elementwise equations (already supported) and products handled together. examples/SphericalPendulum: a pendulum free to swing in 3D, the first genuine spherical-coordinate trajectory. A bob on a rigid rod traces a path on a sphere: in the angles (thetafrom the downward vertical,phiaround it) the motion is the Lagrangian one, with the Coriolis coupling that conserves the vertical angular momentum keeping the bob off the poles. Started attheta = 1rad with a pure azimuthal rate, it oscillates inthetabetween about 0.47 and 1.0 rad whilephiprecesses about 5.5 turns, a rosette wrapped on the sphere. Two quantities are conserved -- the energy and the vertical angular momentumsin(theta)^2 * w_phi-- and grade the integrator as in the Kepler, Henon-Heiles and Schwarzschild benchmarks; both hold to about 1e-12. The rigid rod keeps the radius constant, so the state reads directly as(r, theta, phi)for the new 3-D spherical view. Six scipy reference points at 1e-12, matched to 1e-10.homotopy(actual, simplified)is taken at its actual value. The standard operator that helps an initialisation converge by walking a path from a simplified model to the actual one is now supported: with no continuation run, its value is the actual (the first argument), which is what the expression is worth once initialisation is done. It evaluates, differentiates and emits (interpreter and Rust backend alike) as that first argument -- the opposite end fromnoEventandsmooth, which are transparent to their last -- and the simplified branch is discarded, including any relations in it, so they do not become events nothing reads. This was the last operator still refused.terminate(message)ends a run at the instant itswhenfires. The standard Modelica operator that stops the analysis when a model is defined only up to some condition -- a tank that empties, a decay past a threshold -- is now supported end to end, in the interpreter and the compiled Rust backend alike. It is a state event like any other: thewhencondition is a zero crossing located the ordinary way, and the only new thing is the action, "stop and report" instead ofreinit. The run keeps the trajectory up to the located instant and nothing past it, and records the message as the event's cause. Written aswhen cond then terminate("reason"); end when;.examples/BouncingBallRest: a bouncing ball that comes to rest, the well-posed companion to BouncingBall. BouncingBall accumulates infinitely many bounces at a finite time (Zeno) and then, past the event limit, falls through the floor. This one carries a discrete flag that sticks the ball to the floor once a rebound would be smaller thanv_min, so the whole run is defined: the height stays in[0, 1]and the ball is provably at rest for good after the last bounce, checked withafterinvariants. It shows the standard way to resolve Zeno -- a rest mode -- rather than a terminal time.- Array equations scalarise element by element. An equation written between whole arrays --
der(x) = -xforReal x[2],der(T) = -TforReal T[2, 2]-- is now expanded into one scalar equation per element, the same split the declaration already does for the variables, so a vector field no longer has to be written as an explicitforloop over its elements (TorusHeatstill uses the loop; it now has the shorter form beside it). An array-valuedstartis split the same way, soReal x[2](start = {1, 2})gives its elements two different starts. A scalar broadcasts across the array (der(x) = -a * x + 1,start = x0), and an elementwise function call (-sin(x)) is read out per element. What is not elementwise is refused rather than mis-expanded: an array product likex * y(a contraction, not a per-element operation) says so and names theforloop as the way to write it. examples/TorusHeat: the heat equation on a flat torus, a 2-D method-of-lines PDE. Diffusion over an 8x8 periodic grid (64 states), the space discretised by hand and the edges wrapped withmodso there is no boundary -- HeatBar's one-dimensional recipe on the simplest manifold. The initial condition is a single grid Fourier mode, an exact eigenvector of the discrete periodic Laplacian, so the field keeps its shape and decays in closed form,T(t) = T(0)*exp(lambda*t)withlambda = 4*a*(cos(2*pi/n) - 1). The reference is that exact decay, checked at three cells, and a node of the mode is pinned at zero to show the discretisation preserves the eigenmode. It exercises 2-D arrays, aninitial equationover the grid indices, and the sparse Jacobian.examples/SchwarzschildOrbit: a relativistic orbit, the reachable general-relativity rung. A test mass on an equatorial timelike geodesic of the Schwarzschild metric -- the Kepler problem with the relativistic-3*M*ell^2/r^4term that makes a bound orbit precess into a rosette rather than close. Started atr = 20Mwithell = 4M, it swings between perihelion8.2Mand aphelion20M, advancing about 2.78 rad of precession per radial cycle (Mercury's 43"/century, in the strong field). The specific energyE^2 = vr^2 + (1 - 2M/r)(1 + ell^2/r^2) = 0.936is conserved, held to 1e-6 (Radau drifts it by 1e-14), the same conserved-quantity check as Kepler and Henon-Heiles, now in GR. Six scipy reference points at 1e-12 over nearly four orbits.examples/HenonHeiles: the Henon-Heiles system, a second Hamiltonian benchmark. A star in a galactic potential0.5*(x^2 + y^2) + x^2*y - y^3/3, two degrees of freedom, run at energy 0.1125 (below the escape energy 1/6, a bounded regular orbit). Where Kepler is integrable and closed-form, this one is non-integrable; both carry the energy as the invariant that grades the integrator. Radau holds it to 1e-13 over fifty time units, and the orbit is regular enough that a 1e-11 run and a 1e-12 reference stay within 3e-6, so its six scipy points pin the trajectory point for point.examples/KeplerOrbit: the Kepler two-body problem, the canonical Hamiltonian benchmark. An eccentric orbit (e = 0.6, period 2*pi) under inverse-square gravity, run for three orbits. Its point is its two constants of motion, held as invariants rather than integrated: the energy0.5*(vx^2 + vy^2) - mu/r = -0.5and the angular momentumx*vy - y*vx = 0.8. Radau holds both to below 1e-12 over the three orbits; the invariants pin them at 1e-6, the margin a longer, drift-accumulating run would spend. Six scipy reference points at 1e-12.modelica.ir.causalize: a second derivative may be writtender(der(x)). The pass that turnsder(v)into the variableder_vinsisted its argument be a plain variable, so a model that wrote the second derivative out was refused, even though index reduction already namesder_der_xwhen it differentiates an equation twice. It now recurses into the argument: the innerder(x)becomesder_x, and this der is thender(der_x)->der_der_x. Soder(der(x)) = -xis a harmonic oscillator withxandder_xas its two states, to any depth, and a model need no longer introducev = der(x)by hand.modelica.ir.flatten,modelica.build: structural if-equations. Anif ... else ... end ifin an equation section now selects, when the model is flattened, the branch whose condition holds -- the way a conditional component'sifalready does -- and keeps only that branch's equations;elseifchains and anelsefall out. The condition has to be known at flatten time, a parameter or a constant; a condition on a continuous variable is refused, because switching which equations exist at run time is awhen, a different machine.build.if_equationwrites one from the Python DSL, and theProjectileexample uses it to choose the air-drag law from a Boolean parameter.modelica.ir.evaluate,modelica.ir.derive:smooth(order, expr)evaluates and differentiates. The operator was already transparent to the emitter but unknown to the interpreter and the differentiator, so a model that wrapped a term in it ran through compiled code yet was refused at the Jacobian with "no derivative rule for 'smooth'". Both tiers now see through it to its expression argument, the way they donoEvent; the continuity order is a hint to the solver and drops.examples: four cleanly-referenced prototypes.TwoTank(interacting tank levels with Torricelli coupling, the textbook process-control plant),HeatExchanger(two fluid streams trading heat through a wall with its own inertia, the first flow-through thermal model),CoulombOscillator(a spring-mass damped by dry friction viatanh, whose amplitude decays linearly not exponentially), andProjectile(a ball thrown up with quadratic air drag, the law chosen by a structural if-equation). Each carries scipy reference points at 1e-12 and bounded invariants, and between them they exercisetanh,atan2,sqrtand the if-equation in real trajectories.modelica.ir.flatten: causal signal connectors, soModelica.Blocksflattens. A connector writtenconnector RealInput = input Realis a scalar, not a record of fields: it aliases a predefined type and carries a causality but holds no members. It used to hit the refusal that only atypemay extend a predefined type. It now lays down as one leaf variable, the way atypealias does, and stays a connector, soconnect(out, in)equates the two -- an output driving an input, the whole point of a signal wire; two outputs on one wire are refused by name. The existing connection machinery needed no change. The bundled standard-library slice gainedModelica.Blocks(Sources.Constant,Math.Gain/Feedback,Continuous.Integrator/FirstOrder), wired by the realRealInput/RealOutput.modelica.sim.rustsim: the compiled run carries the events back in full.md_integrategained two output buffers it writes each located crossing's instant and indicator index into, threaded through the FFI entry point;rustsimreads the cause from the model by that index, soRun.event_listis the interpreter'sResult.events-- instant, index and cause. The bouncing ball's six bounces, the roaster's nine and a genset's load switch match the interpreter's events exactly (instants to a microsecond); a smooth model carries none.modelica.sim.rustsim: the Rust backend, packaged.rustsim.simulate(system, stop, ...)takes the same knobs assimulateand returns the sameResult, from a shared library: the model's unit and the radau runtime as one source behind a C entry point, compiled once withrustc --crate-type cdylib, kept under the source's content hash so an unchanged model is never compiled twice, and called throughctypes. A run the driver stops raisesSimulationErrorwith the driver's reason. Checked against the interpreter on a smooth model, an event model and one that switches state sets; the event tallies agree exactly.modelica.sim.rust_radau: the chattering guard. A relay with no hysteresis reaches its threshold and then crosses it at every step, each crossing carrying the run forward by the rearm nudge and nothing more; the Rust driver ran such a model to the horizon, or forever. It now keeps the interpreter's standstill counter, the same two numbers, and stops with the statusCHATTERINGwhere the interpreter raises, to within 1e-2 on the chattering model.docs/rust-backend.md: the Rust backend, today: a standing account of what exists (the dialect,rustgen, theradauport with its event loop, dispatcher and Newton), how it is checked (215 differential tests against the interpreter), how to use it now (emit, append the runtime, compile withrustc), and what is missing (the chattering guard, the packaging, the interpreter pivot). The issue page that planned it records where it stands.modelica.sim.emit: an event's reported condition names the alias the model wrote. Alias elimination foldsprobe = T_probe - 273.15into every read ofprobe, so the roaster'swhen probe >= T_dropreached the event list, the chattering report and the sweep's response menu aswhen T_probe + -273.15 >= T_drop. The reported text now writes an eliminated alias back by name wherever a subtree is exactly its value; a bare-name alias is left alone, since renaming every read of the other variable is a choice the model did not make. The indicators the integrator watches are untouched.modelica.sim.rust_radau: observed variables at every grid point.md_integratetakes a further output and fills it withobservein whichever state set the run is in, which is what a run that switches sets is compared on: Pendulum's coordinates across its four switches now agree with the interpreter at 1e-8, the last corpus trajectory the Rust backend could not check.modelica.sim.rust_radau: a located root lands past the crossing. The bisection returned the midpoint of its final bracket, a hair before the crossing, so a relation sharing the threshold with the one that fired (PhaseChange'sT > MsandT < Ms) was not judged stale and each re-crossed its rearm nudge: four events at one instant where the interpreter fires two. The far end of the bracket is returned now, and the counts agree, 12 and 12.examples/LotkaVolterra: prey and predators on a closed orbit, with the conserved quantitydelta x - gamma ln x + beta y - alpha ln yheld to 1e-7 over four periods as an invariant: exact at every instant, so its drift is the integrator's drift and nothing else.examples/Rossler: chaos from one nonlinear term, with a Lyapunov exponent thirteen times smaller than Lorenz's, so a reference point late in the run is still a fair test of the integrator rather than of the arithmetic. Both new models'.mofiles are generated from their Python twins, so the two front ends build one tree by construction.examples/TwoInertias: two bodies bolted to one shaft — index 3 again, and for a different reason than the pendulum. The constraintbig.phi = small.phiis between two states, so Pantelides differentiates it twice and the dummy-derivative method throws away one of each pair. What makes it worth a model is that the two candidate state sets are the same shape:bigandsmalldiffer only in a number, so nothing in the structure prefers either and the answer comes from a tie-break.candidatesis a new key in the.toml, ordered with the chosen set first, so that a change in the selection rule shows up as a diff in a spec file rather than as a silent change of behaviour. Every reference number is the closed formw(t) = 10 exp(-t/10), and the conserved angular impulseJ w + d phiis asserted flat at 25 over the whole run.modelica.sim.emit:observe_jacobian—d(observed)/dy, exact, by the same forward mode that writesjacobian. This is what a state estimator callsH, and until now every caller had to finite-difference it: one extraobserveper state, and truncation error injected straight into the Kalman gain. It reaches codejacobiandoes not — the aliases, which causalization eliminates before the solver sees them and which are exactly what a model is usually measured through (a probe temperature, a branch current, a lag). Namedobserve_jacobianrather thansensitivitydeliberately: sensitivity means the derivative with respect to a parameter in this field, that is the same transformation with a different seed, and it will want the name.modelica.ir.derive: a conditional whose two answers are the same is folded away, and that one line is what makes a schedule compilable. A chain of constant segments has derivative zero on every segment, so differentiating it produced0.0 if w[30] > 0.5 else 0.0 if w[31] > 0.5 else ...— nine branches, one answer — andsimplifyhad no fold for it. Every block Jacobian carried the chain, so it reachedrhs,jacobian,observe,indicatorsandtransitionalike. CompilingRoastingMachineunder Numba, measured before and after:jacobianwent from 15.6 s and 4.4 GB without finishing to 2.6 s and 0.21 GB,rhsfrom 5.6 s to 0.2 s, and the whole model from uncompilable to 3.9 s.tests/test_jit.pycovers it now, and the note there that said "a model whose behaviour is a long schedule againsttimeis effectively not compilable" is corrected: it was a missing fold, not a limit of Numba and not a fault of the model.modelica.diagram: a model is a graph, and now you can see it. Three pictures, one per stage:schematicreads the classes before flattening and shows components and connections;incidencereads aFlatModeland shows which equation touches which variable;blocksreads aSystemand shows the order the solver runs in. Each returns aGraph, which writes itself as SVG, Graphviz or Mermaid, and renders itself in a notebook. There is a CLI:python -m modelica.diagram <model.mo> <libraries...>. No new dependency — the SVG, including the layout, is drawn here.- The schematic resolves names with
modelica.ir.flatten's own machinery rather than a second copy, which is whyScope,Foundandeffectiveare public now. A test holds the two together: no box may name a component the flat model does not have. - The SVG names no colour. Every line and character is
currentColor, so one image reads correctly on a light page and a dark one — no second copy, no script, no CDN. - The layout starts from a circle, never from random positions, so a rebuild of the docs site produces no diff.
modelica.ir.flatten: connectors are compared by structure, not by class name.connect(source.y, error.u1)joining anoutputconnector to aninputone was rejected as "mixes connector types"; Modelica compares the variables the two carry, and an output joined to an input is the ordinary case. The opposite check arrived with it: two outputs on one wire is a real error, and the message says which two.modelica.ir.flatten: a modification on an inner component now gets the enclosing instance's path.winding(R = Rw)inside a component boundRto a variable that did not exist, and code generation died on "nothing computes 'Rw'". At the top level the prefix is empty and nothing moves, which is why no earlier example could catch it.modelica.build.ref: promoted from a private helper in one example. A parameter computed from other parameters, and a component modification that reads one, are both written in the class body, whereselfdoes not exist yet.modelica.ir.source:to_programandflattenmoved out ofmodelica.sim. They have no numerics in them, and reaching them throughmodelica.simpulled NumPy into a package whose promise is that reading a model's structure costs nothing.modelica.simre-exports both, so no caller changes.examples/:Blocks,RotationalandMachineslibraries, and two models.PIControl— a block diagram:input/outputconnectors, a directed signal, and a closed loop. It found both flattener issues above and one in the corpus harness, which read the documentedafterkey on an invariant and then ignored it.DCMotor— the first model in two domains at once, and the first whose component holds components:Machines.Motorhas the winding, coil, converter and rotor inside it, with three connectors wired from both sides.- Both agree with an independently integrated reference to 1e-10.
tests/test_diagram.py: 63 tests. Every model in the corpus draws, the SVG parses as XML, the same model draws the same bytes twice, and no drawn component is missing from the flat model.- Docs rewritten in ASD-STE100 Simplified Technical English. Short sentences, active
voice, one idea per sentence, one topic per paragraph, and one meaning per word. Modelica
is used worldwide and most readers here do not read English first; the standard costs the
writer some effort and saves every reader some.
docs/style.mdrecords the rules followed and the two places this project departs from them. No claim, number or reason was dropped in the rewrite. -
Docs: Drawing a model, a new executed notebook — the three pictures, what each one is for, and what keeps them honest. The architecture pipeline diagram is a Mermaid graph instead of ASCII art, and the site now renders
mermaidfences. -
modelica.ir: the expression, equation and class value types the whole toolchain is built on. Frozen dataclasses throughout. modelica.lang.lexer: a hand-written tokenizer. Strings arrive decoded, comments and whitespace are dropped, and every token carries its line and column so errors can point at the source rather than describe it.modelica.lang.parse: a recursive-descent parser producingmodelica.irvalues. It refuses the constructs it does not implement by name (for,while,algorithm, arrays, …) instead of misreading them, because a parser that quietly mangles a model is worse than one that has never heard of it.modelica.lang.unparse: IR back to Modelica source, with correct operator precedence and associativity.modelica.lang.precedence: one table, climbed by both the parser and the printer, which is whyparse(unparse(x)) == xis structural rather than coincidental. The single deliberate deviation from the spec — the unary sign binding tighter than*— is recorded there with its justification.tests/test_lang.py: 139 tests. Round trips over the whole example corpus in both directions, the precedence deviations pinned as trees rather than as strings, and the refusals asserted so an unimplemented construct cannot start silently half-working.modelica.build: a Python front-end that constructsmodelica.irvalues directly — a peer ofmodelica.lang, not a wrapper around it. Declarations are descriptors,==builds an equation, class inheritance isextends, a module is apackage.examples/: 3 component libraries and 10 models, each written twice — once as.moand once through the Python front-end — plus a TOML behaviour spec per model (reference points, invariants, expected events).tests/test_examples.py: runs the corpus. The spec guard and the front-end equivalence check run today; parse, flatten and simulate skip until those stages exist.- Architecture: recorded that the IR must be compilable —
modelica.simemits code for the RHS and Jacobian rather than walking a closure tree, which is what makes a Python engine competitive with the Fortran solvers. Matching them is now stated as the floor rather than the target, with the three things a model-owning compiler can do that a precompiled library structurally cannot. - Docs: Backends — two evaluators over one IR (the interpreter stays as the reference tier and makes differential debugging possible), backends as emitters rather than rewrites, and why the compiler is written for this workload instead of adopted from a general-purpose one.
- Docs: Benchmarks — the problem sets any performance claim here has to answer to (Bari IVP test set, Hairer's, SciMLBenchmarks, the Modelica ScalableTestSuite) and why the measurement is a work-precision diagram rather than a FLOPS number.
-
Python floor raised to 3.12 (was 3.11). The
simextra decides it: NumPy 2.4 is itselfRequires-Python: >=3.12, and its stubs spellArrayLikewithcollections.abc.Buffer, a 3.12 name — type-checked as 3.11, every array argument in the package reads as partially unknown. Supporting a version the numerics cannot be installed on was a claim about nothing.requires-python, the ruff target, the pyright version and the floor CI job now all say 3.12. -
modelica.sim.solve: event iteration. An event no longer ends the instant — applying it can leave a second relation disagreeing with its own latch, and the loop now runs to a fixed point before the integrator restarts. This was a correctness hole, not a performance one: the integrator could never have caught that second relation, because on the next segment its indicator does not cross zero, it starts out already past it, so the stale value was carried for the rest of the run. modelica.sim.emit:Trigger.latchputs the discrete structure of a model on the public record — which workspace slot each crossing owns,Nonefor awhenbranch. Two things above the emitter need it, and both used to have no way to ask.modelica.verify: model-checking the discrete half, behind the newverifyextra (musil as the checker). At one instant the continuous state is frozen, so the event iteration is a finite machine: this enumerates it from every latch configuration and in every order the pending events could be applied, and proves that it settles (liveness) and that it settles in one place (confluence). The emittedindicatorsandtransitionare the functions being called, so a counterexample is a real execution rather than an artefact of an approximation.tests/test_verify.py: the whole corpus checked att = 0and again at every instant a real run stopped at — the bounce, the switch, the moment the tank runs dry.modelica.ir.causalize: every admissible state set, onSystem.candidates. A set can be the states exactly when holding it back still leaves an equation for every remaining unknown, so the test is the matching itself, run once per candidate with that set forbidden. The pendulum answers(y, vy)and(x, vx)— the two the literature names, which are two exchanges apart, so they are enumerated rather than found by perturbing the chosen set one variable at a time. This is the menu dynamic state selection chooses from.modelica.sim.emit: dynamic state selection, and with it the pendulum simulates. Each candidate set is causalized into its own blocks and scored by the product of its 1×1 pivots —4x²against4y²for the pendulum, so the crossover is at 45°, which is the answer the literature gives. The emitter writes onerhs/observe/indicators/transitionper set plus a picker that reads which one is live from a workspace slot appended after the latches, so nothing above the emitter needs an extra argument and numba still compiles the lot. Crossings come one per ordered pair (a pair compares with one smooth expression; a maximum does not) and read−1outside their origin, because a set the run is not using is not a set it can leave. A 20% margin on the scores is what makes the switch terminate rather than chatter. The crossing itself costs no accuracy — every state of every set is an unknown of the model, so the destination's states are already computed when the transition rewrites the vector. Pendulum over two seconds: four switches,x² + y² = 1held to 2·10⁻¹⁶, energy drift 1.6·10⁻¹⁰.modelica.sim.emit: the analytic state Jacobian, as an eighth generated function. Forward mode by source transformation over the same blocks the right-hand side runs, one row vector per variable so the emitted source stays the size of the model rather than its square. It is not a speedup and is not offered as one — measured against SciPy's own difference quotient the step counts are identical and the wall clock moves by less than the noise, because for systems this small a difference quotient is two right-hand sides. It is exactness: a block that Newton iterates on has no derivative a difference quotient can see (perturbing the input re-runs the loop, so the answer is only as smooth as the tolerance it stopped at) while its residual differentiates into one linear solve against the matrix the block was already solved with; and a latched relation is frozen, so the exact derivative steps across a switch that a difference quotient would straddle. Both are why FMI asks forfmi3GetDirectionalDerivativerather than differencing outputs itself. Wired intosolve_ivpfor the implicit methods and to those only, since SciPy warns about arguments a solver ignores, and handed back in a fresh array each call because Radau keeps the last Jacobian it was given across steps.tests/test_examples.py: the emitted Jacobian against central differences of the emitted right-hand side, at twelve points along each model's own simulated trajectory rather than around the initial point — which is where models are at their most degenerate, every rate zero. Only the pendulum exercises the implicit path, and it is the one that catches a sign flip in either the 1×1 or the 3×3 tangent solve.modelica.sim.kernel: the compiled path is executed rather than claimed.jit=Truehas handed the generated module to Numba since the emitter existed, and nothing had ever run it — Numba is in no dependency group, so "there is a Numba backend" was a docstring rather than a fact.tests/test_jit.pynow checks every generated function, compiled against interpreted, bit for bit, on five models chosen to force every shape the emitter writes; plus a whole simulation, bounces and all, agreeing to 1e-12.modelica.sim.kernel:error_model="numpy". Numba defaults to raisingZeroDivisionErrorwhere CPython would, while the interpreted kernel divides numpy scalars and gets an infinity — a real semantic fork between the two tiers, and the interpreted one is the reference tier that differential debugging depends on. Defensive rather than a fix for an observed failure, and said that way on purpose: no model in the corpus reaches a division by exactly zero (the pendulum aty = -Lcomes closest and its Newton lands near 1e-16, not on 0.0). But an exact zero pivot is reachable in principle exactly where this engine has something to say — a state set going singular, which dynamic state selection survives by switching rather than stopping. An infinity there is information; an exception is a crash.- Measured, because the reason to compile is not the same for every model: on
AtwoodPendulum(10 states, a 4×4 implicit block) Numba is 21× onrhsand 44× onjacobian— 33.6 µs to 1.61 µs, 180.6 µs to 4.07 µs. On a two-state model it is 1.0×, the dispatch costing what the arithmetic does. And put back in context it is a 1.55× on the simulation, because 63% of that run is SciPy's Radau, which is written in Python: compiling the model is nearly free and nearly finished, and it is not where the time is. moon run :test-jit, and deliberately nojitextra — which is a finding, not an oversight. Numba pinsnumpy<2.5as far as its current release (0.66) and this project runs 2.5. Declaring the extra was tried and reverted: uv locks one resolution across every extra, so naming Numba anywhere inpyproject.tomlrolled the whole lock back to numpy 2.4.6, dev environment included — every other test would have run against a numpy nobody installs, to support an optional backend most callers never ask for.[tool.uv] conflictswould separate them at the price of declaringsimandjitmutually exclusive, which is false. So the task installs Numba into a throwaway environment where numpy resolves down on its own, andtests/test_jit.pyskips itself everywhere else.modelica.verify.check_selection: the switching modelled with the linear algebra as the adversary — which set is usable is not the engine's to choose, and it only finds out by failing to factor one. The two assumptions that makes necessary are surfaced throughcheck_openrather than swallowed, and the load-bearing one is executable:quiescent=Falsedrops it and produces the run that breaks it.modelica.fmi.modes: the FMI 3.0 calling sequence, as one table, written before the FMI layer that has to obey it. Read three times — an exported FMU's guard (Lifecycle.callrefuses an illegal call, which is what becomesfmi3Error), an importer's driver, andcheck_modes. Checking it immediately found Event Mode sitting in the Scheduled Execution table, where the standard has Clock Activation Mode and Clock Update Mode instead.modelica.ssp.master: the co-simulation master's protocol as pure transitions, and the finding that motivates writing it first —canGetAndSetFMUStateis optional, so a conforming FMU may decline to save its state, and a master that rolls back on a discarded step then has no move left. Four moves from the start, and invisible to any test whose FMUs all happen to support state saving.- Docs: Verification — what a model checker can and cannot say about a simulation engine, and the four places it pays. Written before any of it existed and corrected where building it disagreed with the plan, including one plan that turned out to be unnecessary.
- Docs: two executable notebooks (
docs/notebooks/), run at build time by mkdocs-jupyter withallow_errors: false. A page whose code stopped working failsmkdocs build --strictin CI rather than sitting there showing output from a version that no longer exists. They are.pyin percent format rather than.ipynbso a diff of one is readable. - Docs: three
.ipynbnotebooks with matplotlib figures and ipywidgets sliders, meant to be opened and run rather than only read — Turning the knobs (parameter studies by rewriting aFlatVariable.binding, a damping sweep, overshoot and settling time against critical damping), The shape of the equations (the incidence matrix of a generated RC ladder, what alias elimination deletes from it, the block-lower-triangular staircase, and the pendulum's 3×3 block that cannot be sorted away), and Where the smoothness breaks (bounce events against the geometric series they should follow, the solver's step size collapsing into each event, both state sets' pivots crossing at 45°, and constraint drift never exceeding one machine epsilon). Committed with outputs stripped, so they diff like the percent files and cannot render results from an older version of the code; the site executes them like the others. matplotlib and ipywidgets joined thedocsextra — the library itself still draws its own charts with no plotting dependency.
0.0.2¶
- Trademark notice: Modelica® / FMI / SSP belong to the Modelica Association; this is an independent implementation, not affiliated or endorsed. Stated in the README (so it is on the PyPI page), the docs, and the package docstring.
0.0.1¶
Project restart. The 2010 Python 2 / pyparsing prototype is retired (it remains in git history); nothing from it is carried forward.
- Claim the
modelicadistribution name on PyPI. - Repository moved from GitHub to GitLab (
gitlab.com/jorgeecardona/pymodelica). - Toolchain: uv + moon pinned via proto; ruff (
select = ["ALL"]) and pyright strict. - Single-job GitLab CI driven by
moon run :ci; PyPI releases via OIDC Trusted Publishing; docs on GitLab Pages. - Python 3.11+ only.