diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 0c4d72e5..fc0d88f8 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -23,7 +23,7 @@ Upcoming Version *Build a model from a math-spec program* -* ``Model.from_spec`` / ``model.add_spec`` build a model from a `math-spec `__ YAML program attached to data, and ``model.spec`` (a ``linopy.spec.ModelSpec``) reads it back. Requires the ``spec`` dependency group (``uv sync --group spec`` / ``uv pip install --group spec``, Python >= 3.12) and v1 semantics. Data is attached onto the spec's dimensions and parameters with ``linopy.spec.attach``, raising a ``linopy.spec.SpecDataError`` on mismatched or missing data; ``linopy.spec.Attached`` carries the attached result. The spec API emits an :class:`linopy.EvolvingAPIWarning` once per session while it stabilises. See :doc:`building-models-from-specs` for a worked example. +* ``Model.from_spec`` / ``model.add_spec`` build a model from a `math-spec `__ YAML program attached to data, and ``model.spec`` (a ``linopy.spec.ModelSpec``) reads it back. Requires the ``spec`` dependency group (``uv sync --group spec`` / ``uv pip install --group spec``, Python >= 3.12) and v1 semantics. Data is attached onto the spec's dimensions and parameters with ``linopy.spec.attach``, raising a ``linopy.spec.SpecDataError`` on mismatched or missing data; ``linopy.spec.Attached`` carries the attached result. The parameters the spec retains live in ``model.spec.parameters``, its own dataset; ``model.parameters`` stays the caller's and a build never writes to it. ``retain`` decides what a netcdf file holds, not what a session can read: a parameter it dropped is resolved from the sources the model was built with, and only a model read back from a file can run out of data. The spec API emits an :class:`linopy.EvolvingAPIWarning` once per session while it stabilises. See :doc:`building-models-from-specs` for a worked example. * ``model.spec.expressions`` (a ``linopy.spec.NamedExpressions`` mapping) returns a ``linopy.spec.NamedExpression`` for each declared name, with three views: ``.node`` (the lowered formula), ``.expression`` (the unsolved linopy expression — a ``LinearExpression``, bare ``Variable``, array or scalar) and ``.solution`` (the expression folded over the solved model). ``model.spec.evaluate(name, sources)`` returns the same object with its parameters attached afresh. diff --git a/examples/building-models-from-specs.ipynb b/examples/building-models-from-specs.ipynb index 09495cd4..cfb2ec04 100644 --- a/examples/building-models-from-specs.ipynb +++ b/examples/building-models-from-specs.ipynb @@ -343,7 +343,14 @@ "id": "18", "metadata": {}, "outputs": [], - "source": "print(\"spend.to_latex(): \", spend.to_latex())\nprint(\"power_balance.to_latex():\", m.spec.declaration(\"power_balance\").to_latex())\nprint(\"p.to_latex(): \", m.spec.declaration(\"p\").to_latex())\n\n# each renders as its own formula in a notebook:\nMarkdown(f\"$$\\n{m.spec.declaration('power_balance').to_markdown()}\\n$$\")" + "source": [ + "print(\"spend.to_latex(): \", spend.to_latex())\n", + "print(\"power_balance.to_latex():\", m.spec.declaration(\"power_balance\").to_latex())\n", + "print(\"p.to_latex(): \", m.spec.declaration(\"p\").to_latex())\n", + "\n", + "# each renders as its own formula in a notebook:\n", + "Markdown(f\"$$\\n{m.spec.declaration('power_balance').to_markdown()}\\n$$\")" + ] }, { "cell_type": "markdown", @@ -377,16 +384,25 @@ "## 5. `retain`: what data stays on the model\n", "\n", "Folding needs the parameters an expression reads. `retain` controls which\n", - "parameters linopy keeps in `model.parameters` after building:\n", + "parameters linopy keeps in `model.spec.parameters` after building.\n", + "That is the spec's own dataset — `model.parameters` stays yours, and a\n", + "build never writes to it:\n", "\n", - "| `retain` | keeps in `model.parameters` |\n", + "| `retain` | keeps in `model.spec.parameters` |\n", "|------------|-------------------------------------------------|\n", "| `\"report\"` | only parameters the named expressions read (default) |\n", "| `\"all\"` | every parameter |\n", "| `\"none\"` | nothing |\n", "\n", "`spend` reads `cost`, `usage` reads `p_max`, neither reads `load` — so\n", - "`\"report\"` keeps `cost` and `p_max` but drops `load`." + "`\"report\"` keeps `cost` and `p_max` but drops `load`.\n", + "\n", + "Dropping is about *storage*, not about what you can read. A parameter\n", + "`retain` left out is resolved from the `sources` you built with, which the\n", + "model keeps hold of — so every `retain` folds the same in this session.\n", + "It is writing the model to netCDF that leaves the sources behind: read that\n", + "file back and only what `retain` kept is still there, with\n", + "`m.spec.evaluate(name, sources)` as the way in for the rest." ] }, { @@ -398,7 +414,9 @@ "source": [ "for retain in [\"report\", \"all\", \"none\"]:\n", " mm = Model.from_spec(DISPATCH, dispatch_data, retain=retain)\n", - " print(f\"retain={retain!r:9} -> parameters kept: {sorted(mm.parameters.data_vars)}\")" + " print(\n", + " f\"retain={retain!r:9} -> parameters kept: {sorted(mm.spec.parameters.data_vars)}\"\n", + " )" ] }, { diff --git a/linopy/io.py b/linopy/io.py index 5e1b09d2..275cb2b7 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1160,8 +1160,9 @@ def to_netcdf(m: Model, *args: Any, **kwargs: Any) -> None: :func:`linopy.io.read_netcdf`. The insertion order of each container is stored as a JSON list in the ``_linopy__order`` attribute. - A model built with :meth:`Model.add_spec` also persists its spec: the - YAML text, the master coordinates and the lookups. ``read_netcdf`` + A model built with :meth:`Model.add_spec` also persists its spec under a + ``spec-`` prefix of its own: the YAML text, the master coordinates and the + parameters the spec retained, apart from ``m.parameters``. ``read_netcdf`` lowers the program from the text again, so reading such a file needs the ``math-spec`` package; a file without a spec does not. @@ -1209,14 +1210,12 @@ def to_netcdf(m: Model, *args: Any, **kwargs: Any) -> None: if m.objective.value is not None: objective = objective.assign_attrs(value=m.objective.value) obj = [with_prefix(objective, "objective")] - parameters = m.parameters specs: list[xr.Dataset] = [] if m._spec is not None: from linopy.spec.netcdf import encode - parameters, spec_ds = encode(m._spec) - specs = [spec_ds] - params = [with_prefix(record_dtypes(parameters), "parameters")] + specs = [encode(m._spec)] + params = [with_prefix(record_dtypes(m.parameters), "parameters")] scalars = {k: getattr(m, k) for k in m.scalar_attrs} ds = xr.merge( @@ -1499,7 +1498,7 @@ def _copy_con_data(con: ConstraintBase) -> xr.Dataset: new_model._parameters = m._parameters.copy(deep=deep) if m._spec is not None: - new_model._spec = m._spec._reattach(new_model) + new_model._spec = m._spec._reattach(new_model, deep=deep) new_model._blocks = m._blocks.copy(deep=deep) if m._blocks is not None else None for attr in m.scalar_attrs: diff --git a/linopy/model.py b/linopy/model.py index 2e127973..41c1e2bd 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -482,8 +482,11 @@ def add_spec( Data keyed by declared name: dimension labels, parameters and lookups. Read by key on demand and never iterated. retain : {"report", "all", "none"} - Which parameters to keep in ``model.parameters``: those the named - expressions read, all of them, or none. + Which parameters to keep in ``model.spec.parameters``: those the + named expressions read, all of them, or none. ``model.parameters`` + stays the caller's and is never written to. This decides what a + netcdf file holds, not what this session can read: ``model.spec`` + falls back to ``sources`` for a parameter it did not keep. Returns ------- diff --git a/linopy/spec/accessor.py b/linopy/spec/accessor.py index fff7e30f..bb588a2a 100644 --- a/linopy/spec/accessor.py +++ b/linopy/spec/accessor.py @@ -1,10 +1,18 @@ """ ``model.spec``: the program a model was built from, and its named expressions as data. -The model owns the data. The spec text, the retained parameters, the lookups -and the master coordinates all sit on the model, so this accessor holds -nothing a round trip through a file could lose: it re-lowers the text and -reads ``model.parameters``. +The spec owns its data. The spec text, the retained parameters, the lookups +and the master coordinates sit on the accessor rather than in +``model.parameters``, which stays the caller's: a spec never overwrites what +was put there, and nothing reading a spec-built model has to guess which of +its parameters the spec owns. All of it round trips through a file, written +under the ``spec-`` prefix. + +A parameter is resolved the same way however much of it was retained: from +the retained dataset, else from the sources the model was built with, which +the accessor keeps for as long as the model lives. So ``retain`` decides what +a *file* holds, not what a session can read, and it is only after a round trip +that a parameter can be out of reach. """ from __future__ import annotations @@ -77,13 +85,18 @@ def attach( text, program = _source(spec) attached: Attached = attach_data(program, sources, retain=retain) build(model, attached) - model.parameters = attached.retained().assign_coords(dict(attached.coords)) - return ModelSpec(model, program, text) + parameters = attached.retained().assign_coords(dict(attached.coords)) + return ModelSpec(model, program, text, parameters, attached) -def restore(model: Model, text: str) -> ModelSpec: - """The accessor for *model*, with the program lowered afresh from *text*.""" - return ModelSpec(model, to_program(yaml.safe_load(text)), text) +def restore(model: Model, text: str, parameters: xr.Dataset) -> ModelSpec: + """ + The accessor for *model*, with the program lowered afresh from *text*. + + Read from a file, so the sources the model was built with are gone and + only what ``retain`` kept can be read back. + """ + return ModelSpec(model, to_program(yaml.safe_load(text)), text, parameters, None) def _source(spec: SpecLike) -> tuple[str, ms.Program]: @@ -123,10 +136,19 @@ class ModelSpec: The spec as YAML, verbatim where a file or text was passed. """ - def __init__(self, model: Model, program: ms.Program, text: str) -> None: + def __init__( + self, + model: Model, + program: ms.Program, + text: str, + parameters: xr.Dataset, + attached: Attached | None, + ) -> None: self._model = model self.program = program self.text = text + self._parameters = parameters + self._attached = attached def __repr__(self) -> str: p = self.program @@ -143,14 +165,20 @@ def __repr__(self) -> str: rows.append(_row("Expressions", list(p.named_expressions))) return "\n".join(rows) - def _reattach(self, model: Model) -> ModelSpec: - """The same spec, read off *model*.""" - return ModelSpec(model, self.program, self.text) + def _reattach(self, model: Model, deep: bool = True) -> ModelSpec: + """The same spec, read off *model*, holding its own copy of the parameters.""" + return ModelSpec( + model, + self.program, + self.text, + self._parameters.copy(deep=deep), + self._attached, + ) @property def parameters(self) -> xr.Dataset: - """The parameters and lookups retained on the model, on the master coordinates.""" - return self._model.parameters + """The parameters and lookups the spec retained, on the master coordinates.""" + return self._parameters @property def description(self) -> str: @@ -222,8 +250,9 @@ def evaluate( """ The named expression *name*, with its parameters attached afresh from *sources*. - For a model built with ``retain="none"``, or an expression reading a - parameter ``retain="report"`` did not keep. *sources* is read the way + For reading the spec against other data than the model was built with, + and for a model read from a file, whose own sources are gone. + ``model.spec.expressions`` needs neither. *sources* is read the way ``add_spec`` read it, and must describe the coordinates the model was built on. @@ -244,14 +273,18 @@ def evaluate( ) return NamedExpression(self, name, self._context(attached.parameter)) - def _retained(self, name: str) -> xr.DataArray: - if name not in self.parameters: - raise SpecDataError( - f"parameter '{name}' is not retained on the model: retain='report' keeps only what " - f"the named expressions read, and retain='none' keeps nothing. Build with " - f"retain='all', or read the expression with evaluate(name, sources)." - ) - return self.parameters[name] + def _resolve(self, name: str) -> xr.DataArray: + """The parameter *name*: retained if it was kept, else read from the sources again.""" + if name in self.parameters: + return self.parameters[name] + if self._attached is not None: + return self._attached.parameter(name) + raise SpecDataError( + f"parameter '{name}' was not retained and this model no longer holds the sources " + f"it was built with, which is what a model read from a file looks like. Build with " + f"retain='all' before writing it out, or read the expression with " + f"evaluate(name, sources)." + ) def _context(self, resolve: Resolve) -> Context: return Context( @@ -277,7 +310,7 @@ def __getitem__(self, name: str) -> NamedExpression: + did_you_mean(name, self._spec.program.named_expressions) ) return NamedExpression( - self._spec, name, self._spec._context(self._spec._retained) + self._spec, name, self._spec._context(self._spec._resolve) ) def __iter__(self) -> Iterator[str]: @@ -326,8 +359,9 @@ class NamedExpression(Declaration): One named expression, in three views: its math, its linopy fold and its solution. The object pins the data sources it was made with for its lifetime, so the - three views agree. ``expressions[name]`` reads the retained parameters and - the solution the model holds; ``evaluate(name, sources)`` attaches fresh data. + three views agree. ``expressions[name]`` reads the model's own data -- + what ``retain`` kept, and the sources behind it for the rest; + ``evaluate(name, sources)`` attaches fresh data instead. Attributes ---------- @@ -371,7 +405,8 @@ def solution(self) -> xr.DataArray: RuntimeError The model reads a variable but holds no solution yet. SpecDataError - A parameter the body reads was not retained. + A parameter the body reads was neither retained nor + still reachable through the model's sources. """ return fold(self._name, self._ctx) diff --git a/linopy/spec/netcdf.py b/linopy/spec/netcdf.py index 2a62e1ea..7f1a2813 100644 --- a/linopy/spec/netcdf.py +++ b/linopy/spec/netcdf.py @@ -41,6 +41,7 @@ PREFIX = "spec" COORD = "coords__" +PARAM = "param__" CODES = "codes__" CATEGORIES = "cats__" CATEGORY_DIM = "category__" @@ -48,39 +49,40 @@ HOLES: dict[str, Any] = {"f": np.nan, "O": np.nan, "M": np.datetime64("NaT")} -def encode(spec: ModelSpec) -> tuple[xr.Dataset, xr.Dataset]: +def encode(spec: ModelSpec) -> xr.Dataset: """ - The model's parameters without the coded arrays, and the spec's own dataset. - - The spec dataset carries the spec text as its one attribute, which the - merge lifts to the file's, and holds one array of labels per master - coordinate and, per coded array, its codes and its categories. It carries no coordinates of - its own: an index coordinate is dropped on read together with the - dimension it indexes once no data variable is left over that dimension, - and a master coordinate nothing else reaches has exactly that shape. + The spec's own dataset: its text, its master coordinates and its parameters. + + The spec text is the dataset's one attribute, which the merge lifts to the + file's. Beside it sits one array of labels per master coordinate and, per + parameter, either its values or -- where it is coded -- its codes and its + categories. The dataset carries no coordinates of its own: an index + coordinate is dropped on read together with the dimension it indexes once + no data variable is left over that dimension, and a master coordinate + nothing else reaches has exactly that shape. So a parameter is written + over bare dimensions and put back on the master coordinates on read. """ - parameters = spec.parameters arrays: dict[str, xr.DataArray] = { COORD + dim: _array(index.to_numpy(), (dim,)) for dim, index in spec.coords.items() } - for name in _coded(spec): - arrays.update(_encode(name, parameters[name])) - parameters = parameters.drop_vars(name) - written = with_prefix(xr.Dataset(arrays), PREFIX).assign_attrs( - {SPEC_ATTR: spec.text} - ) - return parameters, written + coded = _coded(spec) + for name, arr in spec.parameters.items(): + if str(name) in coded: + arrays.update(_encode(str(name), arr)) + else: + arrays[PARAM + str(name)] = _array(arr.to_numpy(), arr.dims, str(arr.dtype)) + return with_prefix(xr.Dataset(arrays), PREFIX).assign_attrs({SPEC_ATTR: spec.text}) def decode(model: Model, ds: xr.Dataset, text: str) -> ModelSpec: """ - Re-lower *text* onto *model* and put its coded arrays and coordinates back. + Re-lower *text* onto *model* and read back the dataset :func:`encode` wrote. - The parameters read from the file are the retained ones minus what - :func:`encode` took out; together with the master coordinates and the - decoded arrays they are the dataset :func:`linopy.spec.accessor.attach` - left on the model when it was built. + The master coordinates, the plainly written parameters and the coded ones + together are the dataset :func:`linopy.spec.accessor.attach` gave the spec + when the model was built. ``model.parameters`` is not touched: it holds + what the caller put there and nothing of the spec. """ sub = get_prefix(ds, PREFIX) coords = { @@ -88,24 +90,30 @@ def decode(model: Model, ds: xr.Dataset, text: str) -> ModelSpec: for name in sub.data_vars if str(name).startswith(COORD) } - coded = { - _stripped(name, CODES): _decode(sub, _stripped(name, CODES), coords) + arrays = { + _stripped(name, PARAM): _plain(sub[name], _stripped(name, PARAM), coords) for name in sub.data_vars - if str(name).startswith(CODES) + if str(name).startswith(PARAM) } - model.parameters = model.parameters.assign_coords(coords).assign(coded) + arrays.update( + { + _stripped(name, CODES): _decode(sub, _stripped(name, CODES), coords) + for name in sub.data_vars + if str(name).startswith(CODES) + } + ) restamp_coords(model, coords) - return restore(model, text) + return restore(model, text, xr.Dataset(arrays).assign_coords(coords)) -def _coded(spec: ModelSpec) -> list[str]: +def _coded(spec: ModelSpec) -> set[str]: """The parameters written as codes: every lookup and every array of objects.""" lookups = {name for by_name in spec.lookups.values() for name in by_name} - return [ + return { str(name) for name, arr in spec.parameters.items() if name in lookups or arr.dtype == object - ] + } def _encode(name: str, arr: xr.DataArray) -> dict[str, xr.DataArray]: @@ -122,6 +130,14 @@ def _encode(name: str, arr: xr.DataArray) -> dict[str, xr.DataArray]: return written +def _plain(arr: xr.DataArray, name: str, coords: dict[str, pd.Index]) -> xr.DataArray: + """A parameter written as its own values, back on the master coordinates at its own dtype.""" + dims = tuple(str(d) for d in arr.dims) + return xr.DataArray( + _values(arr), coords={d: coords[d] for d in dims}, dims=dims, name=name + ) + + def _decode(sub: xr.Dataset, name: str, coords: dict[str, pd.Index]) -> xr.DataArray: codes = sub[CODES + name] dtype = np.dtype(codes.attrs[DTYPE_ATTR]) diff --git a/linopy/testing.py b/linopy/testing.py index 0333d1cb..2cf5d28c 100644 --- a/linopy/testing.py +++ b/linopy/testing.py @@ -154,6 +154,7 @@ def assert_model_equal(a: Model, b: Model) -> None: assert (a._spec is None) == (b._spec is None) if a._spec is not None and b._spec is not None: assert a._spec.text == b._spec.text + assert_datasetequal(a._spec.parameters, b._spec.parameters) assert a.status == b.status assert a.termination_condition == b.termination_condition diff --git a/test/test_spec_accessor.py b/test/test_spec_accessor.py index 3716b190..ed8457d5 100644 --- a/test/test_spec_accessor.py +++ b/test/test_spec_accessor.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any +import numpy as np import pandas as pd import pytest import xarray as xr @@ -109,16 +110,27 @@ def test_from_spec_passes_model_kwargs_and_chains() -> None: ("none", set()), ], ) -def test_retain_decides_what_the_fold_can_read(retain: str, kept: set[str]) -> None: +def test_retain_decides_what_is_kept_and_not_what_can_be_read( + retain: str, kept: set[str] +) -> None: + """A parameter retain dropped is read from the sources the model still holds.""" m = solved(yaml_dict(), DISPATCH_DATA, retain=retain) - assert set(m.parameters.data_vars) == kept + assert set(m.spec.parameters.data_vars) == kept + assert not m.parameters.data_vars want = (DISPATCH_P * [0.0, 50.0]).sum("generator").rename("spend") + xr.testing.assert_allclose(m.spec.expressions["spend"].solution, want) xr.testing.assert_allclose(m.spec.evaluate("spend", DISPATCH_DATA).solution, want) - if "cost" in kept: - xr.testing.assert_allclose(m.spec.expressions["spend"].solution, want) - else: - with pytest.raises(SpecDataError, match="not retained"): - m.spec.expressions["spend"].solution + + +def test_the_spec_keeps_its_parameters_off_the_model() -> None: + """``model.parameters`` is the caller's: a build neither reads nor writes it.""" + own = xr.DataArray(np.array(["a", "b", "c"], dtype=object), dims=["own"]) + m = Model() + m.parameters["cost"] = own + m.add_spec(yaml_dict(), DISPATCH_DATA, retain="all") + + assert m.parameters["cost"].equals(own) + assert m.spec.parameters["cost"].dims == ("generator",) def test_an_unknown_expression_is_a_key_error_with_a_hint() -> None: diff --git a/test/test_spec_builder.py b/test/test_spec_builder.py index e5a3d879..b7ee54f5 100644 --- a/test/test_spec_builder.py +++ b/test/test_spec_builder.py @@ -413,7 +413,7 @@ def test_an_operator_under_a_power_keeps_its_parameters_retained() -> None: expressions={"e": "shift(c, over=t, offset=lag, edge=0) ** 1"}, ) m = Model.from_spec(spec, {"t": T, "w": FULL_W, "c": FULL_C, "lag": 1}) - assert {"c", "lag"} <= set(m.parameters.data_vars) + assert {"c", "lag"} <= set(m.spec.parameters.data_vars) xr.testing.assert_allclose( m.spec.expressions["e"].solution, xr.DataArray([0.0, 0.0, 4.0], coords={"t": T}, name="e"), diff --git a/test/test_spec_io.py b/test/test_spec_io.py index 5af661f0..30139a14 100644 --- a/test/test_spec_io.py +++ b/test/test_spec_io.py @@ -31,6 +31,7 @@ import linopy # noqa: E402 from linopy import Model, read_netcdf # noqa: E402 from linopy.io import SPEC_ATTR # noqa: E402 +from linopy.spec import SpecDataError # noqa: E402 from linopy.spec.testing import synthetic_sources # noqa: E402 from linopy.testing import assert_model_equal # noqa: E402 @@ -137,17 +138,34 @@ def test_a_spec_built_model_round_trips( def test_a_retain_none_model_evaluates_after_a_round_trip( tmp_path: Path, engine: str ) -> None: + """A file is where retain bites: the sources the built model still read are gone.""" m = solved(EXAMPLE_DISPATCH, DISPATCH_DATA, retain="none") p = roundtrip(m, tmp_path, engine) assert_model_equal(m, p) assert not p.spec.parameters.data_vars + with pytest.raises(SpecDataError, match="no longer holds the sources"): + p.spec.expressions["spend"].solution assert_arrayequal( - m.spec.evaluate("spend", DISPATCH_DATA).solution, + m.spec.expressions["spend"].solution, p.spec.evaluate("spend", DISPATCH_DATA).solution, ) +@pytest.mark.parametrize("engine", ENGINES) +def test_the_caller_parameters_and_the_spec_ones_stay_apart( + tmp_path: Path, engine: str +) -> None: + """A model parameter of the caller's is written beside the spec's, not into them.""" + m = solved(EXAMPLE_DISPATCH, DISPATCH_DATA, retain="all") + m.parameters["cost"] = xr.DataArray([1, 2, 3], dims=["own"]) + p = roundtrip(m, tmp_path, engine) + + assert_model_equal(m, p) + assert_arrayequal(p.parameters["cost"], m.parameters["cost"]) + assert_arrayequal(p.spec.parameters["cost"], m.spec.parameters["cost"]) + + @pytest.mark.parametrize("engine", ENGINES) @pytest.mark.parametrize("mapped", [3, 2, 0], ids=["full", "partial", "empty"]) @pytest.mark.parametrize("name", LOOKUP_OVER) @@ -215,13 +233,23 @@ def test_a_copy_carries_the_spec(deep: bool) -> None: """The copy's spec reads the copy, and only a deep copy owns its buffers.""" m = Model.from_spec(WHERE_SPEC, WHERE_DATA, retain="all") p = m.copy(deep=deep) - p.parameters["label"].values[1] = "changed" + p.spec.parameters["label"].values[1] = "changed" assert p.spec.text == m.spec.text assert p.spec.parameters["label"].values[1] == "changed" assert m.spec.parameters["label"].values[1] == ("u" if deep else "changed") +def test_a_copy_can_still_read_what_retain_dropped() -> None: + """A copy keeps the sources, so it folds an unretained parameter like its original.""" + m = solved(EXAMPLE_DISPATCH, DISPATCH_DATA, retain="none") + + assert_arrayequal( + m.copy(include_solution=True).spec.expressions["spend"].solution, + m.spec.expressions["spend"].solution, + ) + + def test_a_model_without_a_spec_carries_none(tmp_path: Path) -> None: m = Model() x = m.add_variables(coords=[pd.RangeIndex(3, name="i")], name="x")