diff --git a/CHANGELOG.md b/CHANGELOG.md
index 931bd66..ace26cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Added
+- `qsp-codegen verify` subcommand: end-to-end self-test that generates C++,
+ scaffolds and builds a minimal `qsp_sim`, and compares its trajectories
+ against a MATLAB SimBiology reference (PASS/FAIL). The CLI now uses
+ `generate`/`verify` subcommands; the legacy `qsp-codegen --sbml … --out-dir …`
+ form still routes to `generate`.
+- `generate` now also emits a complete, ready-to-run `param_all.xml` (ICs +
+ parameters from the SBML), so the generated `qsp_sim` runs with no manual
+ `` wrapping or `qsp-refresh-param-xml` merge step.
+- SBML `` (user lambda) inlining at call sites.
+- Codegen-time validation of generated C++: catches `AUX_VAR_*`
+ use-before-definition (a dependency-ordering bug class) with a located,
+ human-readable error instead of an opaque downstream C++ compiler error.
+- Tests for the MathML→C++ converter, generated-code validation,
+ `functionDefinition` inlining, and SBML event parsing.
+
+### Fixed
+
+- MathML→C++ converter now handles SimBiology's `nthroot` (and a
+ broader set of ``-exported operators: `log2`/`log10`, `ceiling`, and the
+ hyperbolic / inverse-trig families). Previously emitted a `/* unknown op */`
+ comment that crashed the Jacobian `sympify`. Unrecognized operators now
+ raise a clear, named error.
+- Dependency-ordering bug for concentration assignment-rule species in a
+ dynamic-volume compartment (e.g. a drug concentration in a growing tumor
+ compartment): the compartment-volume temporary is now seeded into the
+ init / `update_y_other` emission closures, fixing an undefined `AUX_VAR_*`
+ reference in the generated C++.
+
- `qsp_sim` runtime flags `--time-unit days|seconds` and `--time-factor `
to override the compile-time time-scaling default per invocation. Lets a
single binary integrate both unit-annotated SBML (default, runs in SI
diff --git a/README.md b/README.md
index e7e6eba..2cc5571 100644
--- a/README.md
+++ b/README.md
@@ -7,36 +7,87 @@ consumed by a CVODEBase-backed QSP simulator:
- `QSP_enum.h`, `ODE_system.h`, `ODE_system.cpp`
- `QSPParam.h`, `QSPParam.cpp`
-- `qsp_params_xml_snippet.xml` (merged into consumer `param_all.xml` via
- `qsp-refresh-param-xml`)
+- `param_all.xml` — a complete, ready-to-run parameter file (initial
+ conditions + parameters taken straight from the SBML). Run the generated
+ `qsp_sim` against it directly — no hand-editing, no merge step.
+- `qsp_params_xml_snippet.xml` — the bare `` block, for consumers that
+ merge it into a maintained `param_all.xml` via `qsp-refresh-param-xml`.
+
+## Prerequisites
+
+- **Python ≥ 3.10** with `sympy` (for the analytical Jacobian) and `numpy`.
+- **A C++17 compiler + CMake ≥ 3.18** to build the generated simulator. The
+ first configure fetches and compiles SUNDIALS and yaml-cpp via CMake
+ `FetchContent` (one-time, a few minutes).
+- **MATLAB + SimBiology** only if you want to (a) export SBML from a
+ SimBiology model or (b) run `qsp-codegen verify` against a SimBiology
+ reference. Pure SBML-in → C++-out needs neither.
## Install
```bash
-pip install ~/Projects/qsp-codegen
+pip install ~/Projects/qsp-codegen # or: uv pip install -e ~/Projects/qsp-codegen
```
-## Usage
+## Quick start (SBML in → trajectories out)
```bash
-qsp-codegen --sbml path/to/PDAC_model.sbml --out-dir path/to/ode/
+# 1. Generate C++ + a ready-to-run param file from a SimBiology SBML export.
+qsp-codegen generate --sbml MyModel.sbml --out-dir build/qsp/ode
-qsp-refresh-param-xml \
- --snippet path/to/ode/qsp_params_xml_snippet.xml \
- --xml path/to/param_all.xml \
- --xml path/to/param_all_test.xml
+# 2. Build a minimal qsp_sim against the generated ODE (driver + ODE, default
+# no-op init hook). See "Bundled C++ driver" below for the CMakeLists; or
+# let `qsp-codegen verify` scaffold and build it for you.
+cmake -S build/sim -B build/sim/build -DCMAKE_BUILD_TYPE=Release
+cmake --build build/sim/build --target qsp_sim -j4
+
+# 3. Run it directly against the emitted param_all.xml — no hand-editing.
+build/sim/build/qsp_sim build/qsp/ode/param_all.xml out.csv 365 4.0
```
-Run whenever the QSP model structure changes (species/parameters/reactions).
-Not needed for parameter-value tweaks.
+The legacy form `qsp-codegen --sbml … --out-dir …` (no `generate` word) still
+works. Re-run codegen whenever the model *structure* changes
+(species/parameters/reactions); not needed for parameter-value tweaks.
+
+## Self-test: does the C++ match SimBiology?
+
+`qsp-codegen verify` runs the whole pipeline end-to-end — codegen, scaffold +
+build a minimal `qsp_sim`, then compare its trajectories against a MATLAB
+SimBiology reference over the same window — and reports PASS/FAIL:
-## Scope
+```bash
+qsp-codegen verify \
+ --sbml MyModel.sbml \
+ --matlab-dir /path/to/model/repo \ # has startup.m + the model script
+ --matlab-script build_my_model \ # builds `model`; must NOT `clear`
+ --stop-time 365
+```
-- Parses SBML Level 2 v4 (SimBiology export dialect).
-- Derives analytical Jacobian via sympy + CSE when sympy is available;
- falls back to numerical Jacobian otherwise.
-- Consumer-side invariants (sync checks, ABM-specific param codegen) are
- *not* in scope — they live in the consumer repo.
+## Supported SBML features
+
+Parses **SBML Level 2 v4** (the SimBiology export dialect):
+
+- **Rate-law math**: the full SBML L2 operator set, including SimBiology's
+ ``-exported named operators (`max`, `min`, `nthroot`, …), `` with
+ ``, and the trig / hyperbolic / log families.
+- **User functions**: SBML `` lambdas are inlined at their
+ call sites.
+- **Rules**: assignment (`repeatedAssignment`) and initial assignments,
+ including dynamic compartment volumes (e.g. a growing tumor compartment).
+- **Events**: single-comparison triggers (`lt`/`leq`/`gt`/`geq`) with event
+ assignments, mapped to CVODE root functions. *Not yet supported* (fail
+ loudly with a clear message): event ``s and compound (`and`/`or`)
+ triggers.
+- **Jacobian**: analytical via sympy + CSE when sympy is present; numerical
+ fallback otherwise.
+- **Units**: converts `` to SI for integration (see
+ *Time units* below).
+
+Anything outside this surface raises a **located, human-readable error** at
+codegen time (naming the operator/function/event) rather than emitting broken
+C++ — and the generator self-checks its output for undefined temporaries
+before writing. Consumer-side invariants (sync checks, ABM-specific param
+codegen) are out of scope — they live in the consumer repo.
## Bundled C++ driver (`qsp_sim_core`)
diff --git a/cpp/include/qsp_sim_core/model_hooks.h b/cpp/include/qsp_sim_core/model_hooks.h
index e217cbb..bb535bf 100644
--- a/cpp/include/qsp_sim_core/model_hooks.h
+++ b/cpp/include/qsp_sim_core/model_hooks.h
@@ -21,7 +21,7 @@
#include
#include
-namespace CancerVCT {
+namespace qsp_sim_core {
class ODE_system;
@@ -73,6 +73,6 @@ struct EvolveResult {
// success=true, t_diagnosis_days=0 — i.e. no pre-scenario evolve.
EvolveResult evolve_to_diagnosis(ODE_system& ode, const EvolveOpts& opts);
-} // namespace CancerVCT
+} // namespace qsp_sim_core
#endif
diff --git a/cpp/include/qsp_sim_core/trajectory_writer.h b/cpp/include/qsp_sim_core/trajectory_writer.h
index f140664..e1bcea6 100644
--- a/cpp/include/qsp_sim_core/trajectory_writer.h
+++ b/cpp/include/qsp_sim_core/trajectory_writer.h
@@ -44,7 +44,7 @@
#include
#include
-namespace CancerVCT {
+namespace qsp_sim_core {
class TrajectoryWriter {
public:
@@ -128,6 +128,6 @@ class TrajectoryWriter {
uint64_t n_times_ = 0;
};
-} // namespace CancerVCT
+} // namespace qsp_sim_core
#endif // QSP_SIM_CORE_TRAJECTORY_WRITER_H
diff --git a/cpp/src/default_hooks.cpp b/cpp/src/default_hooks.cpp
index aa78652..b048f76 100644
--- a/cpp/src/default_hooks.cpp
+++ b/cpp/src/default_hooks.cpp
@@ -10,7 +10,7 @@
#include "qsp_sim_core/model_hooks.h"
-namespace CancerVCT {
+namespace qsp_sim_core {
EvolveResult evolve_to_diagnosis(ODE_system& /*ode*/, const EvolveOpts& /*opts*/) {
EvolveResult r;
@@ -20,4 +20,4 @@ EvolveResult evolve_to_diagnosis(ODE_system& /*ode*/, const EvolveOpts& /*opts*/
return r;
}
-} // namespace CancerVCT
+} // namespace qsp_sim_core
diff --git a/cpp/src/qsp_sim_main.cpp b/cpp/src/qsp_sim_main.cpp
index 2be08f2..dc03332 100644
--- a/cpp/src/qsp_sim_main.cpp
+++ b/cpp/src/qsp_sim_main.cpp
@@ -74,7 +74,7 @@
#include "qsp_sim_core/model_hooks.h"
#include "qsp_sim_core/trajectory_writer.h"
-using namespace CancerVCT;
+using namespace qsp_sim_core;
namespace {
diff --git a/src/qsp_codegen/codegen.py b/src/qsp_codegen/codegen.py
index dbd1232..9dcef2f 100644
--- a/src/qsp_codegen/codegen.py
+++ b/src/qsp_codegen/codegen.py
@@ -51,11 +51,14 @@ def __init__(self, sbml_path: str):
self.initial_assignments: List[dict] = []
self.events: List[dict] = []
self.unit_defs: Dict[str, float] = {} # unit_id → SI factor
+ # SBML id → {"bvars": [...], "body": }
+ self.function_defs: Dict[str, dict] = {}
self._parse_units()
self._parse_compartments()
self._parse_parameters() # before species (species may ref param units)
self._parse_species()
+ self._parse_function_definitions() # before reactions/rules (they may call them)
self._parse_reactions()
self._parse_rules()
self._parse_initial_assignments()
@@ -317,6 +320,63 @@ def _parse_initial_assignments(self):
# --- MathML → Infix Converter ----------------------------------------
+ def _parse_function_definitions(self):
+ """Parse lambdas so calls can be inlined.
+
+ SBML stores a user function as ``x ...
+ ``. We keep the bound-variable names and the body
+ MathML element; :meth:`_inline_function_def` substitutes call-site
+ arguments for the bvars and converts the body in place.
+ """
+ for fd in self.model.findall(f".//{SBML_NS}functionDefinition"):
+ fid = fd.get("id")
+ math = fd.find(f"{MATH_NS}math")
+ if not fid or math is None:
+ continue
+ lam = math.find(f"{MATH_NS}lambda")
+ if lam is None:
+ continue
+ bvars, body = [], None
+ for child in lam:
+ if child.tag.replace(MATH_NS, "") == "bvar":
+ ci = child.find(f"{MATH_NS}ci")
+ if ci is not None and ci.text:
+ bvars.append(ci.text.strip())
+ else:
+ body = child # last non-bvar child is the function body
+ if body is not None:
+ self.function_defs[fid] = {"bvars": bvars, "body": body}
+
+ def _inline_function_def(self, name: str, arg_nodes) -> str:
+ """Inline a call to an SBML as infix C++.
+
+ Converts each actual argument, then converts the lambda body with the
+ bound variables temporarily resolving to those argument expressions.
+ Reentrant: nested/self-referential calls save and restore the shadowed
+ id_to_name entries.
+ """
+ fd = self.function_defs[name]
+ bvars, body = fd["bvars"], fd["body"]
+ if len(arg_nodes) != len(bvars):
+ raise ValueError(
+ f"SBML functionDefinition '{name}' expects {len(bvars)} "
+ f"argument(s), called with {len(arg_nodes)}."
+ )
+ arg_infix = [f"({self._mathml_to_infix(a)})" for a in arg_nodes]
+ missing = object()
+ saved = {}
+ for bv, ai in zip(bvars, arg_infix):
+ saved[bv] = self.id_to_name.get(bv, missing)
+ self.id_to_name[bv] = ai
+ try:
+ return f"({self._mathml_to_infix(body)})"
+ finally:
+ for bv, old in saved.items():
+ if old is missing:
+ self.id_to_name.pop(bv, None)
+ else:
+ self.id_to_name[bv] = old
+
def _mathml_to_infix(self, node) -> str:
"""Convert a MathML