Repository navigation
fix: resolve coupled variables in boundary conditions without modifying the input model - #6002
brosaplanella wants to merge 4 commits into
Conversation
…ng the input model Resolve CoupledVariables before boundary conditions are discretised, so a CoupledVariable in a boundary condition no longer raises a DiscretisationError. With inplace=False, resolve into a copy of the model and build new equation dicts, so the input model is left unchanged. Fixes #6001 Co-Authored-By: Claude Opus 5.5 <[email protected]>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6002 +/- ##
==========================================
+ Coverage 98.23% 98.25% +0.01%
==========================================
Files 350 350
Lines 36173 36174 +1
==========================================
+ Hits 35533 35541 +8
+ Misses 640 633 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
| Project | PyBaMM |
| Branch | fix/coupled-variable-resolution |
| Testbed | bare-metal |
🐰 View full continuous benchmarking report in Bencher
⚠️ WARNING: Truncated view!The full continuous benchmarking report exceeds the maximum length allowed on this platform.
…ations Default Discretisation(resolve_coupled_variables=True), so models that use CoupledVariables in their equations no longer need the discretisation kwarg. Resolve all equations with one shared cache, so subtrees and coupled targets shared between equations are resolved once, and return the model unchanged (without copying it) when nothing needed resolving. Refs #6001 Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Pushed 25748fc, which makes What changed
Method
Results
¹ Marquis2019. ² Chen2020_composite, Takeaways
The full unit suite passes on this branch: 5,081 passed, 278 skipped. Environment: WSL2 on Linux 6.18, 8 cores, 7 GB RAM, Python 3.14.4, commit 25748fc. The 2M-state discretisation peaked at about 1.9 GB of memory. Benchmark script"""
Time discretisation, and build + solve, with ``resolve_coupled_variables`` off and on.
Run with ``uv run --no-sync python local/benchmark_resolve_default.py``.
"""
from __future__ import annotations
import argparse
import copy
import gc
import platform
import statistics
import subprocess
import time
import pybamm
pybamm.set_logging_level("ERROR")
DEGRADATION = {
"SEI": "solvent-diffusion limited",
"SEI porosity change": "true",
"lithium plating": "partially reversible",
"lithium plating porosity change": "true",
"particle mechanics": ("swelling and cracking", "swelling only"),
"SEI on cracks": "true",
"loss of active material": "stress-driven",
}
def pouch_3d_var_pts(y, z):
return {"x_n": 20, "x_s": 20, "x_p": 20, "r_n": 20, "r_p": 20, "y": y, "z": z}
# (name, model factory, parameter set, var_pts, solve)
CONFIGS = [
("SPM", lambda: pybamm.lithium_ion.SPM(), "Chen2020", None, True),
("SPMe", lambda: pybamm.lithium_ion.SPMe(), "Chen2020", None, True),
("DFN", lambda: pybamm.lithium_ion.DFN(), "Chen2020", None, True),
(
"DFN + lumped thermal",
lambda: pybamm.lithium_ion.DFN({"thermal": "lumped"}),
"Chen2020",
None,
True,
),
(
"DFN + x-full thermal",
lambda: pybamm.lithium_ion.DFN({"thermal": "x-full"}),
"Marquis2019",
None,
True,
),
(
"DFN composite anode",
lambda: pybamm.lithium_ion.DFN(
{
"particle phases": ("2", "1"),
"open-circuit potential": (("single", "current sigmoid"), "single"),
}
),
"Chen2020_composite",
None,
True,
),
(
"SPMe + degradation",
lambda: pybamm.lithium_ion.SPMe(DEGRADATION),
"OKane2022",
None,
True,
),
(
"DFN + degradation",
lambda: pybamm.lithium_ion.DFN(DEGRADATION),
"OKane2022",
None,
True,
),
(
"DFN + degradation + lumped thermal",
lambda: pybamm.lithium_ion.DFN({**DEGRADATION, "thermal": "lumped"}),
"OKane2022",
None,
True,
),
(
"DFN 1+1D pouch + x-lumped thermal",
lambda: pybamm.lithium_ion.DFN(
{
"current collector": "potential pair",
"dimensionality": 1,
"thermal": "x-lumped",
}
),
"Marquis2019",
None,
True,
),
*(
(
f"3D pouch DFN (unstructured, {y}×{z})",
lambda: pybamm.lithium_ion.BasicDFNUnstructured({"dimensionality": 2}),
"Marquis2019",
pouch_3d_var_pts(y, z),
False,
)
for y, z in [(6, 4), (24, 16), (33, 32), (46, 46)]
),
]
def make_simulation(model_factory, parameter_set, var_pts, flag=None):
kwargs = {} if flag is None else {"resolve_coupled_variables": flag}
return pybamm.Simulation(
model_factory(),
parameter_values=pybamm.ParameterValues(parameter_set),
var_pts=var_pts,
discretisation_kwargs=kwargs,
)
def unique_nodes(model):
exprs = [
*model.rhs.values(),
*model.algebraic.values(),
*model.initial_conditions.values(),
*(e for bcs in model.boundary_conditions.values() for e, _ in bcs.values()),
]
return len({node for expr in exprs for node in expr.pre_order()})
def time_it(fn):
gc.collect()
start = time.perf_counter()
result = fn()
return time.perf_counter() - start, result
def benchmark(name, model_factory, parameter_set, var_pts, solve, repeats):
# parameterise once as Simulation.build does; inplace=False leaves it reusable
sim = make_simulation(model_factory, parameter_set, var_pts)
sim._set_parameters()
model = sim._model_with_set_params
resolve_times = [
time_it(
lambda: pybamm.Discretisation()._resolve_coupled_variables_in_model(
model, inplace=False
)
)[0]
for _ in range(20)
]
# fresh mesh and spatial methods per repeat, as the unstructured finite volume
# method caches assembled operators on the submesh; repeat 0 is a warm-up
disc_times = {False: [], True: []}
states = {}
for repeat in range(repeats + 1):
for flag in (False, True):
# Mesh pops "tabs" from the geometry it is given
mesh = pybamm.Mesh(
copy.deepcopy(sim._geometry), sim._submesh_types, sim._var_pts
)
disc = pybamm.Discretisation(
mesh,
copy.deepcopy(sim._spatial_methods),
resolve_coupled_variables=flag,
)
elapsed, built = time_it(
lambda: disc.process_model(
model, inplace=False, delayed_variable_processing=True
)
)
if repeat > 0:
disc_times[flag].append(elapsed)
states[flag] = built.len_rhs_and_alg
if states[False] != states[True]:
raise RuntimeError(f"{name}: state counts differ between flag values")
e2e_times = {False: [], True: []}
if solve:
for repeat in range(repeats + 1):
for flag in (False, True):
sim_e2e = make_simulation(model_factory, parameter_set, var_pts, flag)
elapsed = time_it(lambda: sim_e2e.solve([0, 3600]))[0]
if repeat > 0:
e2e_times[flag].append(elapsed)
def median(times):
return statistics.median(times) if times else float("nan")
return {
"name": name,
"states": states[True],
"nodes": unique_nodes(model),
"resolve_ms": 1e3 * median(resolve_times),
"disc_off": median(disc_times[False]),
"disc_on": median(disc_times[True]),
"e2e_off": median(e2e_times[False]),
"e2e_on": median(e2e_times[True]),
}
def format_row(r):
def pct(on, off):
return "—" if off != off else f"{100 * (on - off) / off:+.1f}%"
def secs(t):
return "not solved" if t != t else f"{t:.3f} s"
return (
f"| {r['name']} | {r['states']:,} | {r['nodes']:,} | {r['resolve_ms']:.2f} ms "
f"| {r['disc_off']:.3f} s | {r['disc_on']:.3f} s "
f"| {pct(r['disc_on'], r['disc_off'])} "
f"| {secs(r['e2e_off'])} | {secs(r['e2e_on'])} "
f"| {pct(r['e2e_on'], r['e2e_off'])} |"
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--repeats", type=int, default=5)
parser.add_argument("--only", nargs="*", help="substrings of config names to run")
args = parser.parse_args()
commit = subprocess.run(
["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True
).stdout.strip()
print(
f"<!-- commit {commit}, Python {platform.python_version()}, "
f"repeats {args.repeats} -->"
)
print(
"| Model | States | Unique nodes | Resolve pass | Disc off | Disc on | Δ disc "
"| Build + solve off | Build + solve on | Δ build + solve |"
)
print("|---|---|---|---|---|---|---|---|---|---|")
for name, factory, parameter_set, var_pts, solve in CONFIGS:
if args.only and not any(s in name for s in args.only):
continue
result = benchmark(name, factory, parameter_set, var_pts, solve, args.repeats)
print(format_row(result), flush=True)
if __name__ == "__main__":
main()🤖 Generated with Claude Code |
Description
Fixes two bugs in
Discretisation(resolve_coupled_variables=True)reported in #6001:CoupledVariablein a boundary condition raised aDiscretisationError.process_modeldiscretised the boundary conditions before resolving coupled variables. The resolve step now runs beforeprocess_boundary_conditions.inplace=Falsestill rewrote the input model's equations. The resolve step wrote into themodelpassed in rather than into a copy. It now resolves intomodel.new_copy()wheninplace=False. It also assigns new equation dicts instead of writing into the existing ones, becausenew_copycopiesboundary_conditionsonly one level deep and the per-boundary dicts are shared with the original.This PR does not change the default of
resolve_coupled_variables, and it does not add the shared resolve cache suggested in #6001. Both are left for the discussion in the issue.Fixes #6001
Type of change
Bug fix. Entry added under
## Bug fixesinCHANGELOG.md.Tests
Added two regression tests to
tests/unit/test_expression_tree/test_coupled_variable.py. Both fail onmainand pass with this change:test_coupled_variable_in_boundary_conditionsolves a diffusion problem whose Neumann flux is aCoupledVariable, and checks it against the same problem with the flux written directly.test_resolve_does_not_modify_model_when_not_inplacechecks that the input model'srhsis untouched afterprocess_model(..., inplace=False).Important checks:
nox -s pre-commit. Onlyruff formatandruff checkhave been run so far; both are clean.nox -s tests.nox -s doctests. Not run yet.🤖 Generated with Claude Code