95 add bortfeld model and wilkens models - #203
Conversation
…update module exports - Enhanced the `let_dose` function in `let_wilkens.cpp` to handle multi-dimensional NumPy arrays for depth input, allowing for batch processing of LET calculations. - Updated the `__init__.py` file to include `proton_models` in the module exports, making it accessible for users.
95 add wilkens models
…ortfeld-model Signed-off-by: Witold Nieć <witold.niec@gmail.com>
Updated versions of black, isort, and clang-format in pre-commit configuration. Signed-off-by: Witold Nieć <witold.niec@gmail.com>
Add Bortfeld dose calculation model and update module imports
Proton dose & LET models — a primer for the team0. Why these models exist (60-second version)We treat tumours by firing protons into the body. The one fact that makes protons special:
X-rays dump most of their energy near the surface and keep going; protons let you place the "explosion" inside the tumour and spare tissue behind it. The two models here answer two questions:
Both are analytical: closed-form formulas evaluated directly, instead of a slow Monte-Carlo simulation. They run in microseconds — which is what makes them usable inside an optimisation loop. 1. Core vocabulary (read once, refer back forever)
Two unit traps:
2. The single assumption everything is built on: the range–energy power lawEvery formula in both papers falls out of one empirical rule: In words: a proton's range is its starting energy raised to a power.
Worked example — a 150 MeV proton in water: Measured value ≈ 15.8 cm. A two-constant formula lands within a couple of millimetres — that's why the whole approach works (a log–log plot of range vs energy is a straight line). For programmers: 3. The Bortfeld single Bragg curve — what
|
| Quantity | Averaged by | Symbol |
|---|---|---|
| Track-averaged LET | particle count (fluence) — plain mean of S | LET_t (Lt) |
| Dose-averaged LET | dose contribution — slow, high-S protons count more | LET_d (Ld) |
LET_t = ∫ w(r)·S(r) dr / ∫ w(r) dr (mean of S)
LET_d = ∫ w(r)·S²(r) dr / ∫ w(r)·S(r) dr (S-weighted mean of S)
w(r) is the spectrum (how many protons have residual range r). LET_d has S² on top, so it up-weights the slow high-stopping-power protons. Therefore:
LET_d ≥ LET_t always, and the gap grows near the Bragg peak. For monoenergetic protons the two are equal.
In radiobiology, dose-averaged LET (LET_d) is the one people actually use for RBE.
⛔ Why the review comment is correct
The PR currently names the function let_dose and documents it as "the LET dose." That phrase doesn't exist in the field and reads like "LET × dose," which is a different quantity. The C function it calls is AT_LET_d_Wilkens_keV_um_* — the _d means dose-averaged LET.
The fix is purely naming/wording:
| Current (wrong) | Correct |
|---|---|
let_dose |
let_dose_averaged (or dose_averaged_let) |
| "the LET dose" | "the dose-averaged LET (LET_d), in keV/µm" |
If the track-averaged variant gets wrapped later, it'd be let_track_averaged → AT_LET_t_Wilkens_*. Keeping _d / _t visible in the Python name mirrors the C API and removes the ambiguity for good.
6. The Wilkens model — LET, built directly on Bortfeld
Wilkens' insight (2003): if Bortfeld gets the dose curve from the power-law range–energy rule, the same machinery gives the LET curve. He reuses:
- the same power law
R = α E^p(p = 1.77), - the same stopping-power fit (
S(r) ∝ r^(1/p − 1)), - a Gaussian proton spectrum at each depth (mean residual range
R₀ − z, widthσfrom straggling + initial beam energy spread).
Plug those into the two averaging integrals and they solve in closed form (using parabolic cylinder functions — the LET analogue of Bortfeld's arctan/log for dose). Feed in beam energy + energy spread, get LET_t and LET_d at any depth, validated against Monte-Carlo to within 0.5 keV/µm.
One detail you'll see referenced as R (the regularization, 2 mm): the raw stopping-power formula blows up as r → 0 (a nearly-stopped proton has "infinite" stopping power on paper). Unphysical, so Wilkens averages S over the last 2 mm of range to tame it — the LET equivalent of how sigma/eps tame Bortfeld's dose singularity.
let_d = pm.let_dose_averaged( # suggested name
depth_cm, # depth(s) [cm]
material, # 1 = water [id]
energy_MeV, # beam energy [MeV]
energy_spread_MeV, # initial energy width σ_E [MeV]
) # → dose-averaged LET [keV/µm]7. How the pieces fit together
range–energy power law R = α·E^p ← the single shared assumption
│
┌────────────┴─────────────┐
▼ ▼
Bortfeld dose Wilkens LET
(energy WHERE?) (energy how CONCENTRATED?)
│ │
single Bragg peak LET_t (fluence-weighted mean of S)
(Bortfeld 1997 → LET_d (dose-weighted mean of S) ← what we wrap
dose_bortfeld) ↑ "dose-averaged LET", NOT "LET dose"
both: central-axis / broad-beam (large field on axis,
or integrated pencil-beam depth dose) — not narrow-pencil-on-a-small-detector
8. Concrete numbers to sanity-check the code
For a 150 MeV proton beam in water (material = 1):
| Quantity | Expect roughly | Why |
|---|---|---|
| Peak depth (range) | ~15.6 cm | 0.0022 × 150^1.77 |
| Entrance dose | small, flat | far from the peak |
| Peak dose | several × entrance | the 1/(R−d) blow-up, rounded by sigma/eps |
| LET_d in entrance | ~0.5 keV/µm | fast protons, dilute |
| LET_d at distal edge | several keV/µm, rising sharply | protons crawling to a stop |
| LET_d vs LET_t | LET_d ≥ LET_t | the S² weighting |
If dose_bortfeld puts the peak near 15–16 cm and the LET curve rises steeply at the distal edge while staying ~0.5 keV/µm in the entrance, the wrappers are behaving.
Minor aside spotted in
let_example.ipynb:pm.let_dose(depths, 1, 150, 1.5)raisesstd::bad_castwhendepthsis built vialist(numpy_array)— the C++ side handles a Pythonlist, afloat, or annb::ndarray<double>, but a list ofnumpy.float64matches none cleanly. Separate from the vocabulary fixes, but worth a follow-up.
Generated by Claude Code
…ty and error handling - Updated dose_bortfeld function to set default tail fraction (eps) to 0.03 instead of -1.0. - Enhanced let_wilkens function to include averaging parameter, allowing selection between dose-averaged and track-averaged LET calculations. - Added error handling for invalid averaging options in let_wilkens. - Improved documentation for both functions, clarifying parameters and return values.
- Changed the parameter type for 'material' in let_wilkens from 'long' to 'nb::object' to allow for more flexible input types. - Introduced a new helper function to validate and retrieve the material ID from either an integer or a Material object. - Updated the internal calls to use the new material ID instead of the original parameter. - Added validation for depth inputs to ensure they are non-negative. - Adjusted the function signature in the header file and the bindings to reflect the changes in parameter types.
…date bindings documentation
…ergy spread fraction
…rameter validations in bindings and documentation
…and update bindings documentation
…ial_argument and parse_material_argument functions, update dose_bortfeld and let_wilkens to use them, and adjust bindings accordingly.
…lity; update material argument handling and documentation
…ng for invalid material IDs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (7)
examples/let_wilkens.ipynb:144
cartesian=Trueis not a valid keyword argument. The bound parameter iscartesian_product. Passingcartesian=TrueraisesTypeErrorfor an unexpected keyword. (This line also uses the non-existentparticle_modelsmodule and the wrong positional argument order noted above.)
"results = pyamtrack.particle_models.let_wilkens(depths, pyamtrack.materials.water_liquid, 150, 0.01, averaging=pyamtrack.particle_models.Averaging.TRACK, cartesian=True)"
examples/dose_bortfeld.ipynb:27
- This example notebook was committed with a stale failed execution: the first cell's saved output is a
ModuleNotFoundError: No module named 'matplotlib', and all subsequent cells have no outputs. As shipped, this example does not demonstrate the Bortfeld model. Please re-run it in an environment with matplotlib installed (or clear the outputs) before committing.
{
"output_type": "error",
"ename": "ModuleNotFoundError",
"evalue": "No module named 'matplotlib'",
"traceback": [
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
"\u001b[31mModuleNotFoundError\u001b[39m Traceback (most recent call last)",
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 2\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m numpy \u001b[38;5;28;01mas\u001b[39;00m np\n\u001b[32m----> \u001b[39m\u001b[32m2\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m matplotlib.pyplot \u001b[38;5;28;01mas\u001b[39;00m plt\n\u001b[32m 3\u001b[39m \n\u001b[32m 4\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m pyamtrack\n\u001b[32m 5\u001b[39m \n",
"\u001b[31mModuleNotFoundError\u001b[39m: No module named 'matplotlib'"
]
}
],
src/proton_models/proton_models_bindings.cpp:116
- Typo in comment: "constucted" should be "constructed".
// enum constucted by chaining
src/proton_models/proton_models_bindings.cpp:58
- The
Raisessection refers to the internal C++ namesz_cm,E_MeV, andsigma_E_fraction, but the Python-facing parameters (documented above) aredepth_cm,energy_MeV, andenergy_spread_fraction. Use the public names here so the user-facing docstring is self-consistent.
If z_cm < 0, E_MeV outside [0.1, 10000.0], sigma_E_fraction outside (0, 1),
eps outside [0, 1), or a material ID is not a known material.
src/proton_models/proton_models_bindings.cpp:14
- The new
proton_modelsmodule (dose_bortfeldhere andlet_wilkensbelow) has no automated tests, while the other bound modules are covered (e.g.tests/test_stopping.py,tests/test_materials.py). Consider adding atests/test_proton_models.pycovering scalar/list/array inputs, thecartesian_productpath, theaveragingselector, and the validation error cases (negative depth, out-of-range energy/spread/eps, invalid material).
m.def("dose_bortfeld", &dose_bortfeld, nb::arg("depth_cm"), nb::arg("fluence_cm2"), nb::arg("energy_MeV"),
src/proton_models/dose_bortfeld.h:22
- The hardcoded range
[1, 24]is misleading. Material validation is table-driven (validate_material_argument), andmaterials_bindings.cppnow enumerates actual IDs viaget_ids()rather than assuming contiguous1..N, so material IDs are not guaranteed to be[1, 24]. Reference the canonical source instead.
* - material: int material_no in [1, 24] OR pyamtrack.materials.Material object
CMakeLists.txt:154
- These two top-level commands were indented by 2 spaces, which is inconsistent with the surrounding top-level statements in this file (e.g. lines 156-157). Remove the indentation to keep the file consistent.
# Create the Python module from the source file
nanobind_add_module(_core src/main.cpp)
| "metadata": {}, | ||
| "outputs": [], | ||
| "source": [ | ||
| "results = pyamtrack.particle_models.let_wilkens(depths, 1, 150, 0.01)" |
Critical review of #203 — Bortfeld dose & Wilkens LET modelsHow this was checked: I built the head commit ( What works well
Already flagged by the Copilot review and I agree, so I won't re-litigate: broken BlockingB1. The documented
|
| call | result |
|---|---|
let_wilkens(1.0, 150.0, 0.01, 0) |
ValueError: invalid material ID: 0 |
electron_range(120, 0) |
returns nan — no error |
let_wilkens(1.0, 150.0, 0.01, np.int64(1)) |
TypeError: material must be an int, a Material, ... |
electron_range(120, np.int64(1)) |
RuntimeError: An error occurred while processing the Material object: ... |
naming_convention.md presents material as one convention with one rule, so this divergence directly undercuts the doc. Migrating electron_range is a two-line change (validate_material_argument(material) + parse_material_argument(material) in place of get_id(...)) and would let process_material/get_id be deleted; if you'd rather not widen the PR, please soften the header comment and open a follow-up issue. Either way, the new helpers duplicate process_material + get_id rather than replacing them — the codebase now has two material paths where it had one.
H4. NumPy integer scalars are rejected, while NumPy integer arrays work
pm.let_wilkens(1.0, 150.0, 0.01, np.array([1, 2])) # OK
pm.let_wilkens(1.0, 150.0, 0.01, np.int64(1)) # TypeError
pm.let_wilkens(1.0, 150.0, 0.01, True) # OK -> silently material 1np.int64 is not a PyLong, so nb::isinstance<nb::int_> is false and it is neither a list nor an ndarray — it falls through to the type_error. This is easy to hit (ids[0], arr.max(), anything indexed out of an int array). PyIndex_Check accepts exactly the integer-like objects and rejects floats:
} else if (PyIndex_Check(argument.ptr()) && !nb::isinstance<nb::bool_>(argument)) {
id = nb::cast<long>(nb::steal(PyNumber_Index(argument.ptr())));
}That also closes the material=True hole. Note the same fix is needed in parse_material_argument so the two stay in agreement, and it should return nb::int_ rather than the original object.
H5. let_wilkens returns NaN past the beam range, undocumented
pm.let_wilkens(17.0, 150.0) # 24.88 keV/µm
pm.let_wilkens(18.0, 150.0) # nanOver np.linspace(0, 20, 2001) at 150 MeV with the default spread, 13.6 % of the returned values are NaN, silently. dose_bortfeld by contrast decays cleanly to 0.0 and stays finite everywhere I checked.
I pinned down the onset — it is not simply "past the range". In AT_LET_t/d_Wilkens_keV_um_single both nominator and denominator are clamped with if (x < 0) x = 0.0; and no zero guard follows, so once the Gaussian tail underflows the result is 0.0 / 0.0. Measured, the first NaN sits at exactly z − R = 5σ in every case, where R = α·E^p and σ = sqrt(σ_mono² + (σ_E·α·p·E^(p−1))²):
energy_spread_fraction |
σ [cm] | first NaN (150 MeV, R = 15.693 cm) | (z−R)/σ |
|---|---|---|---|
| 1e-6 | 0.1575 | 16.481 cm | 5.00 |
| 0.001 | 0.1599 | 16.493 cm | 5.00 |
| 0.01 (default) | 0.3181 | 17.284 cm | 5.00 |
| 0.05 | 1.3907 | 22.647 cm | 5.00 |
So the usable depth window silently depends on energy_spread_fraction — a sharper-beam request shrinks it. That is worth stating in the Returns section rather than leaving users to discover a NaN inside plt.plot or an optimiser. Options, in increasing order of effort: document it; or return 0.0 past the range in the binding; or fix the 0/0 upstream in libamtrack and treat this as a known-issue note here. At minimum the example notebooks should keep their depth grid inside the finite window (examples/let_wilkens.ipynb currently goes to 20 cm at 150 MeV).
Medium
M1. export_values() leaks DOSE/TRACK into the module namespace
>>> [x for x in dir(pyamtrack.proton_models) if not x.startswith('_')]
['Averaging', 'DOSE', 'TRACK', 'dose_bortfeld', 'let_wilkens']pyamtrack.proton_models.DOSE is now public API, undocumented in naming_convention.md (which lists only Averaging.DOSE/Averaging.TRACK). export_values() is the pybind11-era idiom for unscoped enums; for an enum class it just pollutes. Drop it. Also move the nb::enum_ registration above the two m.def calls — right now the type used by averaging is registered after the functions that accept it, which reads backwards.
M2. valid_material_ids is mutable, undocumented in the inventory, and untested
>>> pyamtrack.materials.valid_material_ids.add(999) # succeeds, and persists process-wideThree fixes: expose a frozenset (build the nb::set, then nb::steal(PyFrozenSet_New(s.ptr()))); add it to the pyamtrack.materials inventory in naming_convention.md (the id entry references it, but the inventory at §"Current Python API" doesn't list it); add a test. Also worth asking whether it earns its place at all next to the existing get_ids() — two public spellings of the same data is what the new doc is trying to prevent.
M3. Header declarations disagree with the definitions, and carry dead defaults
dose_bortfeld.h:26-28 declares z_cm, fluence_cm2, E_MeV, sigma_E_fraction; the definition uses depth_cm, ..., energy_MeV, energy_spread_fraction. Legal C++, but the header is what a reader reaches for, and its doc comment then documents names that don't exist in Python.
Worse, the declarations carry C++ default arguments — nb::int_(1), nb::float_(0.03), nb::cast(std::string("dose")) — that are (a) unreachable, since nb::arg(...) = ... in the bindings supplies the real defaults, and (b) a footgun: a default argument is evaluated at each call site, so these construct Python objects and require a live interpreter. Drop them and align the names with the definitions.
M4. Runtime error messages in dose_bortfeld.cpp use the internal names
Copilot flagged this for the docstring's Raises block; the same bug is in the messages a user actually sees:
ValueError: sigma_E_fraction must be in (0, 1), got: 0.000000
ValueError: z_cm must be >= 0, got: -1.000000
ValueError: E_MeV must be in [0.1, 10000.0], got: 0.000000
None of sigma_E_fraction, z_cm, E_MeV is a parameter of pyamtrack.proton_models.dose_bortfeld. let_wilkens.cpp gets this right — please match it.
M5. averaging is case-sensitive against its own enum
Averaging.DOSE is spelled uppercase, but averaging="DOSE" raises ValueError. A std::transform(..., ::tolower) before comparing removes a pointless trap.
M6. The docstring guarantees LET_d >= LET_t always; the implementation doesn't
Over np.linspace(0, 20, 2001) at 150 MeV, 358 of 2001 samples have LET_d marginally below LET_t, up to 1.07e-10 relative. That's an upstream numerical artifact at small spectral width, not a physics error — but the docstring states it as an absolute guarantee, and a test written from it fails. Either soften the wording ("up to numerical precision") or state a tolerance.
M7. validate_material_argument / parse_material_argument duplicate their dispatch
The two functions carry the same four-branch type dispatch, differing only in what they do at the leaf, and both are called on every request — so a NumPy material array is traversed three times (tolist() for validation, tolist() again for parsing, then the wrapper's own pass). One function that validates as it converts and returns the parsed object is smaller and strictly faster. Small nit inside it: nb::cast<long>(nb::cast<Material>(argument).id) resolves to the C++→Python overload only because .id on a temporary is an xvalue — nb::int_(nb::cast<Material>(argument).id) says what it means.
Relatedly, nb::type_error messages differ between the two functions ("an int, a Material, or a list / NumPy array of either" vs "an integer, Material, list, or NumPy array") for the same condition; which one a user sees depends on which function trips first.
M8. docs/naming_convention.md — good doc, a few claims don't match the code
- Multi-argument functions return
np.ndarrayfor list input, not a list (verified). §"Scalar, list, and NumPy arguments" is ambiguous here, and the existingelectron_rangedocstring is outright wrong ("a Python list for a list input") — this doc is the right occasion to fix that. - "arrays must be C-contiguous" is unconditional, but only the cartesian path enforces it:
pm.let_wilkens(np.arange(0, 10, dtype=float)[::2], 150.0)works fine elementwise. valid_material_idsis referenced underidbut missing from the API inventory (M2).- Nothing links to the file —
docs/README.mdlinkspre-commit.mdandtests.mdbut not this. Worth adding, or the doc will be forgotten. - Should mention that
materialacceptsbooltoday (H4) — or better, stop accepting it.
Low / nits
proton_models_bindings.cpp:116failsclang-format(tab indent), i.e.pre-commitwasn't run on this branch. CI never checks it —.github/workflows/main.ymlruns only build+pytest. Adding apre-commit run --all-filesjob would have caught this and theCMakeLists.txtindentation automatically; worth doing separately.proton_models_bindings.cppincludesnanobind/stl/map.h,stl/vector.handstl/string.h, and uses none of them — the file contains nostd::map,std::vectororstd::stringat all.Material temp(ids[i]); m.attr(names[i].c_str()) = temp;— the temporary adds nothing. The loop is also bounded byids.size()while indexingnames[i]; they agree today (both skip table row 0 → 24 entries), but an explicitassert/size check documents the coupling for free.materials.cpp— the three explanatory comments deleted fromto_name()are unrelated churn; please restore them (they explain non-obviousstd::remove_if/std::transformlines).(long)mat_id— the codebase otherwise usesstatic_cast<>.dose_bortfelddocstring:energy_spread_fractionis missingoptionalandDefault: 0.01although the binding defaults it; the same entry mixes public and internal names ("sigma_E_MeV = energy_spread_fraction * E_MeV").let_wilkensdocstring says "CallsAT_LET_d_Wilkens_keV_um" — the actual call is..._single.dose_bortfeld.h:13— "and optionally other args if you pass arrays" is informal for a public header.examples/dose_bortfeld.ipynb:np.linspace(0.0, 35.0, 800)puts >half the plot beyond a 150 MeV range (peak at 15.4 cm) —0..20shows the curve. The cells also use the old names as local variables (z_cm,E_MeV,sigma_E_fraction) and pass everything positionally, which undercuts the naming doc;depth_cm=..., energy_MeV=...would double as documentation. And no markdown narrative, unlikeexamples/example.ipynb.matplotlibis not inrequirements-dev.txt— which is exactly why the committed notebook's only saved output is aModuleNotFoundError. Please add it (or anexamplesextra) sopip install -r requirements-dev.txtgives you a runnable examples environment.- 30 commits including several merge commits — fine if you squash, but the description then needs updating (B3).
- Pre-existing, but this PR widens the exposure:
CMakeLists.txt:129pins libamtrack toGIT_TAG master. The new bindings depend onAT_ProtonAnalyticalModels.hsymbols, so an upstream rename now breaks builds of released tags retroactively. Pinning a tag/SHA is worth a follow-up issue.
Suggested order of work
- Fix
examples/let_wilkens.ipynb, or drop it from this PR (B3); update the PR description. - Add
tests/test_proton_models.py(B2) — it will fail on B1 and H1, which is the point. - Fix the wrapper's exception swallowing + leak (B1).
- Relax the spread bound to
[0, 1)(H1); align theMaterialconstructor with the shared rule and note theMaterial(0)behaviour change (H2). - Accept NumPy integer scalars, reject
bool(H4); documentNaNpast the range (H5); dropexport_values()(M1); rename the internal names in error messages (M4). - Then the doc/naming cleanups (M2, M3, M8) and nits.
Items H3 (unifying electron_range) and the pre-commit-in-CI / libamtrack-pinning suggestions are reasonable follow-up issues rather than blockers for this PR.
Generated by Claude Code
…thub.com/libamtrack/pyamtrack into 95-add-bortfeld-model-and-wilkens-models
…gs and update documentation accordingly
… multi-argument behavior
…odels and stopping functions; update documentation and tests accordingly.
| throw std::invalid_argument("depth_cm must be >= 0. Negative depth has no physical meaning: got " + | ||
| std::to_string(depth)); | ||
| if (E < 0.1 || E > 10000.0) | ||
| throw std::invalid_argument("energy_MeV must in range [0.1, 10000.0], got: " + std::to_string(E)); |
| If z_cm < 0, E_MeV outside [0.1, 10000.0], sigma_E_fraction outside (0, 1), | ||
| eps outside [0, 1), or a material ID is not a known material. |
| If depth_cm is not a float, list, or NumPy array, or material is not | ||
| an int, Material, list, or NumPy array, or if material is a bool. | ||
| )pbdoc"); | ||
| // enum constucted by chaining |
| #include <nanobind/stl/set.h> | ||
| #include <nanobind/stl/string.h> | ||
| #include <nanobind/stl/vector.h> | ||
|
|
||
| #include <iostream> | ||
| #include <set> |
| # Create the Python module from the source file | ||
| nanobind_add_module(_core src/main.cpp) |
| nb::object dose_bortfeld(const nb::object& z_cm, const nb::object& fluence_cm2, const nb::object& E_MeV, | ||
| const nb::object& sigma_E_fraction, const nb::object& material = nb::int_(1), | ||
| const nb::object& eps = nb::float_(0.03), bool cartesian_product = false); |
| "This notebook demonstrates the two analytical proton-beam models exposed by `pyamtrack`:\n", | ||
| "\n", | ||
| "- `dose_bortfeld` calculates the absorbed dose profile from the Bortfeld Bragg-curve approximation.\n", | ||
| "- `let_wilkens` calculates dose-averaged (`LET_d`) and track-averaged (`LET_t`) linear energy transfer using the Wilkens & Oelfke model.\n", |
This pull request introduces several improvements to the pyamtrack project, focusing on expanding functionality, clarifying Python API conventions, and providing practical usage examples. The most significant changes are the addition of a new module to the build process, detailed documentation of Python argument naming conventions, and a new example notebook demonstrating the
dose_bortfeldfunction.Build system update:
proton_modelsto thePYAMTRACK_TARGETSinCMakeLists.txt, ensuring that the proton models module is built and linked as part of the project.Documentation enhancements:
naming_convention.mddocument detailing the rules and current state of Python argument names and calling conventions for the nanobind-based API. This includes general naming rules, argument types, default values, validation, and a dictionary of all public arguments.Examples and usage:
examples/dose_bortfeld.ipynbthat demonstrates how to use theproton_models.dose_bortfeldfunction, including parameter setup, function call, and plotting the resulting dose profile.