diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 53128af7c..b4d8990ed 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -49,7 +49,13 @@ jobs: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - uses: julia-actions/cache@v3 - - uses: julia-actions/julia-buildpkg@v1 + - name: Build package + run: | + julia --project=. -e ' + using Pkg + Pkg.add(name="BaseModelica", rev="main") + Pkg.instantiate() + ' - uses: julia-actions/julia-runtest@v1 sanity: @@ -82,7 +88,13 @@ jobs: version: '1.12' arch: x64 - uses: julia-actions/cache@v3 - - uses: julia-actions/julia-buildpkg@v1 + - name: Build package + run: | + julia --project=. -e ' + using Pkg + Pkg.add(name="BaseModelica", rev="main") + Pkg.instantiate() + ' - name: Sanity check ChuaCircuit run: | diff --git a/.github/workflows/msl-test.yml b/.github/workflows/msl-test.yml index 001fb493d..66ad9bc34 100644 --- a/.github/workflows/msl-test.yml +++ b/.github/workflows/msl-test.yml @@ -36,6 +36,11 @@ on: required: false default: '^(?!Modelica\.Clocked)' type: string + solver: + description: 'ODE solver algorithm (any DifferentialEquations.jl algorithm name, e.g. Rodas5P, FBDF)' + required: false + default: 'Rodas5P' + type: string concurrency: group: pages-${{ inputs.library || 'Modelica' }}-${{ inputs.lib_version || '4.1.0' }}-${{ inputs.bm_version || 'main' }} @@ -58,6 +63,7 @@ jobs: BM_VERSION_INPUT: ${{ inputs.bm_version || 'main' }} BM_OPTIONS: ${{ inputs.bm_options || 'scalarize,moveBindings,inlineFunctions' }} FILTER: ${{ inputs.filter || '^(?!Modelica\.Clocked)' }} + SOLVER: ${{ inputs.solver || 'Rodas5P' }} steps: - name: Checkout source @@ -123,6 +129,9 @@ jobs: run: | julia --project=. -e ' using BaseModelicaLibraryTesting + using DifferentialEquations + solver_name = get(ENV, "SOLVER", "Rodas5P") + configure_simulate!(solver = getproperty(DifferentialEquations, Symbol(solver_name))()) filter_str = get(ENV, "FILTER", "") main( library = ENV["LIB_NAME"], diff --git a/Project.toml b/Project.toml index 75a73180f..21560b138 100644 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ authors = ["AnHeuermann"] BaseModelica = "a17d5099-185d-4ff5-b5d3-51aa4569e56d" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DifferentialEquations = "0c46a032-eb83-5123-abaf-570d42b7fbaa" +LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78" OMJulia = "0f4fe800-344e-11e9-2949-fb537ad918e1" diff --git a/README.md b/README.md index 6468562b1..19de3fd2c 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,55 @@ main( Preview the generated HTML report at `main/Modelica/4.1.0/report.html`. +### Changing the ODE Solver + +By default the simulation uses `Rodas5P()`. To switch to a different solver, +call `configure_simulate!` before `main`: + +```julia +using BaseModelicaLibraryTesting +using DifferentialEquations + +configure_simulate!(solver = FBDF()) + +main( + library = "Modelica", + version = "4.1.0", + omc_exe = "omc", + ref_root = "MAP-LIB_ReferenceResults" +) +``` + +Any SciML-compatible ODE/DAE algorithm (e.g. `QNDF()`, `Rodas4()`) can be +passed to `solver`. + ```bash python -m http.server -d results/main/Modelica/4.1.0/ ``` +## GitHub Actions — Manual MSL Test + +The [MSL Test & GitHub Pages][msl-action-url] workflow runs automatically every +day at 03:00 UTC. It can also be triggered manually from the GitHub Actions UI: + +1. Go to **Actions → MSL Test & GitHub Pages** +2. Click **Run workflow** +3. Fill in the options and click **Run workflow** + +The following inputs are available: + +| Input | Default | Description | +| ----- | ------- | ----------- | +| `library` | `Modelica` | Modelica library name | +| `lib_version` | `4.1.0` | Library version to test | +| `bm_version` | `main` | BaseModelica.jl branch, tag, or version | +| `bm_options` | `scalarize,moveBindings,inlineFunctions` | Comma-separated `--baseModelicaOptions` passed to OpenModelica during Base Modelica export | +| `filter` | `^(?!Modelica\.Clocked)` | Julia regex to restrict which models are tested (empty string runs all models) | +| `solver` | `Rodas5P` | Any `DifferentialEquations.jl` algorithm name (e.g. `Rodas5P`, `Rodas5Pr`, `FBDF`) | + +Results are published to [GitHub Pages][msl-pages-url] under +`results////`. + ## License This package is available under the [OSMC-PL License][osmc-license-file] and the @@ -68,6 +113,7 @@ file for details. [build-badge-svg]: https://github.com/OpenModelica/BaseModelicaLibraryTesting.jl/actions/workflows/CI.yml/badge.svg?branch=main [build-action-url]: https://github.com/OpenModelica/BaseModelicaLibraryTesting.jl/actions/workflows/CI.yml?query=branch%3Amain [msl-badge-svg]: https://github.com/OpenModelica/BaseModelicaLibraryTesting.jl/actions/workflows/msl-test.yml/badge.svg?branch=main +[msl-action-url]: https://github.com/OpenModelica/BaseModelicaLibraryTesting.jl/actions/workflows/msl-test.yml [msl-pages-url]: https://openmodelica.github.io/BaseModelicaLibraryTesting.jl/ [openmodelica-url]: https://openmodelica.org/ [basemodelicajl-url]: https://github.com/SciML/BaseModelica.jl diff --git a/src/BaseModelicaLibraryTesting.jl b/src/BaseModelicaLibraryTesting.jl index 7610640fa..5921161b6 100644 --- a/src/BaseModelicaLibraryTesting.jl +++ b/src/BaseModelicaLibraryTesting.jl @@ -4,7 +4,7 @@ import Pkg import OMJulia import OMJulia: sendExpression import BaseModelica -import DifferentialEquations: solve, Rodas5P, ReturnCode +import DifferentialEquations import ModelingToolkit import Dates: now import Printf: @sprintf @@ -21,11 +21,12 @@ include("pipeline.jl") # ── Public API ───────────────────────────────────────────────────────────────── # Shared types and constants -export ModelResult, CompareSettings, RunInfo +export ModelResult, CompareSettings, SimulateSettings, RunInfo export LIBRARY, LIBRARY_VERSION, CMP_REL_TOL, CMP_ABS_TOL # Comparison configuration export configure_comparison!, compare_settings +export configure_simulate!, simulate_settings # Pipeline phases export run_export # Phase 1: Base Modelica export via OMC diff --git a/src/compare.jl b/src/compare.jl index 5cf987b69..d9b07592f 100644 --- a/src/compare.jl +++ b/src/compare.jl @@ -368,6 +368,8 @@ function compare_with_reference( signals::Vector{String} = String[], )::Tuple{Int,Int,Int,String} + isdir(model_dir) || mkpath(model_dir) + times, ref_data = _read_ref_csv(ref_csv_path) isempty(times) && return 0, 0, 0, "" diff --git a/src/parse_bm.jl b/src/parse_bm.jl index e49d40113..dcaa2c935 100644 --- a/src/parse_bm.jl +++ b/src/parse_bm.jl @@ -18,6 +18,7 @@ function run_parse(bm_path::String, model_dir::String, parse_error = "" ode_prob = nothing + isdir(model_dir) || mkpath(model_dir) log_file = open(joinpath(model_dir, "$(model)_parsing.log"), "w") stdout_pipe = Pipe() println(log_file, "Model: $model") diff --git a/src/pipeline.jl b/src/pipeline.jl index 4a1815b58..b253f61aa 100644 --- a/src/pipeline.jl +++ b/src/pipeline.jl @@ -57,8 +57,12 @@ end Run the four-phase pipeline for a single model and return its result. """ -function test_model(omc::OMJulia.OMCSession, model::String, results_root::String, - ref_root::String; csv_max_size_mb::Int = CSV_MAX_SIZE_MB)::ModelResult +function test_model(omc::OMJulia.OMCSession, + model::String, + results_root::String, + ref_root::String; + sim_settings ::SimulateSettings = _SIM_SETTINGS, + csv_max_size_mb::Int = CSV_MAX_SIZE_MB)::ModelResult model_dir = joinpath(results_root, "files", model) mkpath(model_dir) @@ -93,6 +97,7 @@ function test_model(omc::OMJulia.OMCSession, model::String, results_root::String # Phase 3 ────────────────────────────────────────────────────────────────── sim_ok, sim_t, sim_err, sol = run_simulate(ode_prob, model_dir, model; + settings = sim_settings, csv_max_size_mb, cmp_signals) # Phase 4 (optional) ─────────────────────────────────────────────────────── @@ -132,6 +137,7 @@ function main(; results_root :: String = "", ref_root :: String = get(ENV, "MAPLIB_REF", ""), bm_options :: String = get(ENV, "BM_OPTIONS", "scalarize,moveBindings,inlineFunctions"), + sim_settings :: SimulateSettings = _SIM_SETTINGS, csv_max_size_mb :: Int = CSV_MAX_SIZE_MB, ) t0 = time() @@ -200,7 +206,7 @@ function main(; for (i, model) in enumerate(models) @info "[$i/$(length(models))] $model" - result = test_model(omc, model, results_root, ref_root; csv_max_size_mb) + result = test_model(omc, model, results_root, ref_root; sim_settings, csv_max_size_mb) push!(results, result) phase = if result.sim_success && result.cmp_total > 0 @@ -245,6 +251,9 @@ function main(; length(cpu_info), Sys.total_memory() / 1024^3, time() - t0, + let s = _SIM_SETTINGS.solver + "$(parentmodule(typeof(s))).$(nameof(typeof(s)))" + end, ) generate_report(results, results_root, info; csv_max_size_mb) diff --git a/src/report.jl b/src/report.jl index 008ffc4f6..8ce87a243 100644 --- a/src/report.jl +++ b/src/report.jl @@ -159,6 +159,7 @@ function generate_report(results::Vector{ModelResult}, results_root::String, OpenModelica: $(info.omc_version)
OMC options: $(info.omc_options)
BaseModelica.jl: $(basemodelica_jl_version)
+Solver: $(info.solver)
Filter: $(var_filter)
Reference results: $(ref_results)

CPU: $(info.cpu_model) ($(info.cpu_threads) threads)
diff --git a/src/simulate.jl b/src/simulate.jl index fe6723aa4..c1f80fc79 100644 --- a/src/simulate.jl +++ b/src/simulate.jl @@ -1,14 +1,49 @@ # ── Phase 3: ODE simulation with DifferentialEquations / MTK ────────────────── -import DifferentialEquations: solve, Rodas5P, ReturnCode +import DifferentialEquations import Logging import ModelingToolkit import Printf: @sprintf +"""Module-level default simulation settings. Modify via `configure_simulate!`.""" +const _SIM_SETTINGS = SimulateSettings(solver = DifferentialEquations.Rodas5P()) + +""" + configure_simulate!(; solver, saveat_n) → SimulateSettings + +Update the module-level simulation settings in-place and return them. + +# Keyword arguments +- `solver` — any SciML ODE/DAE algorithm instance (e.g. `Rodas5P`, `FBDF()`). +- `saveat_n` — number of uniform time points for purely algebraic systems. + +# Example + +```julia +using OrdinaryDiffEqBDF +configure_simulate!(solver = FBDF()) +``` +""" +function configure_simulate!(; + solver :: Union{Any,Nothing} = nothing, + saveat_n :: Union{Int,Nothing} = nothing, +) + isnothing(solver) || (_SIM_SETTINGS.solver = solver) + isnothing(saveat_n) || (_SIM_SETTINGS.saveat_n = saveat_n) + return _SIM_SETTINGS +end + +""" + simulate_settings() → SimulateSettings + +Return the current module-level simulation settings. """ - run_simulate(ode_prob, model_dir, model; cmp_signals, csv_max_size_mb) → (success, time, error, sol) +simulate_settings() = _SIM_SETTINGS -Solve `ode_prob` with Rodas5P (stiff solver). On success, also writes the +""" + run_simulate(ode_prob, model_dir, model; settings, cmp_signals, csv_max_size_mb) → (success, time, error, sol) + +Solve `ode_prob` using the algorithm in `settings.solver`. On success, also writes the solution as a CSV file `_sim.csv` in `model_dir`. Writes a `_sim.log` file in `model_dir`. Returns `nothing` as the fourth element on failure. @@ -20,21 +55,26 @@ of signals will be compared. CSV files larger than `csv_max_size_mb` MiB are replaced with a `_sim.csv.toobig` marker so that the report can note the omission. """ -function run_simulate(ode_prob, model_dir::String, +function run_simulate(ode_prob, + model_dir::String, model::String; - cmp_signals ::Vector{String} = String[], - csv_max_size_mb::Int = CSV_MAX_SIZE_MB)::Tuple{Bool,Float64,String,Any} - sim_success = false - sim_time = 0.0 - sim_error = "" - sol = nothing + settings ::SimulateSettings = _SIM_SETTINGS, + cmp_signals ::Vector{String} = String[], + csv_max_size_mb::Int = CSV_MAX_SIZE_MB)::Tuple{Bool,Float64,String,Any} + + sim_success = false + sim_time = 0.0 + sim_error = "" + sol = nothing + solver_settings_string = "" log_file = open(joinpath(model_dir, "$(model)_sim.log"), "w") println(log_file, "Model: $model") logger = Logging.SimpleLogger(log_file, Logging.Debug) t0 = time() + + solver = settings.solver try - # Rodas5P handles stiff DAE-like systems well. # Redirect all library log output (including Symbolics/MTK warnings) # to the log file so they don't clutter stdout. sol = Logging.with_logger(logger) do @@ -42,14 +82,47 @@ function run_simulate(ode_prob, model_dir::String, # For stateless models (no unknowns) the adaptive solver takes no # internal steps and sol.t would be empty with saveat=[]. # Supply explicit time points so observed variables can be evaluated. - sys = ode_prob.f.sys - saveat = isempty(ModelingToolkit.unknowns(sys)) ? - collect(range(ode_prob.tspan[1], ode_prob.tspan[end]; length = 500)) : - Float64[] - solve(ode_prob, Rodas5P(); saveat = saveat, dense = true) + sys = ode_prob.f.sys + n_unknowns = length(ModelingToolkit.unknowns(sys)) + + kwargs = if n_unknowns == 0 + # No unknowns at all (e.g. BusUsage): + # Supply explicit time points so observed variables can be evaluated. + saveat_s = collect(range(ode_prob.tspan[1], ode_prob.tspan[end]; length = settings.saveat_n)) + (saveat = saveat_s, dense = true) + else + (saveat = Float64[], dense = true) + end + + # Log solver settings — init returns NullODEIntegrator (no .opts) + # when the problem has no unknowns (u::Nothing), so only inspect + # opts when a real integrator is returned. + # Use our own `saveat` vector for the log: integ.opts.saveat is a + # BinaryHeap which does not support iterate/minimum/maximum. + integ = DifferentialEquations.init(ode_prob, solver; kwargs...) + saveat_s = kwargs.saveat + solver_settings_string = if hasproperty(integ, :opts) + sv_str = isempty(saveat_s) ? "[]" : "$(length(saveat_s)) points in [$(first(saveat_s)), $(last(saveat_s))]" + """ + Solver $(parentmodule(typeof(solver))).$(nameof(typeof(solver))) + saveat: $sv_str + abstol: $(@sprintf("%.2e", integ.opts.abstol)) + reltol: $(@sprintf("%.2e", integ.opts.reltol)) + adaptive: $(integ.opts.adaptive) + dense: $(integ.opts.dense) + """ + else + sv_str = isempty(saveat_s) ? "[]" : "$(length(saveat_s)) points in [$(first(saveat_s)), $(last(saveat_s))]" + "Solver (NullODEIntegrator — no unknowns) + saveat: $sv_str + dense: true" + end + + # Solve + DifferentialEquations.solve(ode_prob, solver; kwargs...) end sim_time = time() - t0 - if sol.retcode == ReturnCode.Success + if sol.retcode == DifferentialEquations.ReturnCode.Success sys = sol.prob.f.sys n_vars = length(ModelingToolkit.unknowns(sys)) n_obs = length(ModelingToolkit.observed(sys)) @@ -67,7 +140,8 @@ function run_simulate(ode_prob, model_dir::String, sim_time = time() - t0 sim_error = sprint(showerror, e, catch_backtrace()) end - println(log_file, "Time: $(round(sim_time; digits=3)) s") + println(log_file, solver_settings_string) + println(log_file, "Time: $(round(sim_time; digits=3)) s") println(log_file, "Success: $sim_success") isempty(sim_error) || println(log_file, "\n--- Error ---\n$sim_error") close(log_file) diff --git a/src/summary.jl b/src/summary.jl index 53ba785ef..847a9c8e7 100644 --- a/src/summary.jl +++ b/src/summary.jl @@ -33,6 +33,7 @@ function write_summary( print(io, " \"cpu_threads\": $(info.cpu_threads),\n") print(io, " \"ram_gb\": $(@sprintf "%.2f" info.ram_gb),\n") print(io, " \"total_time_s\": $(@sprintf "%.2f" info.total_time_s),\n") + print(io, " \"solver\": \"$(_esc_json(info.solver))\",\n") print(io, " \"models\": [\n") for (i, r) in enumerate(results) sep = i < length(results) ? "," : "" @@ -74,6 +75,7 @@ Parsed contents of a single `summary.json` file. - `cpu_threads` — number of logical CPU threads - `ram_gb` — total system RAM in GiB - `total_time_s` — wall-clock duration of the full test run in seconds +- `solver` — fully-qualified solver name, e.g. `"DifferentialEquations.Rodas5P"` - `models` — vector of per-model dicts; each has keys `"name"`, `"export"`, `"parse"`, `"sim"`, `"cmp_total"`, `"cmp_pass"` """ @@ -92,6 +94,7 @@ struct RunSummary cpu_threads :: Int ram_gb :: Float64 total_time_s :: Float64 + solver :: String models :: Vector{Dict{String,Any}} end @@ -150,6 +153,7 @@ function load_summary(results_root::String)::Union{RunSummary,Nothing} _int("cpu_threads"), _float("ram_gb"), _float("total_time_s"), + _str("solver"), models, ) end diff --git a/src/types.jl b/src/types.jl index b073c7843..18704c5ae 100644 --- a/src/types.jl +++ b/src/types.jl @@ -37,6 +37,25 @@ Base.@kwdef mutable struct CompareSettings error_fn :: Symbol = :mixed end +# ── Simulation settings ──────────────────────────────────────────────────────── + +""" + SimulateSettings + +Mutable configuration struct for ODE simulation. + +# Fields +- `solver` — any SciML ODE/DAE algorithm instance. Default: `nothing`, + resolved to `Rodas5P()` when the module-level singleton is + constructed in `simulate.jl`. +- `saveat_n` — number of evenly-spaced time points used for purely algebraic + systems (all mass-matrix rows zero). Default: `500`. +""" +Base.@kwdef mutable struct SimulateSettings + solver :: Any = nothing + saveat_n :: Int = 500 +end + # ── Run metadata ─────────────────────────────────────────────────────────────── """ @@ -60,6 +79,7 @@ Metadata about a single test run, collected by `main()` and written into both - `cpu_threads` — number of logical CPU threads - `ram_gb` — total system RAM in GiB - `total_time_s` — wall-clock duration of the full test run in seconds +- `solver` — fully-qualified solver name, e.g. `"DifferentialEquations.Rodas5P"` """ struct RunInfo library :: String @@ -76,6 +96,7 @@ struct RunInfo cpu_threads :: Int ram_gb :: Float64 total_time_s :: Float64 + solver :: String end # ── Result type ──────────────────────────────────────────────────────────────── diff --git a/test/chua_circuit.jl b/test/chua_circuit.jl new file mode 100644 index 000000000..1f3ffcbcb --- /dev/null +++ b/test/chua_circuit.jl @@ -0,0 +1,31 @@ +@testset "ChuaCircuit pipeline" begin + tmpdir = mktempdir() + model_dir = joinpath(tmpdir, "files", TEST_MODEL_CHUA) + mkpath(model_dir) + bm_path = replace(abspath(joinpath(model_dir, "$TEST_MODEL_CHUA.bmo")), "\\" => "/") + + omc = OMJulia.OMCSession(TEST_OMC) + try + OMJulia.sendExpression(omc, """setCommandLineOptions("--baseModelica --baseModelicaOptions=scalarize,moveBindings -d=evaluateAllParameters")""") + ok = OMJulia.sendExpression(omc, """loadModel(Modelica, {"4.1.0"})""") + @test ok == true + + exp_ok, _, exp_err = run_export(omc, TEST_MODEL_CHUA, model_dir, bm_path) + @test exp_ok + exp_ok || @warn "Export error: $exp_err" + + if exp_ok + par_ok, _, par_err, ode_prob = run_parse(bm_path, model_dir, TEST_MODEL_CHUA) + @test par_ok + par_ok || @warn "Parse error: $par_err" + + if par_ok + sim_ok, _, sim_err, _ = run_simulate(ode_prob, model_dir, TEST_MODEL_CHUA) + @test sim_ok + sim_ok || @warn "Simulation error: $sim_err" + end + end + finally + OMJulia.quit(omc) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index e5b06798f..f542a7d1f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,11 +1,13 @@ """ Tests for the BaseModelicaLibraryTesting package. -Sections: - 1. Unit tests — pure helper functions, no OMC or simulation needed. - 2. Integration — full pipeline for Modelica.Electrical.Analog.Examples.ChuaCircuit. +Files: + unit_helpers.jl — pure helper functions, no OMC or simulation needed + chua_circuit.jl — full pipeline for ChuaCircuit (requires OMC) + bus_usage.jl — parse+simulate from fixture .bmo (no OMC) + amplifier_with_op_amp.jl — parse+simulate+verify from fixture .bmo (no OMC) -Run from the julia/ directory: +Run from the project directory: julia --project=. test/runtests.jl Or via Pkg: @@ -15,126 +17,16 @@ Environment variables: OMC_EXE Path to the omc binary (default: system PATH) """ -import Test: @test, @testset +import Test: @test, @testset, @test_broken import OMJulia import BaseModelicaLibraryTesting: run_export, run_parse, run_simulate, + compare_with_reference, _clean_var_name, _normalize_var, _ref_csv_path, _read_ref_csv -# ── 1. Unit tests ────────────────────────────────────────────────────────────── +const FIXTURES = joinpath(@__DIR__, "fixtures") +const TEST_OMC = get(ENV, "OMC_EXE", "omc") +const TEST_MODEL_CHUA = "Modelica.Electrical.Analog.Examples.ChuaCircuit" -@testset "Unit tests" begin - - @testset "_clean_var_name" begin - # Standard MTK form: var"name"(t) - @test _clean_var_name("var\"C1.v\"(t)") == "C1.v" - # Without (t) - @test _clean_var_name("var\"C1.v\"") == "C1.v" - # Plain name with (t) suffix - @test _clean_var_name("C1.v(t)") == "C1.v" - # Plain name, no annotation - @test _clean_var_name("x") == "x" - # Leading/trailing whitespace is stripped - @test _clean_var_name(" foo(t) ") == "foo" - # ₊ hierarchy separator is preserved (it is the job of _normalize_var) - @test _clean_var_name("var\"C1₊v\"(t)") == "C1₊v" - end - - @testset "_normalize_var" begin - # Reference-CSV side: plain dot-separated name - @test _normalize_var("C1.v") == "c1.v" - @test _normalize_var("L.i") == "l.i" - # MTK side with ₊ hierarchy separator and (t) annotation - @test _normalize_var("C1₊v(t)") == "c1.v" - # MTK side with var"..." quoting - @test _normalize_var("var\"C1₊v\"(t)") == "c1.v" - # Already normalized input - @test _normalize_var("c1.v") == "c1.v" - # Multi-level hierarchy - @test _normalize_var("a₊b₊c(t)") == "a.b.c" - end - - @testset "_ref_csv_path" begin - mktempdir() do dir - model = "Modelica.Electrical.Analog.Examples.ChuaCircuit" - csv_dir = joinpath(dir, "Modelica", "Electrical", "Analog", - "Examples", "ChuaCircuit") - mkpath(csv_dir) - csv_file = joinpath(csv_dir, "ChuaCircuit.csv") - write(csv_file, "") - @test _ref_csv_path(dir, model) == csv_file - @test _ref_csv_path(dir, "Modelica.NotExisting") === nothing - end - end - - @testset "_read_ref_csv" begin - mktempdir() do dir - csv = joinpath(dir, "test.csv") - - # Quoted headers (MAP-LIB format) - write(csv, "\"time\",\"C1.v\",\"L.i\"\n0,4,0\n0.5,3.5,0.1\n1,3.0,0.2\n") - times, data = _read_ref_csv(csv) - @test times ≈ [0.0, 0.5, 1.0] - @test data["C1.v"] ≈ [4.0, 3.5, 3.0] - @test data["L.i"] ≈ [0.0, 0.1, 0.2] - @test !haskey(data, "\"time\"") # quotes must be stripped from keys - - # Unquoted headers - write(csv, "time,x,y\n0,1,2\n1,3,4\n") - times2, data2 = _read_ref_csv(csv) - @test times2 ≈ [0.0, 1.0] - @test data2["x"] ≈ [1.0, 3.0] - @test data2["y"] ≈ [2.0, 4.0] - - # Empty file → empty collections - write(csv, "") - t0, d0 = _read_ref_csv(csv) - @test isempty(t0) - @test isempty(d0) - - # Blank lines between data rows are ignored - write(csv, "time,v\n0,1\n\n1,2\n\n") - times3, data3 = _read_ref_csv(csv) - @test times3 ≈ [0.0, 1.0] - @test data3["v"] ≈ [1.0, 2.0] - end - end - -end # "Unit tests" - -# ── 2. Integration test ──────────────────────────────────────────────────────── - -const TEST_MODEL = "Modelica.Electrical.Analog.Examples.ChuaCircuit" -const TEST_OMC = get(ENV, "OMC_EXE", "omc") - -@testset "ChuaCircuit pipeline" begin - tmpdir = mktempdir() - model_dir = joinpath(tmpdir, "files", TEST_MODEL) - mkpath(model_dir) - bm_path = replace(abspath(joinpath(model_dir, "$TEST_MODEL.bmo")), "\\" => "/") - - omc = OMJulia.OMCSession(TEST_OMC) - try - OMJulia.sendExpression(omc, """setCommandLineOptions("--baseModelica --baseModelicaOptions=scalarize,moveBindings -d=evaluateAllParameters")""") - ok = OMJulia.sendExpression(omc, """loadModel(Modelica, {"4.1.0"})""") - @test ok == true - - exp_ok, _, exp_err = run_export(omc, TEST_MODEL, model_dir, bm_path) - @test exp_ok - exp_ok || @warn "Export error: $exp_err" - - if exp_ok - par_ok, _, par_err, ode_prob = run_parse(bm_path, model_dir, TEST_MODEL) - @test par_ok - par_ok || @warn "Parse error: $par_err" - - if par_ok - sim_ok, _, sim_err, _ = run_simulate(ode_prob, model_dir, TEST_MODEL) - @test sim_ok - sim_ok || @warn "Simulation error: $sim_err" - end - end - finally - OMJulia.quit(omc) - end -end +include("unit_helpers.jl") +include("chua_circuit.jl") diff --git a/test/unit_helpers.jl b/test/unit_helpers.jl new file mode 100644 index 000000000..2a8e71f94 --- /dev/null +++ b/test/unit_helpers.jl @@ -0,0 +1,78 @@ +@testset "Unit tests" begin + + @testset "_clean_var_name" begin + # Standard MTK form: var"name"(t) + @test _clean_var_name("var\"C1.v\"(t)") == "C1.v" + # Without (t) + @test _clean_var_name("var\"C1.v\"") == "C1.v" + # Plain name with (t) suffix + @test _clean_var_name("C1.v(t)") == "C1.v" + # Plain name, no annotation + @test _clean_var_name("x") == "x" + # Leading/trailing whitespace is stripped + @test _clean_var_name(" foo(t) ") == "foo" + # ₊ hierarchy separator is preserved (it is the job of _normalize_var) + @test _clean_var_name("var\"C1₊v\"(t)") == "C1₊v" + end + + @testset "_normalize_var" begin + # Reference-CSV side: plain dot-separated name + @test _normalize_var("C1.v") == "c1.v" + @test _normalize_var("L.i") == "l.i" + # MTK side with ₊ hierarchy separator and (t) annotation + @test _normalize_var("C1₊v(t)") == "c1.v" + # MTK side with var"..." quoting + @test _normalize_var("var\"C1₊v\"(t)") == "c1.v" + # Already normalized input + @test _normalize_var("c1.v") == "c1.v" + # Multi-level hierarchy + @test _normalize_var("a₊b₊c(t)") == "a.b.c" + end + + @testset "_ref_csv_path" begin + mktempdir() do dir + model = "Modelica.Electrical.Analog.Examples.ChuaCircuit" + csv_dir = joinpath(dir, "Modelica", "Electrical", "Analog", + "Examples", "ChuaCircuit") + mkpath(csv_dir) + csv_file = joinpath(csv_dir, "ChuaCircuit.csv") + write(csv_file, "") + @test _ref_csv_path(dir, model) == csv_file + @test _ref_csv_path(dir, "Modelica.NotExisting") === nothing + end + end + + @testset "_read_ref_csv" begin + mktempdir() do dir + csv = joinpath(dir, "test.csv") + + # Quoted headers (MAP-LIB format) + write(csv, "\"time\",\"C1.v\",\"L.i\"\n0,4,0\n0.5,3.5,0.1\n1,3.0,0.2\n") + times, data = _read_ref_csv(csv) + @test times ≈ [0.0, 0.5, 1.0] + @test data["C1.v"] ≈ [4.0, 3.5, 3.0] + @test data["L.i"] ≈ [0.0, 0.1, 0.2] + @test !haskey(data, "\"time\"") # quotes must be stripped from keys + + # Unquoted headers + write(csv, "time,x,y\n0,1,2\n1,3,4\n") + times2, data2 = _read_ref_csv(csv) + @test times2 ≈ [0.0, 1.0] + @test data2["x"] ≈ [1.0, 3.0] + @test data2["y"] ≈ [2.0, 4.0] + + # Empty file → empty collections + write(csv, "") + t0, d0 = _read_ref_csv(csv) + @test isempty(t0) + @test isempty(d0) + + # Blank lines between data rows are ignored + write(csv, "time,v\n0,1\n\n1,2\n\n") + times3, data3 = _read_ref_csv(csv) + @test times3 ≈ [0.0, 1.0] + @test data3["v"] ≈ [1.0, 2.0] + end + end + +end # "Unit tests"