Skip to content

Commit 79fd15b

Browse files
authored
Fix experiment initial guess regression (#5452)
* fix state mapper for missing variables * tests * Update CHANGELOG.md
1 parent 37dfaa5 commit 79fd15b

4 files changed

Lines changed: 184 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
## Bug fixes
1414

15+
- Fixed a regression causing inaccurate initial guesses at experiment step transitions. ([#5452](https://github.com/pybamm-team/PyBaMM/pull/5452))
16+
1517
## Breaking changes
1618

1719
# [v26.3.1](https://github.com/pybamm-team/PyBaMM/tree/v26.3.1) - 2026-04-10

src/pybamm/models/base_model.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,11 +1495,16 @@ def _resolve_from_var(target_var):
14951495
equations = []
14961496
for _target_slice, target_var, from_var, from_slice in entries:
14971497
if from_var is None:
1498-
# Keep the target-model initial condition for unmapped variables
1499-
physical = (
1500-
target_var.reference
1501-
+ target_var.scale * self.initial_conditions[target_var]
1502-
)
1498+
# If the variable is not a state variable of the previous model,
1499+
# query it by name. See #5449 for more details
1500+
try:
1501+
physical = from_model.get_processed_variable(target_var.name)
1502+
except KeyError as e:
1503+
raise pybamm.ModelError(
1504+
f"Cannot build initial state mapper: variable "
1505+
f"'{target_var.name}' is not a state in the previous "
1506+
f"model and has no matching named variable to reuse."
1507+
) from e
15031508
else:
15041509
physical = from_var.reference + from_var.scale * pybamm.StateVector(
15051510
from_slice

tests/integration/test_simulations.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import casadi
12
import numpy as np
23
import pytest
34

@@ -52,3 +53,60 @@ def test_pickle_roundtrip_exact(
5253
np.testing.assert_array_equal(
5354
sol_orig.yp, sol_loaded.yp, err_msg=f"{tag} sol.yp mismatch"
5455
)
56+
57+
58+
class TestSimulationConsistentState:
59+
def test_cv_initial_guess_uses_previous_current(self):
60+
charge_current = 15.0
61+
experiment = pybamm.Experiment(
62+
[
63+
(
64+
f"Charge at {charge_current}A until 4.2 V",
65+
"Hold at 4.2 V for 2 seconds",
66+
)
67+
]
68+
)
69+
sim = pybamm.Simulation(
70+
pybamm.lithium_ion.DFN(),
71+
parameter_values=pybamm.ParameterValues("Chen2020"),
72+
experiment=experiment,
73+
)
74+
sol = sim.solve(initial_soc=0.05)
75+
76+
# The mapper only runs when the two steps build distinct models.
77+
cc_step, cv_step = sim.experiment.steps
78+
cc_model = sim.steps_to_built_models[cc_step.basic_repr()]
79+
cv_model = sim.steps_to_built_models[cv_step.basic_repr()]
80+
assert cc_model is not cv_model
81+
82+
cc_solution = sol.cycles[0].steps[0]
83+
84+
# Replay BaseSolver.step's mapper invocation exactly: pull the
85+
# compiled (func, jac_y, jac_p) tuple, build the CV step's input
86+
# vector through the same code path, and call the func on the CC
87+
# step's last raw state vector.
88+
mapper_func, _, _ = sim._compiled_model_state_mappers[(cc_model, cv_model)]
89+
cv_inputs_dict = sim._build_experiment_step_inputs(
90+
{},
91+
cv_step,
92+
float(cc_solution.t[-1]),
93+
None,
94+
include_temperature=False,
95+
)
96+
cv_solver = sim._get_built_experiment_solver(cv_step)
97+
model_inputs = cv_solver._set_up_model_inputs(cv_model, cv_inputs_dict)
98+
p_vec = casadi.vertcat(*model_inputs.values())
99+
y_from = cc_solution.last_state.all_ys[0]
100+
seed = np.asarray(mapper_func(float(cc_solution.t[-1]), y_from, p_vec)).ravel()
101+
102+
# Decode the CV model's "Current variable [A]" entry from the seed.
103+
current_var = next(
104+
v for v in cv_model.y_slices if v.name == "Current variable [A]"
105+
)
106+
current_slice = cv_model.y_slices[current_var][0]
107+
seed_current_scaled = float(seed[current_slice][0])
108+
seed_current_phys = float(current_var.reference.evaluate()) + (
109+
float(current_var.scale.evaluate()) * seed_current_scaled
110+
)
111+
112+
assert seed_current_phys == pytest.approx(-charge_current, rel=1e-9)

tests/unit/test_models/test_base_model.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,120 @@ def test_set_initial_conditions_symbol_with_value_attribute(self):
14881488
disc_var2 = next(iter(model2_disc.initial_conditions.keys()))
14891489
assert isinstance(model2_disc.initial_conditions[disc_var2], pybamm.Vector)
14901490

1491+
def test_build_initial_state_mapper_reuses_input_value(self):
1492+
# ``from_model``: only ``s`` is a state. ``current`` is exposed as a
1493+
# named variable whose value comes from an InputParameter.
1494+
s_from = pybamm.Variable("s")
1495+
from_model = pybamm.BaseModel()
1496+
from_model.rhs = {s_from: -s_from}
1497+
from_model.initial_conditions = {s_from: pybamm.Scalar(0.5)}
1498+
from_model.variables = {
1499+
"s": s_from,
1500+
"current": pybamm.InputParameter("I_in"),
1501+
}
1502+
pybamm.Discretisation().process_model(from_model)
1503+
1504+
# ``to_model``: ``current`` is now an algebraic state with a *wrong*
1505+
# initial-condition guess of 0 (must NOT be used by the mapper).
1506+
s_to = pybamm.Variable("s")
1507+
c_to = pybamm.Variable("current")
1508+
to_model = pybamm.BaseModel()
1509+
to_model.rhs = {s_to: -s_to}
1510+
to_model.algebraic = {c_to: c_to - pybamm.Scalar(1.0)}
1511+
to_model.initial_conditions = {
1512+
s_to: pybamm.Scalar(0.0),
1513+
c_to: pybamm.Scalar(0.0),
1514+
}
1515+
to_model.variables = {"s": s_to, "current": c_to}
1516+
pybamm.Discretisation().process_model(to_model)
1517+
1518+
mapper = to_model.build_initial_state_mapper(from_model)
1519+
1520+
# Evaluate the mapper with a known previous state vector and input.
1521+
y_from = np.array([[0.7]])
1522+
result = np.asarray(
1523+
mapper.evaluate(t=0, y=y_from, inputs={"I_in": 3.0})
1524+
).ravel()
1525+
1526+
# Map (s, current) onto y_slices ordering of the target model.
1527+
s_slice = to_model.y_slices[s_to][0]
1528+
c_slice = to_model.y_slices[c_to][0]
1529+
assert result[s_slice][0] == pytest.approx(0.7)
1530+
# Critical: must equal the previous step's input, not the target IC (0).
1531+
assert result[c_slice][0] == pytest.approx(3.0)
1532+
1533+
def test_build_initial_state_mapper_handles_scale_and_reference(self):
1534+
from_scale_s = pybamm.Scalar(2.0)
1535+
from_ref_s = pybamm.Scalar(10.0)
1536+
s_from = pybamm.Variable("s", scale=from_scale_s, reference=from_ref_s)
1537+
from_model = pybamm.BaseModel()
1538+
from_model.rhs = {s_from: -s_from}
1539+
from_model.initial_conditions = {s_from: pybamm.Scalar(0.0)}
1540+
from_model.variables = {
1541+
"s": s_from,
1542+
"current": pybamm.InputParameter("I_in"),
1543+
}
1544+
pybamm.Discretisation().process_model(from_model)
1545+
1546+
to_scale_s = pybamm.Scalar(4.0)
1547+
to_ref_s = pybamm.Scalar(-5.0)
1548+
to_scale_c = pybamm.Scalar(0.5)
1549+
to_ref_c = pybamm.Scalar(100.0)
1550+
s_to = pybamm.Variable("s", scale=to_scale_s, reference=to_ref_s)
1551+
c_to = pybamm.Variable("current", scale=to_scale_c, reference=to_ref_c)
1552+
to_model = pybamm.BaseModel()
1553+
to_model.rhs = {s_to: -s_to}
1554+
to_model.algebraic = {c_to: c_to - pybamm.Scalar(1.0)}
1555+
to_model.initial_conditions = {
1556+
s_to: pybamm.Scalar(0.0),
1557+
c_to: pybamm.Scalar(0.0),
1558+
}
1559+
to_model.variables = {"s": s_to, "current": c_to}
1560+
pybamm.Discretisation().process_model(to_model)
1561+
1562+
mapper = to_model.build_initial_state_mapper(from_model)
1563+
1564+
# Choose physical values, encode via from_model scaling, decode in mapper.
1565+
phys_s = 17.0
1566+
y_s_from = (phys_s - 10.0) / 2.0 # 3.5
1567+
phys_current = 6.25
1568+
y_from = np.array([[y_s_from]])
1569+
result = np.asarray(
1570+
mapper.evaluate(t=0, y=y_from, inputs={"I_in": phys_current})
1571+
).ravel()
1572+
1573+
s_slice = to_model.y_slices[s_to][0]
1574+
c_slice = to_model.y_slices[c_to][0]
1575+
# s: matched-state branch must round-trip through physical units.
1576+
# expected = (17 - (-5)) / 4 = 5.5
1577+
assert result[s_slice][0] == pytest.approx((phys_s - (-5.0)) / 4.0)
1578+
# current: input-parameter branch.
1579+
# expected = (6.25 - 100) / 0.5 = -187.5
1580+
assert result[c_slice][0] == pytest.approx((phys_current - 100.0) / 0.5)
1581+
1582+
def test_build_initial_state_mapper_missing_variable_raises(self):
1583+
s_from = pybamm.Variable("s")
1584+
from_model = pybamm.BaseModel()
1585+
from_model.rhs = {s_from: -s_from}
1586+
from_model.initial_conditions = {s_from: pybamm.Scalar(0.5)}
1587+
from_model.variables = {"s": s_from}
1588+
pybamm.Discretisation().process_model(from_model)
1589+
1590+
s_to = pybamm.Variable("s")
1591+
new_var = pybamm.Variable("brand_new")
1592+
to_model = pybamm.BaseModel()
1593+
to_model.rhs = {s_to: -s_to}
1594+
to_model.algebraic = {new_var: new_var - pybamm.Scalar(1.0)}
1595+
to_model.initial_conditions = {
1596+
s_to: pybamm.Scalar(0.0),
1597+
new_var: pybamm.Scalar(0.0),
1598+
}
1599+
to_model.variables = {"s": s_to, "brand_new": new_var}
1600+
pybamm.Discretisation().process_model(to_model)
1601+
1602+
with pytest.raises(pybamm.ModelError, match="brand_new"):
1603+
to_model.build_initial_state_mapper(from_model)
1604+
14911605
def test_set_variables_error(self):
14921606
var = pybamm.Variable("var")
14931607
model = pybamm.BaseModel()

0 commit comments

Comments
 (0)