diff --git a/Cargo.lock b/Cargo.lock index 6913f607a..a404813c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.0" @@ -15,6 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.3", "once_cell", "version_check", "zerocopy", @@ -155,6 +165,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -364,10 +389,34 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "ts-rs", + "ts-rs 11.1.0", "uuid", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.21.7" @@ -520,7 +569,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "test-strategy", - "ts-rs", + "ts-rs 11.1.0", "tsify", "ustr", "uuid", @@ -553,6 +602,7 @@ dependencies = [ "pretty", "rebop", "ref-cast", + "rumoca", "scopeguard", "sea-query", "serde", @@ -619,6 +669,42 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chksum-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475dc9fa905fe8feca5c4a48a97f65503ebd257a7566df1367d080083e160c6" +dependencies = [ + "chksum-hash-core", + "thiserror 1.0.69", +] + +[[package]] +name = "chksum-hash-core" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221456234d441c788a2c51a27b91c4380f499de560670a67d3303e621d37b3bd" + +[[package]] +name = "chksum-hash-md5" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8445e9efb2556cedf4ef2bf704cb335cdd037ed20fe954f281baf93fe5e7c0d" +dependencies = [ + "chksum-hash-core", + "thiserror 1.0.69", +] + +[[package]] +name = "chksum-md5" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda0016624d188e791afdf491de89d0157411dafedb0a46dbcd3f1a13ef0a611" +dependencies = [ + "chksum-core", + "chksum-hash-md5", +] + [[package]] name = "chrono" version = "0.4.41" @@ -683,6 +769,17 @@ dependencies = [ "cc", ] +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.1.14", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -915,6 +1012,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.101", ] @@ -992,6 +1090,37 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.101", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -1012,6 +1141,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1052,6 +1187,12 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8975ffdaa0ef3661bfe02dbdcc06c9f829dfafe6a3c474de366a8d5e44276921" +[[package]] +name = "dot-writer" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2f7a508d3f95b7cb559acf2231c7efad02fe04061d3165b12513c2dbcc77af0" + [[package]] name = "dotenvy" version = "0.15.7" @@ -1110,7 +1251,7 @@ dependencies = [ "im-rc", "indexmap", "log", - "num", + "num 0.4.3", "ordered-float", "rayon", "rustc-hash", @@ -1153,10 +1294,10 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "num-rational", + "num-rational 0.4.2", "once_cell", "ordered-float", - "petgraph", + "petgraph 0.8.3", "rayon", "smallvec", "thiserror 2.0.12", @@ -1191,13 +1332,13 @@ dependencies = [ "egglog-numeric-id", "egglog-reports", "egglog-union-find", - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.16.1", "indexmap", "log", - "num", + "num 0.4.3", "once_cell", - "petgraph", + "petgraph 0.8.3", "rand 0.9.2", "rayon", "rustc-hash", @@ -1276,6 +1417,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1371,6 +1535,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1468,6 +1638,21 @@ dependencies = [ "libc", ] +[[package]] +name = "function_name" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ab577a896d09940b5fe12ec5ae71f9d8211fff62c919c03a3750a9901e98a7" +dependencies = [ + "function_name-proc-macro", +] + +[[package]] +name = "function_name-proc-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673464e1e314dd67a0fd9544abc99e8eb28d0c7e3b69b033bcff9b2d00b87333" + [[package]] name = "futures" version = "0.3.31" @@ -1611,6 +1796,32 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -1624,6 +1835,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "graph-cycles" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6ad932c6dd3cfaf16b66754a42f87bbeefd591530c4b6a8334270a7df3e853" +dependencies = [ + "ahash", + "petgraph 0.6.5", + "thiserror 1.0.69", +] + [[package]] name = "h2" version = "0.3.26" @@ -1713,6 +1935,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2099,6 +2327,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2154,6 +2384,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2175,6 +2411,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "jni" version = "0.21.1" @@ -2418,6 +2678,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lalry" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b5cf5d262f1b646b4b4fc864c6026753dab5ba0ea9d94ec1589509d12175ea" + [[package]] name = "lazy_static" version = "1.5.0" @@ -2550,6 +2816,42 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "migrator" version = "0.1.0" @@ -2615,6 +2917,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "minijinja" +version = "2.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d" +dependencies = [ + "memo-map", + "serde", + "serde_json", +] + [[package]] name = "miniz_oxide" version = "0.8.8" @@ -2646,8 +2959,8 @@ dependencies = [ "approx", "matrixmultiply", "nalgebra-macros", - "num-complex", - "num-rational", + "num-complex 0.4.6", + "num-rational 0.4.2", "num-traits", "simba", "typenum", @@ -2746,17 +3059,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7a8e9be5e039e2ff869df49155f1c06bd01ade2117ec783e56ab0932b67a8f" +dependencies = [ + "num-bigint 0.3.3", + "num-complex 0.3.1", + "num-integer", + "num-iter", + "num-rational 0.3.2", + "num-traits", +] + [[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", - "num-complex", + "num-bigint 0.4.6", + "num-complex 0.4.6", "num-integer", "num-iter", - "num-rational", + "num-rational 0.4.2", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" +dependencies = [ + "autocfg", + "num-integer", "num-traits", ] @@ -2787,6 +3125,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747d632c0c558b87dbabbe6a82f3b4ae03720d0646ac5b7b4dae89394be5f2c5" +dependencies = [ + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2822,13 +3169,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07" +dependencies = [ + "autocfg", + "num-bigint 0.3.3", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", ] @@ -2843,6 +3202,25 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "ode_solvers" version = "0.6.1" @@ -2927,6 +3305,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "parking" version = "2.2.1" @@ -2956,6 +3340,71 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parol" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e7b73dba2c72e5192f8e3d1a87459890538fa330d3c16bf6cb71522a71d858" +dependencies = [ + "anyhow", + "assert_cmd", + "bitflags 2.11.0", + "cfg-if", + "clap", + "derive_builder", + "env_logger", + "function_name", + "graph-cycles", + "lalry", + "num_cpus", + "owo-colors", + "parol-macros", + "parol_runtime", + "petgraph 0.6.5", + "rand 0.9.2", + "rand_regex", + "rayon", + "regex", + "regex-syntax 0.8.5", + "rustc-hash", + "serde", + "serde_json", + "syn 2.0.101", + "syntree", + "syntree_layout", + "tempfile", + "thiserror 2.0.12", + "ts-rs 10.1.0", + "ume", +] + +[[package]] +name = "parol-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec451836679aaa55158b454b441e21446a111ea34aa1917eba29245bafe4b8" + +[[package]] +name = "parol_runtime" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "082b278f4794a512259823cbf8dd31c6a25cc65825ce1f64e04239ca0f5231ee" +dependencies = [ + "anyhow", + "codespan-reporting", + "derive_builder", + "function_name", + "log", + "once_cell", + "parol-macros", + "petgraph 0.8.3", + "scnr", + "serde_json", + "syntree", + "syntree_layout", + "thiserror 2.0.12", +] + [[package]] name = "paste" version = "1.0.15" @@ -2987,13 +3436,23 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.3", "indexmap", "serde", @@ -3058,6 +3517,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -3082,6 +3556,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty" version = "0.12.4" @@ -3090,7 +3591,7 @@ checksum = "ac98773b7109bc75f475ab5a134c9b64b87e59d776d31098d8f346922396a477" dependencies = [ "arrayvec", "typed-arena", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -3166,7 +3667,7 @@ dependencies = [ "tokio", "tower 0.5.2", "trait-variant", - "ts-rs", + "ts-rs 11.1.0", "urlencoding", ] @@ -3180,7 +3681,7 @@ dependencies = [ "quote", "syn 2.0.101", "thiserror 2.0.12", - "ts-rs-macros", + "ts-rs-macros 11.1.0", ] [[package]] @@ -3329,6 +3830,16 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "rand_regex" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04db2e382d13679a1e42400e90e306cdbb79dc5cd41bb035ba4eae72e78cdf37" +dependencies = [ + "rand 0.9.2", + "regex-syntax 0.8.5", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -3585,6 +4096,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rumoca" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "888d5713d05433c209c9512c8657d3c3f5ad683890fd5ca4f967ee72c37c3719" +dependencies = [ + "anyhow", + "chksum-md5", + "clap", + "env_logger", + "git-version", + "indexmap", + "miette", + "minijinja", + "owo-colors", + "parol", + "parol_runtime", + "serde", + "serde_json", + "thiserror 1.0.69", + "toml", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3805,6 +4345,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scnr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebfcd7c0893254c23586487f4582b607fea2f9ed4873ed76cb00d1d9c68d6a5e" +dependencies = [ + "dot-writer", + "log", + "regex-syntax 0.8.5", + "rustc-hash", + "serde", + "serde_json", + "seshat-unicode", + "thiserror 2.0.12", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3963,6 +4519,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3975,6 +4540,15 @@ dependencies = [ "serde", ] +[[package]] +name = "seshat-unicode" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c383601b899fbb8a859ae943fe23eb0385b7cd30ebd6d24e6827bc786635c48" +dependencies = [ + "num 0.3.1", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4049,7 +4623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a386a501cd104797982c15ae17aafe8b9261315b5d07e3ec803f2ea26be0fa" dependencies = [ "approx", - "num-complex", + "num-complex 0.4.6", "num-traits", "paste", "wide", @@ -4077,7 +4651,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "thiserror 2.0.12", "time", @@ -4451,6 +5025,27 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "1.0.109" @@ -4499,6 +5094,24 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "syntree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c99c9cda412afe293a6b962af651b4594161ba88c1affe7ef66459ea040a06" + +[[package]] +name = "syntree_layout" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d027955f5f14d526450b6a11ef5aa962761e924dcd092298dd0d1466570565" +dependencies = [ + "anyhow", + "syntree", + "thiserror 2.0.12", + "xml_writer", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -4573,6 +5186,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-strategy" version = "0.4.5" @@ -4596,6 +5225,16 @@ dependencies = [ "rgb", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4812,6 +5451,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.4.13" @@ -4962,6 +5642,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "lazy_static", + "thiserror 2.0.12", + "ts-rs-macros 10.1.0", +] + [[package]] name = "ts-rs" version = "11.1.0" @@ -4971,10 +5662,22 @@ dependencies = [ "chrono", "serde_json", "thiserror 2.0.12", - "ts-rs-macros", + "ts-rs-macros 11.1.0", "uuid", ] +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "termcolor", +] + [[package]] name = "ts-rs-macros" version = "11.1.0" @@ -5060,6 +5763,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ume" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c524dc90cd71769e23e877ffdcb128f867970f1febdb8eb11a776f5f49e7fc" + [[package]] name = "unarray" version = "0.1.4" @@ -5084,6 +5793,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.24" @@ -5111,6 +5826,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -5853,6 +6574,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xml_writer" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a74a847d8392999f89e9668c4dd46283b91fd6fc1f34aa5ecf4ceaf8fa3258e" + [[package]] name = "yansi" version = "1.0.1" diff --git a/packages/catlog-wasm/src/analyses.rs b/packages/catlog-wasm/src/analyses.rs index 57fee9811..d90c7d834 100644 --- a/packages/catlog-wasm/src/analyses.rs +++ b/packages/catlog-wasm/src/analyses.rs @@ -4,7 +4,9 @@ use serde::{Deserialize, Serialize}; use tsify::Tsify; use catlog::simulate::ode::PolynomialSystem; +use catlog::simulate::ode::modelica::{ModelicaExperiment, ModelicaOptions}; use catlog::stdlib::analyses::ode; +use catlog::stdlib::analyses::ode::modelica_export::render_polynomial_system_as_modelica; use catlog::zero::QualifiedName; use super::latex::{LatexEquations, latex_mor_names, latex_mor_names_mass_action, latex_ob_names}; @@ -143,3 +145,207 @@ pub(crate) fn mass_action_simulation( latex_equations: LatexEquations(latex_equations), }) } + +// --------------------------------------------------------------------------- +// Modelica export +// --------------------------------------------------------------------------- + +/// Data driving the Modelica code-export analysis. +/// +/// All fields apart from `modelName` are optional. When omitted, parameters +/// and state variables are emitted with default values of `1.0`. The frontend +/// surfaces only the model name and the experiment time span; consumers can +/// then edit the generated parameters in their preferred Modelica tooling. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct ModelicaExportData { + /// The Modelica model name (e.g. `"LotkaVolterra"`). + #[serde(rename = "modelName")] + pub model_name: String, + /// Simulation start time written into the `experiment` annotation. + #[serde(rename = "startTime")] + pub start_time: f32, + /// Simulation stop time written into the `experiment` annotation. + #[serde(rename = "stopTime")] + pub stop_time: f32, +} + +impl Default for ModelicaExportData { + fn default() -> Self { + Self { + model_name: "Model".to_string(), + start_time: 0.0, + stop_time: 10.0, + } + } +} + +/// Modelica source emitted by an export analysis, plus the model name. +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct ModelicaResult { + /// Sanitised Modelica model name actually emitted (matches the closing + /// `end ;`). May differ from the input if the user supplied an + /// identifier that needed cleanup. + #[serde(rename = "modelName")] + pub model_name: String, + /// The Modelica source code. + pub source: String, +} + +fn modelica_options(data: &ModelicaExportData) -> ModelicaOptions { + ModelicaOptions { + model_name: data.model_name.clone(), + experiment: Some(ModelicaExperiment { + start_time: data.start_time, + stop_time: data.stop_time, + }), + ..Default::default() + } +} + +/// Closure that turns a `QualifiedName` into a Modelica identifier using the +/// model's object name namespace. +fn modelica_ob_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { + |id: &QualifiedName| model.ob_namespace.label_string(id) +} + +/// Closure that turns a `QualifiedName` into a Modelica identifier using the +/// model's morphism name namespace (with a fallback for unlabelled morphisms). +fn modelica_mor_names(model: &DblModel) -> impl Fn(&QualifiedName) -> String { + move |id: &QualifiedName| { + if let Some(label) = model.mor_namespace.label(id) { + label.to_string() + } else if let Some((dom, cod)) = model.mor_generator_dom_cod_label_strings(id) { + format!("{dom}_to_{cod}") + } else { + id.to_string() + } + } +} + +/// Closure that turns a [`ode::FlowParameter`] into a Modelica identifier. +fn modelica_mor_names_mass_action(model: &DblModel) -> impl Fn(&ode::FlowParameter) -> String { + let transition_label = |t: &QualifiedName| -> String { + if let Some(label) = model.mor_namespace.label(t) { + label.to_string() + } else if let Some((dom, cod)) = model.mor_generator_dom_cod_label_strings(t) { + format!("{dom}_to_{cod}") + } else { + t.to_string() + } + }; + move |p: &ode::FlowParameter| match p { + ode::FlowParameter::Balanced { transition } => { + format!("r_{}", transition_label(transition)) + } + ode::FlowParameter::Unbalanced { direction, parameter } => match (direction, parameter) { + (ode::Direction::IncomingFlow, ode::RateParameter::PerTransition { transition }) => { + format!("rho_{}", transition_label(transition)) + } + (ode::Direction::OutgoingFlow, ode::RateParameter::PerTransition { transition }) => { + format!("kappa_{}", transition_label(transition)) + } + (ode::Direction::IncomingFlow, ode::RateParameter::PerPlace { transition, place }) => { + format!( + "rho_{}__{}", + transition_label(transition), + model.ob_namespace.label_string(place) + ) + } + (ode::Direction::OutgoingFlow, ode::RateParameter::PerPlace { transition, place }) => { + format!( + "kappa_{}__{}", + transition_label(transition), + model.ob_namespace.label_string(place) + ) + } + }, + } +} + +fn build_modelica_result(data: &ModelicaExportData, source: String) -> ModelicaResult { + let model_name = catlog::simulate::ode::modelica::sanitize_identifier(data.model_name.as_str()); + let model_name = if model_name.is_empty() { + "Model".to_string() + } else { + model_name + }; + ModelicaResult { model_name, source } +} + +/// Emit Modelica source for a mass-action analysis (Petri net or stock-flow). +pub(crate) fn mass_action_modelica( + model: &DblModel, + mass_conservation_type: ode::MassConservationType, + export: ModelicaExportData, + logic: MassActionAnalysisLogic, +) -> Result { + let sys = mass_action_system(model, mass_conservation_type, logic)?; + let opts = modelica_options(&export); + let source = render_polynomial_system_as_modelica( + sys, + modelica_ob_names(model), + modelica_mor_names_mass_action(model), + &opts, + ); + Ok(build_modelica_result(&export, source)) +} + +/// Emit Modelica source for a generic polynomial-ODE analysis. +pub(crate) fn polynomial_ode_modelica( + model: &DblModel, + export: ModelicaExportData, +) -> Result { + let sys = polynomial_ode_system(model)?; + let opts = modelica_options(&export); + let source = render_polynomial_system_as_modelica( + sys, + modelica_ob_names(model), + modelica_mor_names(model), + &opts, + ); + Ok(build_modelica_result(&export, source)) +} + +/// Emit Modelica source for the Lotka–Volterra analysis on a signed graph. +pub(crate) fn lotka_volterra_modelica( + model: &DblModel, + export: ModelicaExportData, +) -> Result { + use catlog::one::Path; + use catlog::zero::name; + let (sys, _) = ode::SignedCoefficientBuilder::new(name("Object")) + .add_positive(Path::Id(name("Object"))) + .add_negative(name("Negative").into()) + .lotka_volterra_system(model.discrete()?); + let opts = modelica_options(&export); + let source = render_polynomial_system_as_modelica( + sys, + modelica_ob_names(model), + modelica_mor_names(model), + &opts, + ); + Ok(build_modelica_result(&export, source)) +} + +/// Emit Modelica source for the linear ODE analysis on a signed graph. +pub(crate) fn linear_ode_modelica( + model: &DblModel, + export: ModelicaExportData, +) -> Result { + use catlog::one::Path; + use catlog::zero::name; + let (sys, _) = ode::SignedCoefficientBuilder::new(name("Object")) + .add_positive(Path::Id(name("Object"))) + .add_negative(name("Negative").into()) + .linear_ode_system(model.discrete()?); + let opts = modelica_options(&export); + let source = render_polynomial_system_as_modelica( + sys, + modelica_ob_names(model), + modelica_mor_names(model), + &opts, + ); + Ok(build_modelica_result(&export, source)) +} diff --git a/packages/catlog-wasm/src/theories.rs b/packages/catlog-wasm/src/theories.rs index 2b95175c5..50e5b0085 100644 --- a/packages/catlog-wasm/src/theories.rs +++ b/packages/catlog-wasm/src/theories.rs @@ -185,6 +185,26 @@ impl ThSignedCategory { .into(), )) } + + /// Emit Modelica source code for the Lotka-Volterra system of this model. + #[wasm_bindgen(js_name = "lotkaVolterraModelica")] + pub fn lotka_volterra_modelica( + &self, + model: &DblModel, + data: ModelicaExportData, + ) -> Result { + lotka_volterra_modelica(model, data) + } + + /// Emit Modelica source code for the linear ODE system of this model. + #[wasm_bindgen(js_name = "linearODEModelica")] + pub fn linear_ode_modelica( + &self, + model: &DblModel, + data: ModelicaExportData, + ) -> Result { + linear_ode_modelica(model, data) + } } /// The theory of delayable signed categories. @@ -329,6 +349,22 @@ impl ThCategoryLinks { ) -> Result { mass_action_equations(model, data, MassActionAnalysisLogic::StockFlow) } + + /// Emit Modelica source code for the mass-action ODE system of this model. + #[wasm_bindgen(js_name = "massActionModelica")] + pub fn mass_action_modelica( + &self, + model: &DblModel, + data: MassActionEquationsData, + export: ModelicaExportData, + ) -> Result { + mass_action_modelica( + model, + data.mass_conservation_type, + export, + MassActionAnalysisLogic::StockFlow, + ) + } } /// The theory of categories with signed links. @@ -366,6 +402,22 @@ impl ThCategorySignedLinks { ) -> Result { mass_action_equations(model, data, MassActionAnalysisLogic::StockFlow) } + + /// Emit Modelica source code for the mass-action ODE system of this model. + #[wasm_bindgen(js_name = "massActionModelica")] + pub fn mass_action_modelica( + &self, + model: &DblModel, + data: MassActionEquationsData, + export: ModelicaExportData, + ) -> Result { + mass_action_modelica( + model, + data.mass_conservation_type, + export, + MassActionAnalysisLogic::StockFlow, + ) + } } /// The theory of strict symmetric monoidal categories. @@ -418,6 +470,22 @@ impl ThSymMonoidalCategory { ))) } + /// Emit Modelica source code for the mass-action ODE system of this model. + #[wasm_bindgen(js_name = "massActionModelica")] + pub fn mass_action_modelica( + &self, + model: &DblModel, + data: MassActionEquationsData, + export: ModelicaExportData, + ) -> Result { + mass_action_modelica( + model, + data.mass_conservation_type, + export, + MassActionAnalysisLogic::PetriNet, + ) + } + /// Solve the subreachability problem for petri nets. #[wasm_bindgen(js_name = "subreachability")] pub fn subreachability( @@ -465,6 +533,16 @@ impl ThPolynomialODE { ) -> Result { polynomial_ode_equations(model, data) } + + /// Emit Modelica source code for the polynomial ODE system of this model. + #[wasm_bindgen(js_name = "polynomialODEModelica")] + pub fn polynomial_ode_modelica( + &self, + model: &DblModel, + data: ModelicaExportData, + ) -> Result { + polynomial_ode_modelica(model, data) + } } /// A theory of systems of signed polynomial ODEs @@ -502,6 +580,16 @@ impl ThSignedPolynomialODE { ) -> Result { polynomial_ode_equations(model, data) } + + /// Emit Modelica source code for the polynomial ODE system of this model. + #[wasm_bindgen(js_name = "polynomialODEModelica")] + pub fn polynomial_ode_modelica( + &self, + model: &DblModel, + data: ModelicaExportData, + ) -> Result { + polynomial_ode_modelica(model, data) + } } /// A theory of power systems. diff --git a/packages/catlog/Cargo.toml b/packages/catlog/Cargo.toml index dc0f9219c..9fc118a64 100644 --- a/packages/catlog/Cargo.toml +++ b/packages/catlog/Cargo.toml @@ -50,6 +50,7 @@ expect-test = "1.5" textplots = "0.8.7" similar = "2.7.0" serde_json = "1.0.145" +rumoca = { version = "0.7", default-features = false } [[example]] name = "tt" diff --git a/packages/catlog/src/simulate/ode/mod.rs b/packages/catlog/src/simulate/ode/mod.rs index 6e6e1e5a6..3f94e099d 100644 --- a/packages/catlog/src/simulate/ode/mod.rs +++ b/packages/catlog/src/simulate/ode/mod.rs @@ -158,7 +158,9 @@ pub(crate) fn textplot_mapped_ode_result( } pub mod kuramoto; +pub mod modelica; pub mod polynomial; pub use kuramoto::*; +pub use modelica::*; pub use polynomial::*; diff --git a/packages/catlog/src/simulate/ode/modelica.rs b/packages/catlog/src/simulate/ode/modelica.rs new file mode 100644 index 000000000..0e2297e88 --- /dev/null +++ b/packages/catlog/src/simulate/ode/modelica.rs @@ -0,0 +1,405 @@ +//! Emit [Modelica](https://modelica.org) source for a polynomial ODE system. +//! +//! This module converts a [`PolynomialSystem`] into a self-contained +//! `model … end …;` block. The resulting Modelica is intentionally minimal: +//! every parameter occurring symbolically in the coefficients is declared as +//! `parameter Real

= 1.0;` and every state variable is declared as +//! `Real (start = 1.0);`. Users can either edit the defaults in their +//! preferred Modelica tooling, or pass [`ModelicaOptions`] with concrete +//! starting values and parameter values. +//! +//! The output is consumable by [Rumoca](https://crates.io/crates/rumoca); see +//! the emission tests in `tests/modelica_emission.rs`. +//! +//! # Identifier sanitisation +//! +//! Modelica identifiers must match `[A-Za-z_][A-Za-z0-9_]*`. The helper +//! [`sanitize_identifier`] replaces any other character with `_` and prepends +//! `_` to identifiers that begin with a digit, so callers can pass any +//! `Display` value through [`PolynomialSystem::map_variables`] / +//! `extend_scalars` and then emit. + +use std::collections::BTreeSet; +use std::fmt::Display; +use std::ops::Neg; + +use indexmap::IndexMap; +use num_traits::One; + +use super::polynomial::PolynomialSystem; +use crate::zero::alg::Polynomial; +use crate::zero::rig::{DisplayCoef, Monomial}; + +/// Settings controlling how a [`PolynomialSystem`] is rendered as Modelica. +#[derive(Clone, Debug)] +pub struct ModelicaOptions { + /// The Modelica model name (e.g. `"LotkaVolterra"`). + pub model_name: String, + /// Default start value for state variables that don't appear in + /// [`initial_values`](Self::initial_values). + pub default_initial_value: f32, + /// Default value for parameters that don't appear in + /// [`parameter_values`](Self::parameter_values). + pub default_parameter_value: f32, + /// Per-variable initial values, keyed by the sanitised Modelica identifier. + pub initial_values: IndexMap, + /// Per-parameter values, keyed by the sanitised Modelica identifier. + pub parameter_values: IndexMap, + /// If `Some`, emitted as an `annotation(experiment(...))` block. + pub experiment: Option, +} + +/// Configuration for a Modelica `experiment` annotation. +#[derive(Clone, Debug)] +pub struct ModelicaExperiment { + /// Simulation start time. + pub start_time: f32, + /// Simulation stop time. + pub stop_time: f32, +} + +impl Default for ModelicaOptions { + fn default() -> Self { + Self { + model_name: "Model".to_string(), + default_initial_value: 1.0, + default_parameter_value: 1.0, + initial_values: IndexMap::new(), + parameter_values: IndexMap::new(), + experiment: None, + } + } +} + +/// Sanitise an arbitrary `Display`able value into a valid Modelica identifier. +/// +/// Non-alphanumeric, non-underscore characters become `_`. If the result is +/// empty or starts with a digit, an underscore is prepended. +pub fn sanitize_identifier(raw: impl Display) -> String { + let s = raw.to_string(); + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + out.push(c); + } else { + out.push('_'); + } + } + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +/// A coefficient or expression that can be embedded inside a Modelica term. +/// +/// The implementations on [`f32`] and [`Polynomial`] are enough to render the +/// systems produced by all the ODE analyses in +/// [`crate::stdlib::analyses::ode`]. Custom systems can implement this trait +/// to participate in [`polynomial_system_to_modelica`]. +pub trait ToModelicaExpr { + /// Renders the value as a Modelica expression string. + fn to_modelica_expr(&self) -> String; + /// Names of any free parameters appearing in the expression. Used to emit + /// `parameter Real

;` declarations at the top of the model. + fn collect_parameters(&self, out: &mut BTreeSet); + /// Whether the expression should be parenthesised when used as a factor in + /// a product (i.e. if it parses as a sum/difference at the top level). + fn modelica_needs_parens(&self) -> bool { + false + } +} + +impl ToModelicaExpr for f32 { + fn to_modelica_expr(&self) -> String { + format_f32(*self) + } + fn collect_parameters(&self, _: &mut BTreeSet) {} + fn modelica_needs_parens(&self) -> bool { + // Negative literals need parens when used as a factor. + *self < 0.0 + } +} + +impl ToModelicaExpr for Polynomial +where + V: Ord + Display, + C: ToModelicaExpr + DisplayCoef + Clone + PartialEq + One + Neg, + E: Display + PartialEq + One + Clone + Ord, +{ + fn to_modelica_expr(&self) -> String { + polynomial_to_modelica(self) + } + fn collect_parameters(&self, out: &mut BTreeSet) { + for m in self.monomials() { + for (var, _exp) in m.iter() { + out.insert(sanitize_identifier(var)); + } + } + } + fn modelica_needs_parens(&self) -> bool { + // A multi-term polynomial parses as a sum at the top level. + self.monomials().len() > 1 + } +} + +/// Renders a [`PolynomialSystem`] as a Modelica `model` block. +/// +/// All free parameters referenced by the coefficients are auto-discovered via +/// [`ToModelicaExpr::collect_parameters`] and declared at the top of the +/// model. State variables are taken from the system's keys. +pub fn polynomial_system_to_modelica( + sys: &PolynomialSystem, + opts: &ModelicaOptions, +) -> String +where + V: Ord + Display, + C: ToModelicaExpr + DisplayCoef + Clone + PartialEq + One + Neg, + E: Display + PartialEq + One + Clone + Ord, +{ + let model_name = if opts.model_name.is_empty() { + "Model" + } else { + opts.model_name.as_str() + }; + // Validate model name: keep simple — sanitise if necessary. + let model_name = sanitize_identifier(model_name); + + let mut params: BTreeSet = BTreeSet::new(); + for poly in sys.components.values() { + for (coef, _m) in poly.terms() { + coef.collect_parameters(&mut params); + } + } + + let mut state_decls = Vec::::new(); + let mut equations = Vec::::new(); + for (var, poly) in sys.components.iter() { + let id = sanitize_identifier(var); + let start = opts.initial_values.get(&id).copied().unwrap_or(opts.default_initial_value); + state_decls.push(format!(" Real {id}(start = {});", format_f32(start))); + let rhs = polynomial_to_modelica(poly); + equations.push(format!(" der({id}) = {rhs};")); + } + + let mut out = String::new(); + out.push_str(&format!("model {model_name}\n")); + + if !params.is_empty() { + for p in ¶ms { + let value = + opts.parameter_values.get(p).copied().unwrap_or(opts.default_parameter_value); + out.push_str(&format!(" parameter Real {p} = {};\n", format_f32(value))); + } + out.push('\n'); + } + + for decl in &state_decls { + out.push_str(decl); + out.push('\n'); + } + out.push_str("equation\n"); + for eq in &equations { + out.push_str(eq); + out.push('\n'); + } + + if let Some(exp) = &opts.experiment { + out.push_str(&format!( + " annotation(experiment(StartTime = {}, StopTime = {}));\n", + format_f32(exp.start_time), + format_f32(exp.stop_time), + )); + } + + out.push_str(&format!("end {model_name};\n")); + out +} + +/// Convenience method: render the system using default options apart from the +/// model name. +impl PolynomialSystem +where + V: Ord + Display, + C: ToModelicaExpr + DisplayCoef + Clone + PartialEq + One + Neg, + E: Display + PartialEq + One + Clone + Ord, +{ + /// Render the system as a self-contained Modelica `model` block. + pub fn to_modelica(&self, opts: &ModelicaOptions) -> String { + polynomial_system_to_modelica(self, opts) + } +} + +/// Renders an individual polynomial as a Modelica expression. +fn polynomial_to_modelica(poly: &Polynomial) -> String +where + V: Ord + Display, + C: ToModelicaExpr + DisplayCoef + Clone + PartialEq + One + Neg, + E: Ord + Display + PartialEq + One, +{ + let fmt_term = |coef: &C, monomial: &Monomial| -> String { + let mon = monomial_to_modelica(monomial); + if coef.is_one() { + if mon.is_empty() { "1".to_string() } else { mon } + } else if *coef == C::one().neg() { + if mon.is_empty() { + "-1".to_string() + } else { + format!("-{mon}") + } + } else { + let coef_str = if coef.modelica_needs_parens() { + format!("({})", coef.to_modelica_expr()) + } else { + coef.to_modelica_expr() + }; + if mon.is_empty() { + coef_str + } else { + format!("{coef_str}*{mon}") + } + } + }; + + let mut iter = poly.terms(); + let Some((coef, monomial)) = iter.next() else { + return "0".to_string(); + }; + let mut output = fmt_term(coef, monomial); + for (coef, monomial) in iter { + if coef.has_negative_sign() { + output.push_str(" - "); + output.push_str(&fmt_term(&coef.clone().neg(), monomial)); + } else { + output.push_str(" + "); + output.push_str(&fmt_term(coef, monomial)); + } + } + output +} + +/// Renders a single monomial as a Modelica product. Empty for the unit monomial. +fn monomial_to_modelica(monomial: &Monomial) -> String +where + V: Ord + Display, + E: Display + PartialEq + One, +{ + let mut parts = Vec::::new(); + for (var, exp) in monomial.iter() { + let id = sanitize_identifier(var); + if exp.is_one() { + parts.push(id); + } else { + // Modelica's `^` is right-associative and works on reals. For negative + // exponents we still emit `x^(-2)`. + let exp_str = exp.to_string(); + if exp_str.starts_with('-') { + parts.push(format!("{id}^({exp_str})")); + } else { + parts.push(format!("{id}^{exp_str}")); + } + } + } + parts.join("*") +} + +/// Formats an `f32` so the output is unambiguous to Modelica's lexer. +fn format_f32(v: f32) -> String { + if v.is_finite() { + // `{}` for f32 prints integers without `.`, which Modelica accepts as + // an integer literal — fine for `Real` contexts since Modelica promotes. + let s = format!("{v}"); + if s.contains('.') || s.contains('e') || s.contains('E') { + s + } else { + format!("{s}.0") + } + } else if v.is_nan() { + // No NaN literal in Modelica; fall back to 0 with a comment-friendly marker. + "0.0 /* NaN */".to_string() + } else if v.is_sign_positive() { + "Modelica.Constants.inf".to_string() + } else { + "-Modelica.Constants.inf".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::zero::rig::Monomial; + + type Parameter = Polynomial; + type Sys = PolynomialSystem; + + fn param(name: &str) -> Parameter { + Polynomial::generator(name.to_string()) + } + + fn var(name: &str) -> Polynomial { + Polynomial::generator(name.to_string()) + } + + #[test] + fn sir_modelica() { + // -β S I, β S I, -γ I, γ I (SIR) + let beta = param("beta"); + let gamma = param("gamma"); + let s = var("S"); + let i = var("I"); + + let terms = [ + ("S".to_string(), -s.clone() * i.clone() * beta.clone()), + ("I".to_string(), s.clone() * i.clone() * beta.clone()), + ("I".to_string(), -i.clone() * gamma.clone()), + ("R".to_string(), i.clone() * gamma.clone()), + ]; + let sys: Sys = terms.into_iter().collect(); + + let opts = ModelicaOptions { + model_name: "SIR".to_string(), + ..Default::default() + }; + let out = sys.to_modelica(&opts); + assert!(out.starts_with("model SIR\n")); + assert!(out.contains("parameter Real beta = 1.0;")); + assert!(out.contains("parameter Real gamma = 1.0;")); + assert!(out.contains("Real S(start = 1.0);")); + assert!(out.contains("Real I(start = 1.0);")); + assert!(out.contains("Real R(start = 1.0);")); + assert!(out.contains("der(S) = -beta*I*S;")); + assert!(out.ends_with("end SIR;\n")); + + let _ = Monomial::::default(); // touch import + } + + #[test] + fn sanitization() { + assert_eq!(sanitize_identifier("foo.bar"), "foo_bar"); + assert_eq!(sanitize_identifier("3x"), "_3x"); + assert_eq!(sanitize_identifier("β"), "_"); + assert_eq!(sanitize_identifier(""), "_"); + } + + #[test] + fn negative_exponent() { + // x^{-1} appears in signed stock-flow analyses. + let m: Monomial = [("x".to_string(), -1i8)].into_iter().collect(); + let s = monomial_to_modelica(&m); + assert_eq!(s, "x^(-1)"); + } + + #[test] + fn experiment_annotation() { + let s = var("x"); + let p = param("k"); + let sys: Sys = [("x".to_string(), -s.clone() * p.clone())].into_iter().collect(); + let opts = ModelicaOptions { + model_name: "M".to_string(), + experiment: Some(ModelicaExperiment { start_time: 0.0, stop_time: 5.5 }), + ..Default::default() + }; + let out = sys.to_modelica(&opts); + assert!(out.contains("annotation(experiment(StartTime = 0.0, StopTime = 5.5));")); + } +} diff --git a/packages/catlog/src/stdlib/analyses/ode/mod.rs b/packages/catlog/src/stdlib/analyses/ode/mod.rs index 4c9b2a862..eed8a57d4 100644 --- a/packages/catlog/src/stdlib/analyses/ode/mod.rs +++ b/packages/catlog/src/stdlib/analyses/ode/mod.rs @@ -73,6 +73,7 @@ pub mod kuramoto; pub mod linear_ode; pub mod lotka_volterra; pub mod mass_action; +pub mod modelica_export; pub mod polynomial_ode; pub mod signed_coefficients; @@ -80,5 +81,6 @@ pub use kuramoto::*; pub use linear_ode::*; pub use lotka_volterra::*; pub use mass_action::*; +pub use modelica_export::*; pub use polynomial_ode::*; pub use signed_coefficients::*; diff --git a/packages/catlog/src/stdlib/analyses/ode/modelica_export.rs b/packages/catlog/src/stdlib/analyses/ode/modelica_export.rs new file mode 100644 index 000000000..4de0c5503 --- /dev/null +++ b/packages/catlog/src/stdlib/analyses/ode/modelica_export.rs @@ -0,0 +1,165 @@ +//! Modelica source code export for ODE analyses. +//! +//! This module is a thin wrapper over [`crate::simulate::ode::modelica`] that +//! takes the symbolic [`PolynomialSystem`] produced by an ODE analysis and +//! relabels its variables/parameters using closures supplied by the caller +//! (typically `catlog-wasm` so labels can be derived from the model's name +//! namespaces), then emits a self-contained Modelica `model` block. +//! +//! See [`crate::simulate::ode::modelica::ModelicaOptions`] for emission +//! controls. + +use std::fmt::Display; +use std::ops::Add; + +use crate::simulate::ode::modelica::{ModelicaOptions, polynomial_system_to_modelica}; +use crate::simulate::ode::polynomial::PolynomialSystem; +use crate::zero::QualifiedName; +use crate::zero::alg::Polynomial; + +use super::Parameter; + +/// Re-labels and renders a symbolic polynomial ODE system as Modelica. +/// +/// The input system has `QualifiedName` variables and [`Parameter`] +/// coefficients (a polynomial in some parameter identifier `X`). The output is +/// a Modelica source string in which both variables and parameters have been +/// renamed via the supplied label functions, and then sanitised into valid +/// Modelica identifiers by [`polynomial_system_to_modelica`]. +pub fn render_polynomial_system_as_modelica( + sys: PolynomialSystem, E>, + ob_label: FOb, + param_label: FParam, + opts: &ModelicaOptions, +) -> String +where + X: Clone + Ord, + E: Clone + Ord + Add + num_traits::One + std::fmt::Display + PartialEq, + FOb: Fn(&QualifiedName) -> String, + FParam: Fn(&X) -> String, +{ + let relabelled: PolynomialSystem, E> = sys + .map_variables(ob_label) + .extend_scalars(|coef| coef.map_variables(¶m_label)); + polynomial_system_to_modelica(&relabelled, opts) +} + +/// Re-labels and renders a symbolic polynomial ODE system as Modelica, where +/// the parameter type is the same as the variable type (a `QualifiedName`). +/// +/// This is the common case for Lotka–Volterra / linear-ODE / generic +/// polynomial-ODE analyses, where parameter identifiers are just +/// `QualifiedName`s. +pub fn render_named_polynomial_system_as_modelica( + sys: PolynomialSystem, E>, + ob_label: FOb, + param_label: FParam, + opts: &ModelicaOptions, +) -> String +where + E: Clone + Ord + Add + num_traits::One + Display + PartialEq, + FOb: Fn(&QualifiedName) -> String, + FParam: Fn(&QualifiedName) -> String, +{ + render_polynomial_system_as_modelica(sys, ob_label, param_label, opts) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use super::*; + use crate::stdlib; + use crate::stdlib::analyses::ode::{ + MassConservationType, PetriNetMassActionAnalysis, PolynomialODEAnalysis, RateGranularity, + StockFlowMassActionAnalysis, + }; + use crate::zero::QualifiedName; + + fn default_label(name: &QualifiedName) -> String { + name.to_string() + } + + fn flow_label(f: &crate::stdlib::analyses::ode::FlowParameter) -> String { + f.to_string() + } + + #[test] + fn petri_balanced_modelica() { + let th = Rc::new(stdlib::theories::th_sym_monoidal_category()); + let model = stdlib::models::catalyzed_reaction(th); + let sys = PetriNetMassActionAnalysis::default() + .build_system(&model, MassConservationType::Balanced); + + let opts = ModelicaOptions { + model_name: "Catalysis".into(), + ..Default::default() + }; + let out = render_polynomial_system_as_modelica(sys, default_label, flow_label, &opts); + + assert!(out.contains("model Catalysis")); + assert!(out.contains("parameter Real f = 1.0;")); + assert!(out.contains("Real x(start = 1.0);")); + assert!(out.contains("Real y(start = 1.0);")); + assert!(out.contains("Real c(start = 1.0);")); + assert!(out.contains("der(x) = -f*c*x;")); + assert!(out.contains("der(y) = f*c*x;")); + assert!(out.contains("der(c) = 0;")); + assert!(out.ends_with("end Catalysis;\n")); + } + + #[test] + fn petri_unbalanced_modelica() { + let th = Rc::new(stdlib::theories::th_sym_monoidal_category()); + let model = stdlib::models::catalyzed_reaction(th); + let sys = PetriNetMassActionAnalysis::default() + .build_system(&model, MassConservationType::Unbalanced(RateGranularity::PerTransition)); + + let opts = ModelicaOptions { + model_name: "Cat".into(), + ..Default::default() + }; + let out = render_polynomial_system_as_modelica(sys, default_label, flow_label, &opts); + // "Outgoing(f)" → "Outgoing_f_" + assert!(out.contains("parameter Real Outgoing_f_")); + assert!(out.contains("parameter Real Incoming_f_")); + } + + #[test] + fn stock_flow_modelica() { + let th = Rc::new(stdlib::theories::th_category_links()); + let model = stdlib::models::backward_link(th); + let sys = StockFlowMassActionAnalysis::default() + .build_system(&model, MassConservationType::Balanced); + + let opts = ModelicaOptions { + model_name: "SF".into(), + ..Default::default() + }; + let out = render_polynomial_system_as_modelica(sys, default_label, flow_label, &opts); + assert!(out.contains("parameter Real f = 1.0;")); + assert!(out.contains("der(x) = -f*x*y;")); + assert!(out.contains("der(y) = f*x*y;")); + } + + #[test] + fn polynomial_ode_modelica() { + let th = Rc::new(stdlib::theories::th_polynomial_ode_system()); + let model = stdlib::models::lotka_volterra_dynamics(th); + let sys = PolynomialODEAnalysis::default().build_system(&model); + + let opts = ModelicaOptions { + model_name: "LV".into(), + ..Default::default() + }; + let out = render_polynomial_system_as_modelica(sys, default_label, default_label, &opts); + + // Three state vars A, B, C + assert!(out.contains("Real A(start = 1.0);")); + assert!(out.contains("Real B(start = 1.0);")); + assert!(out.contains("Real C(start = 1.0);")); + // Growth-rate parameters present + assert!(out.contains("parameter Real A_growth")); + assert!(out.contains("parameter Real BA_interaction")); + } +} diff --git a/packages/catlog/src/zero/alg.rs b/packages/catlog/src/zero/alg.rs index 6899222f9..ad61748d8 100644 --- a/packages/catlog/src/zero/alg.rs +++ b/packages/catlog/src/zero/alg.rs @@ -67,6 +67,11 @@ where self.0.variables() } + /// Iterates over the `(coefficient, monomial)` terms of the polynomial. + pub fn terms(&self) -> impl Iterator)> { + (&self.0).into_iter() + } + /// Maps the coefficients of the polynomial. /// /// In the usual situations when the coefficients from commutative rigs and the diff --git a/packages/catlog/src/zero/rig.rs b/packages/catlog/src/zero/rig.rs index 800330b34..4d3b47a46 100644 --- a/packages/catlog/src/zero/rig.rs +++ b/packages/catlog/src/zero/rig.rs @@ -480,6 +480,11 @@ where self.0.keys() } + /// Iterates over the `(variable, exponent)` pairs of the monomial. + pub fn iter(&self) -> impl ExactSizeIterator { + self.0.iter() + } + /// Evaluates the monomial by substituting for the variables. pub fn eval(&self, mut f: F) -> A where diff --git a/packages/catlog/tests/modelica_emission.rs b/packages/catlog/tests/modelica_emission.rs new file mode 100644 index 000000000..e9c7284a6 --- /dev/null +++ b/packages/catlog/tests/modelica_emission.rs @@ -0,0 +1,150 @@ +//! Modelica emission tests: emit Modelica from each ODE flavour and check +//! that the result parses and balance-compiles via [`rumoca`]. +//! +//! These tests give us machine-checked confidence that the Modelica we emit +//! is syntactically and structurally well-formed. +//! +//! Rumoca is a dev-only dependency, so these tests never ship with the WASM +//! bundle. +//! +//! Each test: +//! 1. Builds a representative ODE model. +//! 2. Calls [`catlog::stdlib::analyses::ode::modelica_export:: +//! render_polynomial_system_as_modelica`]. +//! 3. Feeds the resulting source to `rumoca::Compiler::new().model(...) +//! .compile_str(...)` and asserts that it succeeds with at least one +//! state variable in the produced DAE. + +use std::rc::Rc; + +use catlog::simulate::ode::modelica::ModelicaOptions; +use catlog::stdlib::{ + self, + analyses::ode::{ + FlowParameter, MassConservationType, PetriNetMassActionAnalysis, PolynomialODEAnalysis, + StockFlowMassActionAnalysis, modelica_export::render_polynomial_system_as_modelica, + }, +}; +use catlog::zero::QualifiedName; + +fn ob_label(name: &QualifiedName) -> String { + name.to_string() +} + +fn flow_label(p: &FlowParameter) -> String { + p.to_string() +} + +fn name_label(p: &QualifiedName) -> String { + p.to_string() +} + +fn assert_compiles(model_name: &str, source: &str) { + let result = rumoca::Compiler::new().model(model_name).compile_str(source, "test.mo"); + assert!( + result.is_ok(), + "Rumoca failed to compile generated Modelica for {model_name}:\n--- source ---\n{source}\n--- error ---\n{:?}", + result.err() + ); + let result = result.unwrap(); + assert!( + !result.dae.x.is_empty(), + "Compiled DAE for {model_name} should have at least one state variable\n--- source ---\n{source}" + ); +} + +#[test] +fn petri_balanced_compiles() { + let th = Rc::new(stdlib::theories::th_sym_monoidal_category()); + let model = stdlib::models::catalyzed_reaction(th); + let sys = + PetriNetMassActionAnalysis::default().build_system(&model, MassConservationType::Balanced); + let opts = ModelicaOptions { + model_name: "Catalysis".into(), + ..Default::default() + }; + let source = render_polynomial_system_as_modelica(sys, ob_label, flow_label, &opts); + assert_compiles("Catalysis", &source); +} + +#[test] +fn petri_unbalanced_compiles() { + use catlog::stdlib::analyses::ode::RateGranularity; + let th = Rc::new(stdlib::theories::th_sym_monoidal_category()); + let model = stdlib::models::catalyzed_reaction(th); + let sys = PetriNetMassActionAnalysis::default() + .build_system(&model, MassConservationType::Unbalanced(RateGranularity::PerTransition)); + let opts = ModelicaOptions { + model_name: "CatUnbal".into(), + ..Default::default() + }; + let source = render_polynomial_system_as_modelica(sys, ob_label, flow_label, &opts); + assert_compiles("CatUnbal", &source); +} + +#[test] +fn stock_flow_balanced_compiles() { + let th = Rc::new(stdlib::theories::th_category_links()); + let model = stdlib::models::backward_link(th); + let sys = + StockFlowMassActionAnalysis::default().build_system(&model, MassConservationType::Balanced); + let opts = ModelicaOptions { + model_name: "StockFlow".into(), + ..Default::default() + }; + let source = render_polynomial_system_as_modelica(sys, ob_label, flow_label, &opts); + assert_compiles("StockFlow", &source); +} + +#[test] +fn polynomial_ode_compiles() { + let th = Rc::new(stdlib::theories::th_polynomial_ode_system()); + let model = stdlib::models::lotka_volterra_dynamics(th); + let sys = PolynomialODEAnalysis::default().build_system(&model); + let opts = ModelicaOptions { + model_name: "PolyODE".into(), + ..Default::default() + }; + let source = render_polynomial_system_as_modelica(sys, ob_label, name_label, &opts); + assert_compiles("PolyODE", &source); +} + +#[test] +fn lotka_volterra_compiles() { + use catlog::one::{Path, QualifiedPath}; + use catlog::stdlib::analyses::ode::SignedCoefficientBuilder; + use catlog::zero::name; + + let th = Rc::new(stdlib::theories::th_signed_category()); + let model = stdlib::models::negative_feedback(th); + let builder = SignedCoefficientBuilder::::new(name("Object")) + .add_positive(Path::Id(name("Object"))) + .add_negative(Path::single(name("Negative"))); + let (sys, _) = builder.lotka_volterra_system(&model); + let opts = ModelicaOptions { + model_name: "LV".into(), + ..Default::default() + }; + let source = render_polynomial_system_as_modelica(sys, ob_label, name_label, &opts); + assert_compiles("LV", &source); +} + +#[test] +fn linear_ode_compiles() { + use catlog::one::{Path, QualifiedPath}; + use catlog::stdlib::analyses::ode::SignedCoefficientBuilder; + use catlog::zero::name; + + let th = Rc::new(stdlib::theories::th_signed_category()); + let model = stdlib::models::negative_feedback(th); + let builder = SignedCoefficientBuilder::::new(name("Object")) + .add_positive(Path::Id(name("Object"))) + .add_negative(Path::single(name("Negative"))); + let (sys, _) = builder.linear_ode_system(&model); + let opts = ModelicaOptions { + model_name: "LinODE".into(), + ..Default::default() + }; + let source = render_polynomial_system_as_modelica(sys, ob_label, name_label, &opts); + assert_compiles("LinODE", &source); +} diff --git a/packages/frontend/src/stdlib/analyses.tsx b/packages/frontend/src/stdlib/analyses.tsx index 38b19a6a0..825302cda 100644 --- a/packages/frontend/src/stdlib/analyses.tsx +++ b/packages/frontend/src/stdlib/analyses.tsx @@ -2,6 +2,7 @@ import { lazy } from "solid-js"; import type { MassActionEquationsData, + ModelicaExportData, MorType, ObType, PolynomialODEEquationsData, @@ -423,3 +424,31 @@ export function polynomialODESimulation( } const PolynomialODESimulation = lazy(() => import("./analyses/polynomial_ode_simulation")); + +export function modelicaExport( + options: Partial & { + generate: import("./analyses/modelica_export").ModelicaExporter; + }, +): ModelAnalysisMeta { + const { + id = "modelica-export", + name = "Modelica code", + description = "Export the ODE system as a Modelica model", + help = "modelica-export", + generate, + } = options; + return { + id, + name, + description, + help, + component: (props) => , + initialContent: () => ({ + modelName: "Model", + startTime: 0, + stopTime: 10, + }), + }; +} + +const ModelicaExport = lazy(() => import("./analyses/modelica_export")); diff --git a/packages/frontend/src/stdlib/analyses/modelica_export.tsx b/packages/frontend/src/stdlib/analyses/modelica_export.tsx new file mode 100644 index 000000000..5c34d1744 --- /dev/null +++ b/packages/frontend/src/stdlib/analyses/modelica_export.tsx @@ -0,0 +1,181 @@ +import download from "js-file-download"; +import CircleHelp from "lucide-solid/icons/circle-help"; +import Copy from "lucide-solid/icons/copy"; +import DownloadIcon from "lucide-solid/icons/download"; +import { type Accessor, createMemo, Match, Switch } from "solid-js"; + +import { + BlockTitle, + type ColumnSchema, + createNumericalColumn, + ErrorAlert, + FixedTableEditor, + IconButton, +} from "catcolab-ui-components"; +import type { DblModel, ModelicaExportData, ModelicaResult } from "catlog-wasm"; +import type { ModelAnalysisProps } from "../../analysis"; +import type { ValidatedModel } from "../../model"; + +import "./simulation.css"; + +/** Signature for a Modelica-emitting analysis backend. */ +export type ModelicaExporter = (model: DblModel, data: ModelicaExportData) => ModelicaResult; + +const copyToClipboard = (text: string) => navigator.clipboard.writeText(text); + +const helpTooltip = () => ( + <> +

+ Modelica is a declarative language for component-oriented modelling of physical systems. + This analysis renders the model's ODE system as a self-contained Modelica{" "} + model block. +

+

+ All parameters are declared with default value 1.0 and all state variables + with start = 1.0; edit these in your Modelica tooling (OpenModelica, + Dymola, …) to set concrete values. +

+ +); + +function ModelicaToolbar(props: { source: string; filename: string }) { + return ( +
+ copyToClipboard(props.source)} + disabled={false} + tooltip="Copy Modelica to clipboard" + > + + + download(props.source, props.filename, "text/x-modelica")} + disabled={false} + tooltip={`Download ${props.filename}`} + > + + + + + +
+ ); +} + +/** + * Analysis that emits Modelica source code for the model's ODE system. + * + * UI mirrors the SQL-export analysis: a `BlockTitle` with toolbar actions + * (copy/download/help) and a settings pane for the model name + experiment + * time span, plus a read-only `
` source view below.
+ */
+export default function ModelicaExportAnalysis(
+    props: ModelAnalysisProps & {
+        generate: ModelicaExporter;
+        title?: string;
+    },
+) {
+    const result = createModelicaResult(
+        () => props.liveModel.validatedModel(),
+        (model) => props.generate(model, props.content),
+    );
+
+    const filename = () => `${result()?.modelName ?? "Model"}.mo`;
+
+    const settingsSchema: ColumnSchema[] = [
+        {
+            contentType: "string",
+            name: "Model name",
+            content: (_) => props.content.modelName,
+            setContent: (_, content) => {
+                props.changeContent((c) => {
+                    c.modelName = content;
+                });
+                return true;
+            },
+        },
+        createNumericalColumn({
+            name: "Start time",
+            data: (_) => props.content.startTime,
+            setData: (_, data) =>
+                props.changeContent((c) => {
+                    c.startTime = data;
+                }),
+        }),
+        createNumericalColumn({
+            name: "Stop time",
+            data: (_) => props.content.stopTime,
+            validate: (_, data) => data >= props.content.startTime,
+            setData: (_, data) =>
+                props.changeContent((c) => {
+                    c.stopTime = data;
+                }),
+        }),
+    ];
+
+    const settingsPane = (
+        
+ +
+ ); + + return ( +
+ + + {(source) => ( + <> + + } + settingsPane={settingsPane} + /> +
{source()}
+ + )} +
+ + + +

+ The model is not valid yet — fix outstanding errors to generate Modelica + source. +

+
+
+
+
+ ); +} + +/** Reactively run a Modelica exporter against the current validated model. */ +function createModelicaResult( + validatedModel: Accessor, + generate: (model: DblModel) => ModelicaResult, +) { + const result = createMemo( + () => { + const validated = validatedModel(); + if (validated?.tag !== "Valid") { + return; + } + try { + return generate(validated.model); + } catch { + return; + } + }, + undefined, + { equals: false }, + ); + return result; +} diff --git a/packages/frontend/src/stdlib/analyses/simulation.css b/packages/frontend/src/stdlib/analyses/simulation.css index 9219549fb..2758aec67 100644 --- a/packages/frontend/src/stdlib/analyses/simulation.css +++ b/packages/frontend/src/stdlib/analyses/simulation.css @@ -18,3 +18,14 @@ width: 100%; height: 400px; } + +.modelica-source { + background: var(--color-background-subtle, #f5f5f5); + padding: 0.75em 1em; + border-radius: 4px; + overflow-x: auto; + white-space: pre; + font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, monospace); + font-size: 0.9em; + margin: 0; +} diff --git a/packages/frontend/src/stdlib/theories/causal-loop.ts b/packages/frontend/src/stdlib/theories/causal-loop.ts index 174d40108..61e62c659 100644 --- a/packages/frontend/src/stdlib/theories/causal-loop.ts +++ b/packages/frontend/src/stdlib/theories/causal-loop.ts @@ -79,6 +79,18 @@ export default function createCausalLoopTheory(theoryMeta: TheoryMeta): Theory { analyses.lotkaVolterra({ simulate: (model, data) => thSignedCategory.lotkaVolterra(model, data), }), + analyses.modelicaExport({ + id: "modelica-export-linear", + name: "Modelica code (linear ODE)", + description: "Export the linear ODE system as a Modelica model", + generate: (model, data) => thSignedCategory.linearODEModelica(model, data), + }), + analyses.modelicaExport({ + id: "modelica-export-lotka-volterra", + name: "Modelica code (Lotka-Volterra)", + description: "Export the Lotka-Volterra ODE system as a Modelica model", + generate: (model, data) => thSignedCategory.lotkaVolterraModelica(model, data), + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/petri-net.ts b/packages/frontend/src/stdlib/theories/petri-net.ts index e584783df..24cd1ab12 100644 --- a/packages/frontend/src/stdlib/theories/petri-net.ts +++ b/packages/frontend/src/stdlib/theories/petri-net.ts @@ -83,6 +83,15 @@ export default function createPetriNetTheory(theoryMeta: TheoryMeta): Theory { return thSymMonoidalCategory.massActionEquations(model, data); }, }), + analyses.modelicaExport({ + generate(model, data) { + return thSymMonoidalCategory.massActionModelica( + model, + { massConservationType: { type: "Balanced" } }, + data, + ); + }, + }), analyses.stochasticMassAction({ id: "stochastic-mass-action", name: "Stochastic mass-action dynamics", diff --git a/packages/frontend/src/stdlib/theories/polynomial-ode.ts b/packages/frontend/src/stdlib/theories/polynomial-ode.ts index be3e3b03b..efdf869da 100644 --- a/packages/frontend/src/stdlib/theories/polynomial-ode.ts +++ b/packages/frontend/src/stdlib/theories/polynomial-ode.ts @@ -44,6 +44,9 @@ export default function createPolynomialODETheory(theoryMeta: TheoryMeta): Theor return thPolynomialODE.polynomialODESimulation(model, data); }, }), + analyses.modelicaExport({ + generate: (model, data) => thPolynomialODE.polynomialODEModelica(model, data), + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts index b99784d6f..b3e878f90 100644 --- a/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-signed-stock-flow.ts @@ -84,6 +84,15 @@ export default function createPrimitiveSignedStockFlowTheory(theoryMeta: TheoryM return thCategorySignedLinks.massActionEquations(model, data); }, }), + analyses.modelicaExport({ + generate(model, data) { + return thCategorySignedLinks.massActionModelica( + model, + { massConservationType: { type: "Balanced" } }, + data, + ); + }, + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts index ad31b91bf..acb8d3cbc 100644 --- a/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts +++ b/packages/frontend/src/stdlib/theories/primitive-stock-flow.ts @@ -74,6 +74,15 @@ export default function createPrimitiveStockFlowTheory(theoryMeta: TheoryMeta): return thCategoryLinks.massActionEquations(model, data); }, }), + analyses.modelicaExport({ + generate(model, data) { + return thCategoryLinks.massActionModelica( + model, + { massConservationType: { type: "Balanced" } }, + data, + ); + }, + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/reg-net.ts b/packages/frontend/src/stdlib/theories/reg-net.ts index ff6751faf..6c1b582b1 100644 --- a/packages/frontend/src/stdlib/theories/reg-net.ts +++ b/packages/frontend/src/stdlib/theories/reg-net.ts @@ -80,6 +80,18 @@ export default function createRegulatoryNetworkTheory(theoryMeta: TheoryMeta): T return thSignedCategory.lotkaVolterra(model, data); }, }), + analyses.modelicaExport({ + id: "modelica-export-linear", + name: "Modelica code (linear ODE)", + description: "Export the linear ODE system as a Modelica model", + generate: (model, data) => thSignedCategory.linearODEModelica(model, data), + }), + analyses.modelicaExport({ + id: "modelica-export-lotka-volterra", + name: "Modelica code (Lotka-Volterra)", + description: "Export the Lotka-Volterra ODE system as a Modelica model", + generate: (model, data) => thSignedCategory.lotkaVolterraModelica(model, data), + }), ], }); } diff --git a/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts b/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts index 63fb66988..08fd4abbd 100644 --- a/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts +++ b/packages/frontend/src/stdlib/theories/signed-polynomial-ode.ts @@ -61,6 +61,9 @@ export default function createSignedPolynomialODETheory(theoryMeta: TheoryMeta): return thSignedPolynomialODE.polynomialODESimulation(model, data); }, }), + analyses.modelicaExport({ + generate: (model, data) => thSignedPolynomialODE.polynomialODEModelica(model, data), + }), ], }); }