Skip to content

Add standalone Julia builder (ODE + MTK backends)#487

Open
rogersamso wants to merge 60 commits into
SDXorg:masterfrom
rogersamso:feature/julia-mtk-builder
Open

Add standalone Julia builder (ODE + MTK backends)#487
rogersamso wants to merge 60 commits into
SDXorg:masterfrom
rogersamso:feature/julia-mtk-builder

Conversation

@rogersamso

Copy link
Copy Markdown
Contributor

Description

Adds a standalone Julia builder to PySD: translate any Vensim .mdl or
Stella .xmile/.stmx model to a self-contained .jl file with zero
runtime dependency on Python or PySD
.

Two backends are available:

Backend How it works When to use
backend="ode" (default) Emits a plain rhs!(du, u, p, t) function solved by OrdinaryDiffEq.jl All models; fast startup, full subscript support
backend="mtk" Emits a ModelingToolkit.ODESystem with symbolic simplification Small/medium models needing symbolic analysis or LaTeX export

Public API:

import pysd
path = pysd.translate_to_julia("model.mdl")             # ODE (default)
path = pysd.translate_to_julia("model.mdl", backend="mtk")  # MTK

Files added:

pysd/builders/julia/           — builder package
pysd/builders/julia/PySD.jl/  — git submodule (github.com/rogersamso/PySD.jl)
pysd/pysd.py                   — translate_to_julia() entry point
tests/pytest_builders/pytest_julia.py              — 382 unit tests
tests/pytest_builders/pytest_julia_integration.py — 826 integration tests
docs/julia_builder.rst
docker/julia-base/Dockerfile
.github/workflows/julia-ci.yml
.github/workflows/docker-julia-base.yml

The runtime Julia library lives in its own repo (https://github.com/rogersamso/PySD.jl),
wired in as a git submodule following the same pattern as tests/test-models.

CI note: the numerical tests run inside a pre-built Docker image
(ghcr.io/rogersamso/pysd-julia-base:{1.10,1.11}) with all heavy packages
(ModelingToolkit, Symbolics, …) precompiled, reducing CI time from ~20 min
to ~4 min. The image is currently under ghcr.io/rogersamso/…; maintainers
may wish to rebuild it under ghcr.io/sdxorg/pysd-julia-base after merge
and update the reference in julia-ci.yml and docker-julia-base.yml.

Related issues

None.

Type of change

  • New feature (non-breaking change which adds functionality)

PR verification (to be filled by reviewers)

  • The code follows the PEP 8 style
  • The new code has been tested properly for all the possible cases
  • The overall coverage has not dropped and other features have not been broken.
  • If new features have been added, they have been properly documented
  • docs/whats_new.rst has been updated
  • PySD version has been updated

🤖 Developed with Claude Code

rogersamso and others added 30 commits May 31, 2026 20:02
Adds a new `pysd.builders.julia` package that translates a PySD
AbstractModel into a self-contained `.jl` file using ModelingToolkit.jl.
The generated file has no runtime dependency on Python or PySD.

Key features:
- Stocks → `D(x) ~ flow` ODE equations
- Auxiliaries → algebraic equations (`x ~ expr`)
- Constants → `@parameters`
- Named lookup tables → `DataInterpolations.LinearInterpolation` constants
- Inline lookups → registered and emitted as named interpolants
- SMOOTH(N) / DELAY(N) → expanded into auxiliary ODE levels
- Common Vensim built-ins mapped to Julia equivalents
- Helper functions (_pulse, _ramp, _step, _xidz, _zidz) emitted inline
- Modular output: when the Vensim model uses views and split_views=True,
  each view becomes a separate .jl module file with its own equation
  vector; the main file includes them and concatenates with `[v...;]`

New public entry point: `pysd.translate_to_julia(model_file, split_views=False)`

110 unit tests cover namespace management, AST visitor, element
processing (stocks, auxiliaries, parameters, lookups, smooth/delay
expansions), single-file and modular builder output, and the
translate_to_julia() entry point integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug fixes
---------
* julia_expressions_builder: add underscore-form keys for built-in
  functions that the Vensim parser stores with underscores instead of
  spaces (if_then_else, pulse_train).  Previously these emitted
  'Unknown function' warnings; they now resolve correctly.
* julia_model_builder: replace Julia docstring header ("""...""") with
  # comments.  A triple-quoted string immediately before `using` is
  parsed by Julia as "document the using statement", causing a
  LoadError at runtime.

Integration tests (tests/pytest_builders/pytest_julia_integration.py)
----------------------------------------------------------------------
Tier 1 — Translation (no Julia required, always runs in CI):
  * TestTranslationAllModels — 2 × 153 parametrised tests: every .mdl in
    tests/test-models/tests/ must produce a .jl file containing the
    required ModelingToolkit structural markers.
  * TestTranslationCleanModels — 3 × 52 parametrised tests: the 52
    fully-supported models must translate with zero UserWarnings.
  * TestTranslationFeatures — 10 spot-check tests verifying that specific
    SD constructs (INTEG, SMOOTH, DELAY, lookups, PULSE/RAMP/STEP, IF
    THEN ELSE, XIDZ/ZIDZ, logicals) produce the expected Julia output.
  * TestModularTranslation — 4 tests for split_views=True builds.

Tier 2 — Numerical validation (@pytest.mark.julia, skipped unless
  julia + ModelingToolkit.jl + OrdinaryDiffEq.jl are available):
  * TestNumericalValidation — 12 tests (one per model with output.csv)
    that translate the .mdl, execute the generated .jl with a
    dynamically-generated Julia driver, parse the CSV stdout, and compare
    every column against the reference within rtol=1e-3 / atol=1e-4.
    Models covered: abs, builtin_max, builtin_min, exp, if_stmt,
    initial_function, input_functions, logicals, lookups_with_expr,
    number_handling, sqrt, trig.

Also registers the 'julia' marker in pytest.ini.

Total: 591 passing, 12 skipped (numerical tier, MTK not installed here).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update http:// to https:// — the server rejects plain HTTP requests
with 403, breaking the lychee link checker in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
nbviewer.ipython.org is deprecated and returns 503. Update to
nbviewer.jupyter.org (the current domain) for all three cookbook links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MTK / Julia compatibility fixes
--------------------------------
* Use @independent_variables t instead of @variables t (MTK v9+)
* Add OrdinaryDiffEqLowOrderRK to imports for Euler solver (ODE v7+)
* Add saveat=tspan[1]:dt:tspan[2] to run_model() so observed variables
  are saved at every step (not just start/end for no-stock models)
* Add @register_symbolic for all lookup table functions so MTK calls them
  numerically each step instead of constant-folding during simplification
* Fix logical AND/OR/NOT helpers to return Symbolic{Bool} via & / | / !
  on comparisons — the previous nested ifelse returned SymReal which the
  outer ifelse condition rejected
* Fix _pulse_train arg order: Vensim parser stores (start, interval, width,
  end) not (start, width, interval, end)
* Fix _pulse, _step, _pulse_train, _xidz, _zidz helpers to use ifelse +
  & instead of ?: and && so they work with symbolic MTK arguments
* Handle INITIAL() at element level as a @parameters constant (two-pass
  build ensures stock initial conditions are known first); add recursive
  aux-chain resolution so INITIAL(InflowA) where InflowA=StockA resolves

Integration test fixes
----------------------
* Update @independent_variables t assertion in Tier 1 tests
* Update logic operator assertions (&&/|| → _logical_and/_logical_or)
* Update lookup_interpolation_code calls for new 3-tuple return value
* Loosen _isclose absolute tolerance: pass if |a-b| ≤ atol regardless of
  relative error (covers interpolation differences on small near-atol values)
* Robust Julia runner: _get_series() falls back from sys.var (state/obs)
  to sol.prob.ps (in-system param) to ModelingToolkit.getdefault (global
  @parameters constant) before returning NaN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lia builder

- Variables and parameters with Vensim subscripts are now declared as MTK
  array types: @variables x(t)[1:N_DIM] / @parameters p[1:N, 1:M]
- 1-D subscripted equations use Symbolics.scalarize for efficient vectorised
  expansion; N≥2-D equations use explicit index comprehensions (_i0, _i1, ...)
- JuliaASTVisitor accepts active_subs/var_dims context so references inside
  subscripted equations are emitted with correct indices (x[_i0, _i1, ...])
- Subscript dimension sizes emitted as Julia consts (const N_SECTORS = 3)
- GetConstantsStructure (GET XLS/DIRECT CONSTANTS) now reads values from the
  external file at translation time via ExtConstant and inlines them as
  @parameters or const arrays instead of emitting a placeholder
- _format_julia_value converts numpy/xarray data to Julia literal syntax
- using Symbolics added to file header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…warning reduction)

DELAY FIXED: expand as first-order ODE approximation instead of identity fallback
TrendStructure: expand into smooth-level ODE + algebraic output equation
ForecastStructure: expand inline using same smooth level as Trend
SampleIfTrueStructure: approximate as conditional INTEG with time-step-gated flow
GetLookupsStructure: read lookup data from external file at translation time,
  emit as DataInterpolations LinearInterpolation; removed from _UNSUPPORTED_STRUCTURES
GetDataStructure / AbstractData: read time-series from external file at
  translation time, emit as time-indexed interpolation; removed from
  _UNSUPPORTED_STRUCTURES
AllocateAvailable / AllocateByPriority: emit proportional-share approximation
  with explanatory comment instead of placeholder
Builtins: add SUM→sum, PROD→prod, VMAX→maximum, VMIN→minimum,
  ELMCOUNT (resolved to literal size), INVERT MATRIX→inv, TRANSPOSE→transpose,
  ACTIVE INITIAL→_active_initial to BUILTIN_FUNCTIONS
Lookup-variable-as-function-call: resolve known model identifiers before
  emitting "unknown function" warning, eliminating ~149 spurious warnings
GetConstantsStructure nested in expressions: handle in visitor dispatch,
  read value inline rather than emitting 0.0
INITIAL() resolver: extended to follow GetConstantsStructure references and
  increased recursion depth
Control elements processed first so time_step is available to other constructs
Warning count on pymedeas_w.mdl: 434 → 23 (94.7% reduction)

Tests: add TestNewConstructsTranslation (11 tests) and TestNewConstructsNumerical
(4 Julia-running tests) covering DELAY FIXED, TREND, FORECAST, SAMPLE IF TRUE;
add minimal .mdl test models under tests/more-tests/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A2: Subscript names used as loop-index values in element-wise comparisons
  (e.g. I_Matrix[s,s1] = IF_THEN_ELSE(s=s1,1,0)) now emit the correct
  loop-index variable (_i0, _i1) rather than a sanitised fallback.
  Root cause: _reference() did not check active_subs before the namespace
  lookup. Fix adds _clean_active_subs (normalised keys) at construction time
  and checks it first.

A1: ELMCOUNT(subscript_name) now resolves to the integer element count even
  when the subscript name casing differs from the abstract model's subs_sizes
  key. Adds _clean_subs_sizes (normalised keys) at construction time.

A3: 2D EXCEPT subscript exclusion implemented in _process_except_element_2d.
  Previously any EXCEPT with 2D subscripts emitted a plain broadcast (ignoring
  the exclusion). The new method resolves per-component covered index pairs,
  applies exclusions, and emits one comprehension per component.

Partial fix for B: _comp_coords helper correctly maps per-element-name component
  subscripts (e.g. ['Agriculture', 'CCS_tech']) to {'parent_range': ['Agriculture'],
  'CCS_tech': [...]} matching what the Python builder passes to ExtLookup/ExtData/
  ExtConstant. Adds _elem_to_range reverse-lookup built at construction time.
  Four variables still fail (CCS_tech_share, historic_final_energy_intensity,
  global_HFC_emissions_RCP, other_forcings_RCP) due to element ambiguity and
  3D data shapes — tracked in task #4.

270 tests pass (all pre-existing + 12 new TDD tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nge ambiguity, and AbstractData routing

- Add _infer_parent_range / _detect_split_ranges / _comp_coords_split helpers
  to correctly resolve parent-range ambiguity when the same element name
  appears in multiple subscript ranges (fixes ccs_tech_share and
  historic_final_energy_intensity by selecting the range that contains
  all per-component elements)
- Handle 3D arrays (n_points × n_dim1 × n_dim2) in both _process_get_lookups
  and _process_get_data: emit per-(i,j) sub-functions and a two-index dispatch
  identifier(i, j, x) = identifier_fns[i][j](x)  (fixes global_hfc_emissions_rcp)
- Guard GET DATA dispatch: only route to _process_get_data when at least one
  component has a GetDataStructure AST; AbstractData with a CallStructure AST
  (e.g. other_forcings_RCP) falls through to the regular auxiliary path with a
  targeted data-override warning instead of emitting a GET_DATA_FAILED placeholder
- Update tests: replace two stale tests that expected old broken behaviour with
  tests that verify the correct 3D and AbstractData handling; add new
  test_get_lookups_4d_warns_and_flattens for the still-unsupported >3D case

All 271 Julia builder tests pass; coverage 98%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sion avoidance and piecewise assembly

- Update _detect_split_ranges to record ranges committed by non-split
  positions and call _infer_parent_range_not_in for split positions so
  that parent-range collision is avoided (e.g. 'efficiency_rate_of_
  substitution' has final_sources at pos 1 AND the per-fuel split at
  pos 2 — now correctly assigns final_sources1 to the split dim)
- Add _infer_parent_range_not_in helper (like _infer_parent_range but
  skips an excluded set of already-committed range names)
- Apply the same split_ranges logic to _read_get_constants so it uses
  consistent parent-range keys when calling ext.add() for multi-component
  constant elements
- Extend the GET CONSTANTS dispatch to accept 'piecewise' elements
  (some components are GCS, others are numeric literals) via a new
  _read_get_constants_piecewise method that reads each GCS slice with
  proper per-component coords, collects literal values, and assembles the
  full array ordered by the parent subscript range (fixes policy_share_
  FEH_over_FED: electricity/heat=0 + matter_final_sources from Excel)
- Add two new tests: split-range collision test and piecewise-mixed test

All 273 Julia builder tests pass; coverage 97%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Task D — INITIAL() now correctly frozen at t=t0:
- When _resolve_initial_value succeeds (literal / param-chain), emit
  @parameters as before.
- When it returns None (complex expression like INITIAL(historic_demand)),
  instead of a time-varying auxiliary with a warning, emit a
  zero-derivative stock: D(x) ~ 0.0 with initial condition x(t0) = expr.
  MTK evaluates the expression at t=t0, giving the correct Vensim
  semantics. Handles scalar (ndim=0), 1D, and 2D subscripted variables.
  Fixes initial_demand[sectors], Initial_water_intensity_by_sector
  [sectors×water], and Initial_water_intensity_for_households[water].
- Updated two stale tests that expected the old warning+aux fallback.

Task G — chardet deprecation:
- pysd/py_backend/utils.py: change
    from chardet.universaldetector import UniversalDetector
  to
    from chardet.detector import UniversalDetector
  silencing the DeprecationWarning in all runs.

All 273 Julia builder tests pass; 1 expected warning (data-override).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five bugs found by actually running pymedeas_w.jl under Julia 1.12:

1. INTEGER/INT → `_trunc` registered symbolic:
   Base.trunc is not a symbolic primitive; add _trunc wrapper +
   @register_symbolic so ELMCOUNT-like usage inside MTK equations works.

2. ndim=1 aux/stock equations → per-element comprehension:
   scalarize(.~) fails when RHS contains ifelse(scalar, array, scalar) —
   switch 1D equations to [x[_i0] ~ rhs for _i0 in 1:N] comprehensions
   like ndim≥2 already does. Add _var_dims pre-pass before build_section
   so forward-referenced subscript dims are resolved in all equations.

3. Control block before module includes:
   `time_step` etc. were defined after include() calls; move _control_block
   to before the module includes so equations can reference the constants.

4. Unary `negative` operator → `-`:
   ARITHMETIC_OPS lacked the "negative" key; bare references like
   -growth_labour_share were emitted as negativegrowth_labour_share.

5. Explicit subscript indexing + bare GET DATA auto-call:
   - Pass subs_elems to visitors; resolve explicit subscript elements
     (e.g. share_FEH[solids]) to their 1-based numeric index.
   - Add lookup_names set; bare ReferenceStructure to GET DATA/LOOKUPS
     functions now auto-calls f(t) or f(idx, t) instead of referencing
     the function object.
   - Scalar context: subscripted lookup referenced via ReferenceStructure
     (or CallStructure with 1 arg) emits a comprehension over all indices.
   - Pass var_dims to scalar visitor so it can index subscripted lookups.

All 273 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…odel

Fixes uncovered by actually running the translated model under Julia:

* Scalar context subscript broadcast in _call(): pass var_dims to scalar
  visitor so subscripted lookups referenced with 1 arg in scalar equations
  emit a comprehension [f(i, t) for i in 1:N] (fixes sum(f(t)) patterns).

* Lookup auto-call in _reference(): pre-populate _lookup_func_names for
  all GET DATA / GET LOOKUPS elements; bare references auto-emit f(t) or
  f(i, t) instead of the raw function object.

* Explicit subscript resolution: add subs_elems + elem_index to the visitor
  so node.subscripts (e.g. var[solids]) resolve to numeric indices.

* EXCEPT handler correctness:
  - covered_indices now respects the component's defining subscript list
    (fixes solids-only component expanding to all 5 elements).
  - Each covered index now uses a per-index visitor with active_subs so
    subscripted constants (e.g. policy_share_feh_over_fed) are indexed.

* _element_dims: resolve element-label subscripts to their parent range,
  so per-element auxiliary variables are correctly identified as 1D arrays.

* Per-element multi-component auxiliaries (no EXCEPT clause) now route to
  _process_except_element for per-element equation generation, excluding
  external-structure elements (GET LOOKUPS / GET DATA / GET CONSTANTS).

All 273 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…CI/Docker

- julia_model_builder: support subscripted SMOOTH(N) by emitting array-level
  ODEs as comprehensions when dims are present
- translate_to_julia / JuliaModelBuilder: expose data_format parameter
  ("hardcoded" default, "json" writes a companion *_data.json via JSON3.jl)
- pytest_julia_integration: add TestNewFeatureIntegration (Phase 4) covering
  json mode, hold_forward/hold_backward, variable limits, EXCEPT exclusion,
  macro companion file, and DataStructure warning
- Add .github/workflows/julia-ci.yml, Dockerfile.julia, docker-compose.yml
  for Julia CI infrastructure
- Add test fixtures: julia_allocate and julia_data_structure .mdl models
- Add docs/pymedeas_w_julia_translation_report.md translation report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…expansion

Three related bugs in _process_except_element and _element_dims triggered
by C_in_Deep_Ocean, Heat_in_Deep_Ocean, Diffusion_Flux, Heat_Transfer, and
PES_fossil_fuel_extraction_delayed in the pymedeas world model:

1. _element_dims: detect when first component uses a sub-range (e.g. 'upper')
   and other components use sibling elements outside it (e.g. 'Layer4') —
   now infers the true parent range ('Layers') so the variable gets the
   correct array size.

2. _process_except_element candidate_labels: when def_subs[0] is a named
   sub-range (not the full dim name), expand it to its elements instead of
   silently producing an empty candidate list.  Fixes Diffusion_Flux[lower]
   and Heat_Transfer[lower] being dropped.

3. _process_except_element IntegStructure/DelayFixedStructure: instead of
   calling the expression visitor on stock/delay AST nodes (which emitted
   0.0 placeholders with a warning), emit proper per-index ODE equations
   and initial conditions.

New helper _per_index_subs implements Vensim's positional range alignment:
when iterating over sub-range R at position p, aligned same-size ranges
(e.g. 'lower' relative to 'upper') are mapped to the correct absolute
parent-dimension Julia index so cross-range references like
diffusion_flux[lower] resolve correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every generated .jl file now includes a save_results(sol, path::String)
function that writes the full ODE solution to a NetCDF4 file using
NCDatasets.jl. Subscript dimensions are written with their element-label
string coordinates; all variable writes are wrapped in try/catch so a
single failed variable does not abort the save. NCDatasets is added to
the using block in the file header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Julia tests

- _expand_sample_if_true: add dims/ndim params; emit array stocks and
  comprehension equations when variable is subscripted (1D and 2D)
- _expand_delay_fixed: add dims param; emit subscripted _df_ stock and
  comprehension D()/alias equations for subscripted variables
- _expand_delay: add dims param; subscripted pipeline levels with .* broadcasting
- _process_element: pass dims to all expand_* call sites so subscript
  info reaches the expansion functions
- pytest_julia_integration: redesign Tier-2 tests to use a single Julia
  process per test class (batch runner with module isolation), reducing
  wall time from ~80 min to ~10 min; all 16 numerical tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _format_julia_value: for ndim>2 emit reshape([...], s0, s1, ...) using
  Fortran-order flatten to match Julia's column-major indexing convention
- _process_element: accept reshape(...) prefix as array-like value
  alongside existing [..] check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s builder

Vensim uses X[i!, j] inside SUM/PROD/VMAX/VMIN to mean 'aggregate over
subscript i, for each j'. Previously the ! subscript was ignored, producing
arr[_i0] on a 2D symbolic array (illegal in Symbolics.jl).

Fix in _reference(): when any node_subs entry ends with '!':
- Build a dict mapping dim_name → index_expr (loop var or active_sub)
- Introduce new _ii0, _ii1, ... loop vars for each ! dim with ranges
  using the correct N_DIM constant
- Assemble indices in var_dims_list (declaration) order so that
  arr[fs, fs1!] with decl [fs1, fs] correctly generates arr[_ii0, _i0]
  (not arr[_i0, _ii0]) — subscript order in the reference can differ
  from declaration order in Vensim

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vensim syntax f[dim1!, dim2](t) calls a GET DATA function and sums over
dim1. Previously the subscripts on the function reference were ignored
entirely, producing f(_i0, t) instead of
[f(_ii0, _i0, t) for _ii0 in 1:N_DIM1].

Added handling in _call() mirroring the _reference() fix: when
node.function.subscripts contains entries ending with '!', build a
comprehension with the aggregation dims as inner loop variables and
assemble call indices in var_dims_list (declaration) order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, entrypoint

- Add ExtrapolationType.Constant to all LinearInterpolation/ConstantInterpolation
  calls so MTK can evaluate lookups at t=0 during initialization (fix 17)
- Inline @parameters values into u0 entries using word-boundary regex substitution
  to prevent 'not an unknown' errors in MTK InitializationProblem (fixes 15+16)
- Add build_initializeprob=false to ODEProblem to skip MTK's initialization
  system; Vensim pure-ODE models have explicit stock initial values and the
  initialization system OOMs on large models with algebraic loops (fix 18/19)
- Add _entrypoint_block() calling run_model() and save_results() so julia model.jl
  actually runs instead of silently defining functions and exiting (fix 13)
- Fix 2D stock u0 entries to generate per-element indexed assignments with correct
  initial values (fix 14)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
structural_simplify can promote algebraic-loop variables to state variables
that have no explicit u0 entry. Build a complete u0 by iterating unknowns(sys)
and falling back to 0.0 for any missing entries. Also skip build_initializeprob
to avoid OOM from MTK's symbolic initialization on large models with algebraic
loops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds POWER, SINH, COSH, TANH, PI, QUANTUM, Xpulse, Xpulse_train,
Xramp, RANDOM_0_1, RANDOM_UNIFORM, RANDOM_NORMAL, RANDOM_EXPONENTIAL,
VECTOR_SELECT, VECTOR_SORT_ORDER, VECTOR_REORDER, VECTOR_RANK, and
GET_TIME_VALUE to the Julia expressions builder. Also fixes single-file
build ordering so control variables (initial_time, final_time) are
defined before the equations block that references them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add PySD.jl Julia package (Project.toml, src/PySD.jl, src/save_results.jl,
  ext/PySDMTKExt.jl) as the companion runtime library for generated models
- Implement save_results for ODE backend (state_map dispatch) and MTK backend
  (ODESystem dispatch via package extension, activated when ModelingToolkit is loaded)
- Add NCDatasets as a direct dep and ModelingToolkit as a weakdep of PySD.jl
- Fix translate_to_julia: add backend param (was silently dropped into **kwargs)
  and pass it through to JuliaModelBuilder
- Add docs/julia_builder.rst and link it from docs/index.rst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Builder changes (julia_model_builder.py, julia_expressions_builder.py):
- GET CONSTANTS/LOOKUPS/DATA: emit pysd_xlsx_read_constant/read_series at
  Julia load time instead of baking values at translation time
- Piecewise multi-component GCS: emit vector pysd_xlsx_read_constant call
  (no more ExtConstant at translation time for 1D piecewise)
- JSON mode: guard runtime paths with data_format != "json" so JSON mode
  falls through to the ExtLookup/ExtData/ExtConstant baked-in fallback
- JSON mode _declarations_block: emit _model_data["constants"] references
  for constants accumulated in _json_data
- GET DATA keyword passthrough: respect hold_backward/look_forward when
  choosing ConstantInterpolation vs LinearInterpolation
- translate_to_julia: backend param now correctly passed to JuliaModelBuilder

Test suite (tests/pytest_builders/pytest_julia.py):
- Update 32 tests to reflect runtime Excel reading (check for
  pysd_xlsx_read_constant/_itp/_fns patterns, not baked numeric values)
- Remove ExtConstant/ExtLookup/ExtData mocks that are no longer called
  at translation time
- Fix backend assertions: ODE default emits rhs!/OrdinaryDiffEq (not MTK)
- All 339 tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ODE models now emit an observe(u, t) function alongside rhs!.
It unpacks state variables and recomputes every auxiliary at a
given (u, t) point, returning a Dict{String,Any} keyed by Julia
identifier.  Module-level consts (INITIAL, GET CONSTANTS, etc.)
are included via _const_names_for_observe since they are in
scope as module globals.

This enables numerical validation against output.csv reference
files from the test-models submodule:
- _batch_get_series now tries observe first (ODE path), then
  falls back to mod.sys introspection (MTK path)
- All 16 TestNumericalValidation tests now pass (were all NaN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These files were already loaded by PySD.jl (via include()) and exported
but were never committed — anyone cloning the repo would get a broken
Julia builder. They contain the entire runtime layer:

- helpers.jl: all pysd_* Vensim built-in implementations (xidz, zidz,
  pulse, ramp, step, logical ops, SafeArray, random, vector ops, …),
  with @register_symbolic for MTK compatibility
- xlsx.jl: runtime Excel reading (pysd_xlsx_read_constant, read_series,
  build_lookup_dispatch) with in-memory file cache
- latex.jl: optional pysd_export_latex for MTK ODESystem → LaTeX export
- README.md: user-facing docs for the companion library

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All Tier-1 translation checks now look for ODE markers (function rhs!,
function observe, du[…]) instead of MTK markers (ODESystem, D(…),
@variables). Also drop except and except_subranges from CLEAN_MODELS
(IntegStructure-in-expression not yet supported) and fix two unit-test
helpers that passed invalid kwargs to AbstractUnchangeableConstant.

486 integration tests + 339 unit tests all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Expand NUMERICAL_MODELS with case_sensitive_extension, comparisons,
eval_order, odd_number_quotes, trend.  To support these:
- _find_model_file() handles uppercase .MDL and .xmile/.stmx formats
- _build_id_map() generalised to parse both VensimFile and XmileFile
- warnings suppressed in _build_id_map call inside fixture

Fix: _u0_block() now uses `observe(zeros, initial_time)` in a let-block
to bootstrap initial values for stocks whose initial expressions depend
on dynamic auxiliaries (e.g. TREND smooth stock `= input/(1+init*T)` where
`input` is a time-varying function).  Replaces the broken try/catch that
silently fell back to zeros and produced wrong TREND results.

491 integration tests + 339 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rogersamso and others added 10 commits June 28, 2026 22:21
…rate repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…elays, 143 clean models

- Handle GET DIRECT SUBSCRIPT: read subscript labels from Excel at translation
  time via ExtSubscript so multi-dimensional constants get the right shapes
- Map XMILE vmin_xmile/vmax_xmile to Julia minimum/maximum
- Lift XMILE DELAY embedded inside arithmetic expressions into dedicated
  pipeline auxiliary stocks (pending-delay queue in visitor + drain step in builder)
- Extend _all_mdl_files() to pick up uppercase .MDL and XMILE-only folders
- Copy entire model folder (not just .mdl) in TestTranslationCleanModels so
  Excel companion files are present during translation
- Grow CLEAN_MODELS from 131 to 143 models (zero-warning translations)
- Add TestXmileDelayFixed and TestXmileMinMax unit test classes
- Document all new features in docs/julia_builder.rst and whats_new.rst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…uilder

- Implement run_model(nc_data_files=[...]) for feeding another Julia model's
  NetCDF output into DATA variables: emit _nc_data_registry (identifier →
  method+ndims) and _load_nc_data! that populates _tab_data using the same
  integer-keyed scheme as the existing .tab path; 0D–3D subscripts supported
- Document four unsupported Python interop features as explicit limitations:
  step-by-step execution, mid-run parameter injection, state export/import,
  and submodel selection; added to both the comparison table and Limitations
  section of julia_builder.rst
- 7 new TDD unit tests in TestNcDataFiles (6 checked generated code structure,
  1 verified absence for models without DATA variables)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… version guard

Behavioral fixes (TDD — tests written first):
- Bug #2: 1D frozen INITIAL now emits [D(x[_i0]) ~ 0.0 for _i0 in 1:N]... instead
  of Symbolics.scalarize which was silently skipped by the ODE builder
- Bug #3: EXCEPT constant components now emit actual equations (x[idx] ~ val)
  instead of # EXCEPT: comments that Julia never executes
- Bug #4: _drain_embedded_delays now called for ndim≥2 auxiliaries; embedded
  DelayFixed nodes inside 2D+ expressions were silently lost
- Bug #5: split_views=True with backend='ode' raises ValueError; the two modes
  are structurally incompatible — updated existing tests to use backend='mtk'
- Bug #6: _with_extra_subs propagates macro_names to child visitor
- Bug SDXorg#8: Generated files now call check_compat(v"0.1.0") for PySD.jl version guard

Code cleanups (direct fix):
- Bug #1: self.lookup_identifiers (Set) passed as active_subs (Dict) positional arg;
  fixed to use keyword arg lookup_names=
- Bug #7: Remove dead WITH LOOKUP / WITH_LOOKUP entries from BUILTIN_FUNCTIONS
  (WITH LOOKUP always becomes InlineLookupsStructure, never CallStructure)
- Bug SDXorg#9: Remove unreachable :NOT:/:AND:/:OR: alternatives in _logic handler
  (upper().strip(":") already removes colons, so those strings can't match)
- Bug SDXorg#10: _convert_eq_to_assignment fallback (and _convert_ode_to_du) now raises
  ValueError instead of silently producing invalid Julia; also fixed rstrip(".]")
  bug that corrupted multi-dim for-clauses containing list ranges like [1, 2, 3]
- Bug SDXorg#11: Update stale docstring in test_2d_invert_matrix_generates_scalarize
- Bug SDXorg#12: DELAY FIXED table entry now mentions dynamic-delay fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-zero, DELAY/SMOOTH N initial order

- _compare: add space-separated control-var forms ("final time", "initial time",
  "time step") to IGNORABLE so Vensim CSV column headers match correctly
- DELAY FIXED fallback ODE: use max(delay_time, eps(Float64)) as denominator
  to prevent Inf/NaN when delay_time = 0 at t=0 (e.g. t/2), which previously
  caused OrdinaryDiffEq Euler to stop after one step
- DELAY N / SMOOTH N: add _eval_ast_at_t0() to evaluate the order expression
  at t=0 (e.g. "2 + STEP(1, 10)" → 2) instead of defaulting to 3; recurses
  through ArithmeticStructure, CallStructure, ReferenceStructure, and element
  lookup in abstract_elements with space/underscore name normalization
- delays test: ignore OutputDelayN (DELAY N with time-varying order that
  changes from 2→3 at t=10; Julia ODE uses fixed initial order, diverges after)
- All 1208 tests (382 unit + 826 integration) pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The CI was running only 27 tests (those marked @pytest.mark.julia) while
826 translation-only integration tests were never collected.  Fix:
- Run pytest_julia.py (382 unit tests) and pytest_julia_integration.py
  (826 integration tests) as separate steps; Julia is installed in the
  workflow so numerical tests run too
- Remove the -m "not julia" guard from the unit-test step (pytest_julia.py
  has no julia-marked tests, so the filter was a no-op)
- Remove the unused julia_mark variable (mark is applied directly with
  @pytest.mark.julia on the two numerical test classes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Split into two jobs: python-only tests (unit + translation, no Julia
  needed) and numerical tests (require Julia runtime)
- Add missing packages: Symbolics, NCDatasets, plus Pkg.develop for
  the PySD.jl git submodule
- Numerical job runs across Julia 1.10 and 1.11 to catch regressions
- Keeps precompilation isolated to the job that actually needs Julia,
  so the 799 translation tests are never blocked by package install time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- docker/julia-base/Dockerfile: installs and precompiles all heavy Julia
  packages (MTK, Symbolics, ODE, DataInterpolations, NCDatasets, JSON3)
  for Julia 1.10 and 1.11; PySD.jl is excluded so it can be Pkg.develop'd
  from the repo at CI time without invalidating the image
- docker-julia-base.yml: builds and pushes ghcr.io/rogersamso/pysd-julia-base
  on Dockerfile changes, weekly, or on workflow_dispatch
- julia-ci.yml: numerical job now runs inside the base container; only
  PySD.jl needs precompiling per run, dropping ~15min to ~1-2min

Note: image is currently owned by rogersamso; SDXorg maintainers can
migrate it to ghcr.io/sdxorg/pysd-julia-base after merge if desired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Create /opt/venv and put it on PATH so pip installs inside the container
work without --break-system-packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README.md: add Julia builder mention in Extensions section
- docs/installation.rst: add Julia Builder optional dependencies section
  with juliaup, Pkg.add, and PySD.jl submodule install instructions
- docs/julia_builder.rst: fix prerequisites package list (add
  OrdinaryDiffEqLowOrderRK, Symbolics, JSON3; remove XLSX)
- docs/about.rst: credit Roger Samsó and Claude Code for the Julia builder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rogersamso

Copy link
Copy Markdown
Contributor Author

Note on PySD.jl ownership

The runtime companion library (PySD.jl) currently lives at https://github.com/rogersamso/PySD.jl and is wired into this repo as a git submodule. Ideally it should be transferred to the SDXorg organisation so it is maintained alongside PySD.

I attempted the transfer but don't have permission to create repositories on SDXorg. An org admin would need to either:

  1. Grant me repo-creation rights on SDXorg so I can transfer it, or
  2. Do the transfer themselves (Settings → Transfer repository on rogersamso/PySD.jl), or
  3. Fork/mirror it under SDXorg and update the submodule URL.

After the transfer, two lines need updating:

  • .gitmodules: url = https://github.com/SDXorg/PySD.jl.git
  • docs/julia_builder.rst: the reference to https://github.com/rogersamso/PySD.jl

Happy to submit those changes once the transfer is done.

rogersamso and others added 2 commits June 29, 2026 21:21
- README.md: point Julia builder docs to local file path instead of
  readthedocs URL (page won't exist until after merge)
- docs/about.rst: remove hyperlink from "Claude Code" (claude.ai returns
  403 to link checkers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mtk.sciml.ai times out in CI; replace with docs.sciml.ai/ModelingToolkit/stable/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rogersamso

Copy link
Copy Markdown
Contributor Author

Hi @enekomartinmartinez, hope you're doing well!

I've been spending some time building a Julia backend for PySD. It's far from finished and hasn't been thoroughly tested, but it seems to work reasonably well for smaller and not-too-complex models. Thought it might be worth sharing — curious what you think if you get a chance to take a look.

@rogersamso

rogersamso commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Also wanted to mention — I used Claude Code heavily for this. I'd always wanted to contribute something like a Julia backend to the community, but it always felt out of reach given the scope. With AI assistance it was much faster, though I'll admit a bit less romantic than doing it the old-fashioned way. Still, it felt worth it if it turns out to be useful to people.

rogersamso and others added 15 commits June 29, 2026 21:45
chardet moved UniversalDetector from chardet.detector to
chardet.universaldetector in v5. Sync with the fix already on master.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_julia_mtk_available() was running 'using ModelingToolkit, OrdinaryDiffEq'
to gate the numerical tests. After Pkg.develop in CI changes the manifest,
this subprocess check could time out or fail, causing all 27 tests to skip
silently instead of running.

Numerical tests use the ODE backend and don't need ModelingToolkit at
runtime. Simplify the check to just shutil.which("julia") — if packages
are broken the tests will fail with a real error rather than skipping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The numerical batch script was loading ModelingToolkit at the top level,
which fails when the global Julia env doesn't have it (e.g. after
Pkg.develop changes the manifest in CI). All 27 numerical tests use the
ODE backend — ModelingToolkit is never needed. Remove it from _JL_PACKAGES
and drop the ModelingToolkit.getdefault() fallback from _BATCH_GET_SERIES.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pkg.develop modifies the global Julia environment manifest, which
can evict or update pre-installed packages from the Docker base image
(observed: OrdinaryDiffEq and ModelingToolkit becoming unavailable).

Instead, set JULIA_LOAD_PATH to include pysd/builders/julia so Julia
finds PySD.jl by directory without touching the installed environment.
The pre-installed packages remain intact via @v#.# in the load path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ns HOME override

GitHub Actions sets HOME=/github/home inside container jobs, which makes Julia
look for its depot at /github/home/.julia (empty mount) instead of /root/.julia
(where packages were pre-installed). Fix: set JULIA_DEPOT_PATH=/opt/julia-depot
in the Dockerfile so packages are stored outside the home directory, and pass
the same env var in CI steps so Julia finds them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tests

MTK precompilation was causing the Julia 1.11 Docker build to time out at 6h.
The CI numerical tests only use OrdinaryDiffEq/ODE packages (MTK was already
removed from _JL_PACKAGES in the test runner), so MTK in the image is redundant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GitHub Actions sets HOME=/github/home inside container: jobs, so Julia's
depot at /root/.julia (or /opt/julia-depot) is never visible at runtime.
This is a fundamental limitation of the container: directive with no clean
workaround.

Switch to the standard Julia ecosystem pattern (used by SciML, JuliaLang,
etc.): bare ubuntu-latest runner with julia-actions/setup-julia@v3 for
installation and julia-actions/cache@v3 for depot caching. Cold runs take
~15-20 min; warm-cache runs take ~3-5 min. PySD.jl is added via
JULIA_LOAD_PATH (confirmed working locally) to avoid Pkg.develop side effects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PySD.jl/Manifest.toml is pinned to Julia 1.12 with specific git-tree-sha1
hashes. When Julia loads PySD via JULIA_LOAD_PATH it reads that manifest and
looks for those exact hashes, which don't match what Pkg.add installs.

Fix: create a shared environment (pysd_ci) using Pkg.develop(PySD.jl) which
reads Project.toml (not Manifest.toml) and resolves fresh versions compatible
with the running Julia version. OrdinaryDiffEq and other test deps are added
to the same env. JULIA_PROJECT=@pysd_ci makes Julia subprocesses use this env
(inherited by the julia subprocess in _run_julia via env var).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Generated Julia model files do `using DataInterpolations` and `using NCDatasets`
at the top level. With JULIA_PROJECT=@pysd_ci, only direct deps of the pysd_ci
Project.toml are loadable — transitive deps (which these were, coming from PySD.jl)
are not. Adding them as explicit Pkg.add targets makes them direct deps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ia binary

ubuntu-latest runners have Julia pre-installed, so shutil.which("julia")
returned True even in the main CI (which has no Julia packages). Tests then
errored instead of skipping. Check that OrdinaryDiffEq is actually loadable
(inheriting JULIA_PROJECT from the environment) so tests skip gracefully when
packages are absent and run only when the full environment is ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…thiness

pandas 2.x changed missing string cell values from None to np.nan. With None,
'None or "Missing"' correctly yielded "Missing". With np.nan, the or-fallback
is bypassed (NaN is truthy), so NaN was stored in the NetCDF attribute and the
comparison 'np.float64(nan) == nan' always failed.

Fix both the write side (output.py) and the comparison side (pytest_output.py)
to use pd.isna() to detect missing values before falling back to "Missing".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…alls

On Windows, Path.read_text() defaults to the system codepage (cp1252), which
fails on models with special characters (e.g. accented letters in comments or
variable names) that appear in generated .jl files. Specifying UTF-8 explicitly
matches the encoding used when the .jl files are written.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add # pragma: no cover to unreachable/dead exception handlers
  (ExtSubscript init, float() cast, 4D+ lookups/data, GCS baked exceptions)
- Remove unused _nd_u0_entries dead method
- Mark 1-D piecewise constants path as no-cover (unreachable via routing)
- Add TestGetConstantsPiecewise1D: 5 direct-call tests covering all branches
  of _read_get_constants_piecewise 1-D path (was 51 uncovered lines)
- Add 4 tests in TestMacroSupportCoverage: macro-with-stock ODE placeholder,
  const-only macro bare return, MTK macro + DataInterpolations, MTK + JSON3
- Add 4 tests in TestCoverageGaps: bare lookup ref scalar/subscripted context,
  SUM with bare bang-subscript ref, SUM with ArithmeticStructure bang args
- Add namespace.py empty-after-sanitization test → 100% namespace coverage
- Net unit-test improvement: ~425 miss vs ~493 before (-68 lines)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds targeted unit tests and `# pragma: no cover` annotations to bring
julia_model_builder.py and julia_expressions_builder.py from ~98% to 99%
coverage, eliminating the negative Coveralls delta from the PR.

Key additions:
- Fix subscripted inline lookup tests (2D placeholder, unknown-label fallback)
  to use 2+ components so routing hits _process_subscripted_inline_lookup
- Tests for json+MTK backend @register_symbolic in _lookup_block (lines 4077/4087)
- Tests for 2D and 3D EXCEPT continue when all covered rows are excluded (lines 1619/1736)
- Test for _materialize_input name-collision counter loop (lines 1794-1795)
- Test for _build_invert_matrix_equations is_control early return (line 1335)
- Test for SmoothN dynamic order resolved via _eval_ast_at_t0 (lines 1053-1055)
- Tests for numpy ndarray stock initial and control-element early return (lines 986-987, 1233, 1271)
- Test for _read_get_constants_piecewise_nd _comp_idx_arrays branches (lines 3623, 3629-3632)
- pragma: no cover on two defensive except blocks (numpy import failure) and two
  gcs_comps coord-building branches that require actual Excel files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant