From 86c8b1a05680eaf038c6e41983b9d2932d236b6c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:42:33 +0200 Subject: [PATCH 1/3] refac(spec): typeset takes the format as an argument math-spec spells its typesetter as `typeset(model, fmt)` over a `FORMATS` registry, and `typeset_declaration(model, name, fmt)` the same way. linopy re-expanded that one axis into six methods, three per class, each a one-line delegation -- so linopy's public surface tracked math-spec's list of formats, and a fourth format upstream would have meant either two more methods here or a silent asymmetry. `typeset(fmt)` is the method now, on `ModelSpec` and on `Declaration` alike, with `to_latex`, `to_markdown` and `to_typst` kept as the three-line aliases that spell today's formats -- they read the way pandas taught everyone to expect, and a notebook repr needs one. A format math-spec adds is reachable the day it lands, without a release here. The docstrings said "the whole model", which the code never delivered: it renders the spec, and a spec-built model can hold more than its spec. They say the spec now; making the difference visible is the next commit's job. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015ZfHfTFENUy6WxnFFri5Td --- doc/api.rst | 1 + doc/release_notes.rst | 2 +- examples/building-models-from-specs.ipynb | 4 +-- linopy/spec/accessor.py | 43 +++++++++++++++-------- test/test_spec_accessor.py | 11 +++++- 5 files changed, 43 insertions(+), 18 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index f74c7c5c..21768ec2 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -131,6 +131,7 @@ data. Requires the ``spec`` dependency group. spec.ModelSpec spec.NamedExpressions spec.NamedExpression + spec.Declaration spec.attach spec.Attached spec.SpecDataError diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9210ff80..aa11cd63 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -27,7 +27,7 @@ Upcoming Version * ``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. -* ``model.spec.to_latex`` / ``.to_markdown`` / ``.to_typst`` typeset the whole model, and ``model.spec.declaration(name)`` returns a ``linopy.spec.Declaration`` whose same three methods typeset one named expression, constraint or variable as a single line (math only, no document); a ``NamedExpression`` carries those methods too. A ``ModelSpec``, a ``Declaration`` and a ``NamedExpression`` all render as Markdown in a notebook. +* ``model.spec.typeset(fmt)`` typesets the spec in any format math-spec knows, with ``.to_latex`` / ``.to_markdown`` / ``.to_typst`` spelling the three it knows today, and ``model.spec.declaration(name)`` returns a ``linopy.spec.Declaration`` whose same three methods typeset one named expression, constraint or variable as a single line (math only, no document); a ``NamedExpression`` carries those methods too. A ``ModelSpec``, a ``Declaration`` and a ``NamedExpression`` all render as Markdown in a notebook. *Numerical scaling* diff --git a/examples/building-models-from-specs.ipynb b/examples/building-models-from-specs.ipynb index cfb2ec04..5de19826 100644 --- a/examples/building-models-from-specs.ipynb +++ b/examples/building-models-from-specs.ipynb @@ -323,7 +323,7 @@ "cell_type": "markdown", "id": "16", "metadata": {}, - "source": "### The model as maths\n\nThe accessor typesets the whole model, delegating to math-spec:\n`m.spec.to_latex()`, `.to_markdown()` and `.to_typst()`. In a notebook the\naccessor renders as Markdown on its own; here we show it explicitly.\n\nAny single declaration typesets on its own too. `m.spec.declaration(name)`\ntakes a named expression, a constraint or a variable and hands back a\n`Declaration` with the same three methods; a `NamedExpression` carries them\ndirectly. These render **one** line — math only, no surrounding document — so\nthe string drops straight into a docstring or a table cell, and both a\n`Declaration` and a `NamedExpression` render as their own formula in a notebook." + "source": "### The spec as maths\n\nThe accessor typesets the spec, delegating to math-spec: `m.spec.typeset(fmt)`\nfor any format math-spec knows, with `m.spec.to_latex()`, `.to_markdown()` and\n`.to_typst()` spelling the three it knows today. In a notebook the accessor\nrenders as Markdown on its own; here we show it explicitly.\n\nAny single declaration typesets on its own too. `m.spec.declaration(name)`\ntakes a named expression, a constraint or a variable and hands back a\n`Declaration` with the same methods; a `NamedExpression` carries them\ndirectly. These render **one** line — math only, no surrounding document — so\nthe string drops straight into a docstring or a table cell, and both a\n`Declaration` and a `NamedExpression` render as their own formula in a notebook." }, { "cell_type": "code", @@ -943,7 +943,7 @@ "cell_type": "markdown", "id": "49", "metadata": {}, - "source": "## Where the code lives\n\nThe feature is a small package, `linopy/spec/`, imported only when you call\n`add_spec`/`from_spec` — `import linopy` never pulls in `math_spec`. Roughly:\n\n- `accessor.py` — `model.spec`, the `NamedExpression` views, `evaluate`, and\n typesetting: the whole model (`m.spec.to_latex` / `.to_markdown` /\n `.to_typst`) and any single declaration — a named expression, constraint or\n variable — via `m.spec.declaration(name)` and math-spec's\n `typeset_declaration`.\n- `attach.py` — the three attachment rules; data onto master coordinates.\n- `builder.py` — emits variables, constraints, objective; folds expressions.\n- `operators.py` — `sum`, `by=`, `shift`, `at`, `sum_back`.\n- `where.py` — `where:` predicates as boolean masks.\n- `coverage.py` / `terms.py` — the absence rule from section 6: a missing row\n is refused wherever it is used.\n- `curves.py` — the data side of `piecewise:` blocks.\n- `netcdf.py` — the factorize-based persistence from section 10.\n- `nodes.py` — walks over expression nodes, and the dimensions a node\n spans before any data is bound.\n\n### Summary\n\nA spec is the maths over labelled axes; the sources are the numbers. `linopy`\nattaches them into an ordinary model, hands each named expression back as three\nviews — its formula, its unsolved linopy expression and its solution — refuses a\nmissing parameter row wherever it is used (as a coefficient, bound, constant\nside or divisor alike, with `where:` and filling the data as the escape\nhatches), and round-trips the lot through netCDF by keeping the spec as text\nbeside factorized labels." + "source": "## Where the code lives\n\nThe feature is a small package, `linopy/spec/`, imported only when you call\n`add_spec`/`from_spec` — `import linopy` never pulls in `math_spec`. Roughly:\n\n- `accessor.py` — `model.spec`, the `NamedExpression` views, `evaluate`, and\n typesetting: the spec (`m.spec.typeset`, with `to_latex` / `to_markdown` /\n `to_typst` as its named formats) and any single declaration — a named expression, constraint or\n variable — via `m.spec.declaration(name)` and math-spec's\n `typeset_declaration`.\n- `attach.py` — the three attachment rules; data onto master coordinates.\n- `builder.py` — emits variables, constraints, objective; folds expressions.\n- `operators.py` — `sum`, `by=`, `shift`, `at`, `sum_back`.\n- `where.py` — `where:` predicates as boolean masks.\n- `coverage.py` / `terms.py` — the absence rule from section 6: a missing row\n is refused wherever it is used.\n- `curves.py` — the data side of `piecewise:` blocks.\n- `netcdf.py` — the factorize-based persistence from section 10.\n- `nodes.py` — walks over expression nodes, and the dimensions a node\n spans before any data is bound.\n\n### Summary\n\nA spec is the maths over labelled axes; the sources are the numbers. `linopy`\nattaches them into an ordinary model, hands each named expression back as three\nviews — its formula, its unsolved linopy expression and its solution — refuses a\nmissing parameter row wherever it is used (as a coefficient, bound, constant\nside or divisor alike, with `where:` and filling the data as the escape\nhatches), and round-trips the lot through netCDF by keeping the spec as text\nbeside factorized labels." } ], "metadata": { diff --git a/linopy/spec/accessor.py b/linopy/spec/accessor.py index b8cec579..14d595d0 100644 --- a/linopy/spec/accessor.py +++ b/linopy/spec/accessor.py @@ -28,14 +28,13 @@ from math_spec import ( Spec, did_you_mean, - to_latex, - to_markdown, to_program, to_spec, - to_typst, + typeset, typeset_declaration, ) from math_spec import program as ms +from math_spec.typesetting import FormatName from linopy.constants import warn_evolving_api from linopy.model import Model @@ -231,17 +230,31 @@ def _declarations(self) -> list[str]: p = self.program return [*p.named_expressions, *p.constraints, *p.variables] + def typeset(self, fmt: FormatName, **options: Any) -> str: + """ + The spec this model was built from, typeset in *fmt* as a document. + + Parameters + ---------- + fmt : {"latex", "markdown", "typst"} + What spells the math, as ``math_spec.typeset`` takes it. + **options + Passed on to ``math_spec.typeset``: ``symbols``, ``standalone``, + ``legend``, ``numbered``, ``inline_expressions``. + """ + return typeset(self._schema, fmt, **options) + def to_latex(self, **options: Any) -> str: - """The whole model typeset as a LaTeX document.""" - return to_latex(self._schema, **options) + """The spec typeset as a LaTeX document, see :meth:`typeset`.""" + return self.typeset("latex", **options) def to_markdown(self, **options: Any) -> str: - """The whole model typeset as Markdown, its equations in ``$$`` blocks.""" - return to_markdown(self._schema, **options) + """The spec typeset as Markdown, its equations in ``$$`` blocks, see :meth:`typeset`.""" + return self.typeset("markdown", **options) def to_typst(self, **options: Any) -> str: - """The whole model typeset as Typst.""" - return to_typst(self._schema, **options) + """The spec typeset as Typst, see :meth:`typeset`.""" + return self.typeset("typst", **options) def _repr_markdown_(self) -> str: return self.to_markdown() @@ -343,19 +356,21 @@ def __init__(self, spec: ModelSpec, name: str) -> None: self._spec = spec self._name = name + def typeset(self, fmt: FormatName, **options: Any) -> str: + """This declaration typeset in *fmt* as a single line, no document around it.""" + return typeset_declaration(self._spec._schema, self._name, fmt, **options) + def to_latex(self, **options: Any) -> str: """This declaration typeset as a single LaTeX line, no document around it.""" - return typeset_declaration(self._spec._schema, self._name, "latex", **options) + return self.typeset("latex", **options) def to_markdown(self, **options: Any) -> str: """This declaration typeset as a single Markdown math line, no ``$$`` around it.""" - return typeset_declaration( - self._spec._schema, self._name, "markdown", **options - ) + return self.typeset("markdown", **options) def to_typst(self, **options: Any) -> str: """This declaration typeset as a single Typst line, no document around it.""" - return typeset_declaration(self._spec._schema, self._name, "typst", **options) + return self.typeset("typst", **options) def _repr_markdown_(self) -> str: return f"$$\n{self.to_markdown()}\n$$" diff --git a/test/test_spec_accessor.py b/test/test_spec_accessor.py index 91605cec..edf84a99 100644 --- a/test/test_spec_accessor.py +++ b/test/test_spec_accessor.py @@ -288,7 +288,7 @@ def test_hybrid_model_tags_spec_variables_constraints_and_expressions() -> None: assert "" not in text -def test_the_whole_model_typesets() -> None: +def test_the_spec_typesets_in_every_format() -> None: spec = Model.from_spec(yaml_dict(), DISPATCH_DATA).spec assert "align" in spec.to_latex() assert "$$" in spec.to_markdown() @@ -296,6 +296,15 @@ def test_the_whole_model_typesets() -> None: assert spec._repr_markdown_() == spec.to_markdown() +@pytest.mark.parametrize("fmt", ["latex", "markdown", "typst"]) +def test_typeset_and_its_named_aliases_agree(fmt: str) -> None: + """The format is a parameter; the named methods only spell a common one.""" + spec = Model.from_spec(VIEWS_SPEC, DISPATCH_DATA).spec + declaration = spec.declaration("p") + assert spec.typeset(fmt) == getattr(spec, f"to_{fmt}")() + assert declaration.typeset(fmt) == getattr(declaration, f"to_{fmt}")() + + @pytest.mark.parametrize("fmt", ["to_latex", "to_markdown", "to_typst"]) def test_a_named_expression_typesets_to_one_line(fmt: str) -> None: e = Model.from_spec(VIEWS_SPEC, DISPATCH_DATA).spec.expressions["spend"] From 9f6d6ae188231d6a43d95833fea3f465b6f3400c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:11:04 +0200 Subject: [PATCH 2/3] feat(spec): say what a typeset spec leaves out of the model Typesetting renders the spec, and a spec-built model need not be only its spec: `add_variables` and `add_constraints` go on working on one, and what they add carries no math-spec declaration to typeset. Nothing said so. A hybrid model rendered to LaTeX silently dropped every hand-added variable and constraint -- the kind of omission that survives review and reaches a paper. `model.spec.unspecified` names them, in one place: `Model.__repr__` computed the same difference inline to decide its `[spec]` tags and now reads it too. Where it is non-empty, typesetting says so three ways, because no one channel reaches every reader: - a `UserWarning`, for the script that renders a file; - a comment of the format's own opening the rendered text -- `%` in LaTeX, `", + "typst": "// {}", +} + + +@dataclass(frozen=True) +class Unspecified: + """ + What a spec-built model holds that its spec does not declare. + + A model can grow past the spec it was built from -- ``add_variables`` and + ``add_constraints`` go on working on it -- and what is added that way + carries no math-spec declaration, so nothing can typeset it. + """ + + variables: tuple[str, ...] + constraints: tuple[str, ...] + + def __bool__(self) -> bool: + return bool(self.variables or self.constraints) + + +def _counted(names: tuple[str, ...], kind: str, cap: int = 5) -> str: + """``2 constraints (a, b)``, capped with a ``+N more`` tail; empty for no names.""" + if not names: + return "" + shown = list(names[:cap]) + if len(names) > cap: + shown.append(f"+{len(names) - cap} more") + plural = kind if len(names) == 1 else f"{kind}s" + return f"{len(names)} {plural} ({', '.join(shown)})" + def attach( model: Model, @@ -230,10 +268,31 @@ def _declarations(self) -> list[str]: p = self.program return [*p.named_expressions, *p.constraints, *p.variables] + @property + def unspecified(self) -> Unspecified: + """ + The model's variables and constraints this spec does not declare. + + Empty for a model that is only what its spec says; anything added + beside the spec lands here, and is what typesetting cannot show. + """ + return Unspecified( + tuple(n for n in self._model.variables if n not in self.program.variables), + tuple( + n for n in self._model.constraints if n not in self.program.constraints + ), + ) + def typeset(self, fmt: FormatName, **options: Any) -> str: """ The spec this model was built from, typeset in *fmt* as a document. + The spec, and so not necessarily the whole model: what was added + beside the spec carries no declaration to typeset. Where the model + holds such a thing, :attr:`unspecified` names it, a warning says so, + and the rendered text opens with the same tally as a comment of + *fmt*'s own -- gone once compiled, there in the source. + Parameters ---------- fmt : {"latex", "markdown", "typst"} @@ -241,23 +300,64 @@ def typeset(self, fmt: FormatName, **options: Any) -> str: **options Passed on to ``math_spec.typeset``: ``symbols``, ``standalone``, ``legend``, ``numbered``, ``inline_expressions``. + + Warns + ----- + UserWarning + The model holds variables or constraints the spec does not + declare, which are not in the rendered text. """ - return typeset(self._schema, fmt, **options) + return self._render(fmt, options, 3) def to_latex(self, **options: Any) -> str: """The spec typeset as a LaTeX document, see :meth:`typeset`.""" - return self.typeset("latex", **options) + return self._render("latex", options, 3) def to_markdown(self, **options: Any) -> str: """The spec typeset as Markdown, its equations in ``$$`` blocks, see :meth:`typeset`.""" - return self.typeset("markdown", **options) + return self._render("markdown", options, 3) def to_typst(self, **options: Any) -> str: """The spec typeset as Typst, see :meth:`typeset`.""" - return self.typeset("typst", **options) + return self._render("typst", options, 3) + + def _render( + self, fmt: FormatName, options: Mapping[str, Any], stacklevel: int + ) -> str: + """Typeset in *fmt*, warned and commented where the model holds more than the spec.""" + rendered = typeset(self._schema, fmt, **options) + tally = self._tally() + if tally is None: + return rendered + warnings.warn( + f"this model holds {tally} added outside the spec, which carry no math-spec " + f"declaration and are not typeset, so this is not the whole model.", + UserWarning, + stacklevel=stacklevel, + ) + comment = _COMMENT.get(fmt) + if comment is None: + return rendered + return f"{comment.format(f'Added outside this spec and not shown: {tally}.')}\n{rendered}" + + def _tally(self) -> str | None: + """What the model holds beside the spec, counted and named; ``None`` when it holds nothing.""" + found = self.unspecified + if not found: + return None + counted = ( + _counted(found.variables, "variable"), + _counted(found.constraints, "constraint"), + ) + return " and ".join(c for c in counted if c) def _repr_markdown_(self) -> str: - return self.to_markdown() + """The spec as Markdown, with a *visible* note where a notebook would swallow the warning.""" + rendered = self._render("markdown", {}, 3) + tally = self._tally() + if tally is None: + return rendered + return f"{rendered}\n\n*Added outside this spec and not shown: {tally}.*" @property def _schema(self) -> dict[str, Any]: @@ -357,7 +457,13 @@ def __init__(self, spec: ModelSpec, name: str) -> None: self._name = name def typeset(self, fmt: FormatName, **options: Any) -> str: - """This declaration typeset in *fmt* as a single line, no document around it.""" + """ + This declaration typeset in *fmt* as a single line, no document around it. + + Nothing here can be out of step with the model the way + :meth:`ModelSpec.typeset` can: a declaration is reached by name + through the spec, so there is only ever the spec's own math to show. + """ return typeset_declaration(self._spec._schema, self._name, fmt, **options) def to_latex(self, **options: Any) -> str: diff --git a/test/test_spec_accessor.py b/test/test_spec_accessor.py index edf84a99..8ffc9d58 100644 --- a/test/test_spec_accessor.py +++ b/test/test_spec_accessor.py @@ -29,7 +29,12 @@ yaml_dict, ) from linopy import Model # noqa: E402 -from linopy.spec import ModelSpec, NamedExpression, SpecDataError # noqa: E402 +from linopy.spec import ( # noqa: E402 + ModelSpec, + NamedExpression, + SpecDataError, + Unspecified, +) pytestmark = [ pytest.mark.v1, @@ -305,6 +310,53 @@ def test_typeset_and_its_named_aliases_agree(fmt: str) -> None: assert declaration.typeset(fmt) == getattr(declaration, f"to_{fmt}")() +def hybrid() -> Model: + """A spec-built model grown past its spec by hand.""" + m = Model.from_spec(yaml_dict(), DISPATCH_DATA) + m.add_variables(lower=0, coords=[GENERATOR], name="reserve") + m.add_constraints(m.variables["reserve"] <= 10.0, name="reserve_cap") + return m + + +def test_unspecified_names_what_the_spec_does_not_declare() -> None: + assert not Model.from_spec(yaml_dict(), DISPATCH_DATA).spec.unspecified + assert hybrid().spec.unspecified == Unspecified(("reserve",), ("reserve_cap",)) + + +@pytest.mark.parametrize( + ("fmt", "opener"), [("latex", "%"), ("markdown", "", @@ -65,18 +67,56 @@ @dataclass(frozen=True) class Unspecified: """ - What a spec-built model holds that its spec does not declare. + How a spec-built model has drifted from the spec it was built from. + + A model goes on taking everything linopy can add to it, and none of that + carries a math-spec declaration to typeset. A spec's own ``piecewise:`` + and ``sos:`` are not drift: math-spec lowers them into ordinary + declarations, so they sit in the program like any other. - A model can grow past the spec it was built from -- ``add_variables`` and - ``add_constraints`` go on working on it -- and what is added that way - carries no math-spec declaration, so nothing can typeset it. + Attributes + ---------- + variables, constraints + Added beside the spec, a piecewise formulation's own aside. + expressions + Everything in ``model.expressions``: a spec's named expressions are + read lazily off ``model.spec`` and never live there. + sos + Variables given a special-ordered set the spec does not declare. + ``add_sos_constraints`` writes attributes onto a variable rather than + adding a name of its own, so nothing else here would show it. + piecewise + Formulations added by ``add_piecewise_formulation``, named as + formulations rather than as the variables and constraints they hold. + objective + Whether ``add_objective`` has replaced the spec's objective. The one + entry here that a render gets *wrong* rather than leaves out: the + typeset objective is the spec's, and the model's is another. """ variables: tuple[str, ...] constraints: tuple[str, ...] + expressions: tuple[str, ...] + sos: tuple[str, ...] + piecewise: tuple[str, ...] + objective: bool def __bool__(self) -> bool: - return bool(self.variables or self.constraints) + return bool( + self.variables + or self.constraints + or self.expressions + or self.sos + or self.piecewise + or self.objective + ) + + +def _joined(parts: list[str]) -> str: + """``a``, ``a and b``, ``a, b and c``.""" + if len(parts) < 3: + return " and ".join(parts) + return f"{', '.join(parts[:-1])} and {parts[-1]}" def _counted(names: tuple[str, ...], kind: str, cap: int = 5) -> str: @@ -128,14 +168,21 @@ def attach( return ModelSpec(model, program, text, parameters, attached) -def restore(model: Model, text: str, parameters: xr.Dataset) -> ModelSpec: +def restore( + model: Model, + text: str, + parameters: xr.Dataset, + objective_replaced: bool = False, +) -> 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) + spec = ModelSpec(model, to_program(yaml.safe_load(text)), text, parameters, None) + spec._objective_replaced = objective_replaced + return spec def _source(spec: SpecLike) -> tuple[str, ms.Program]: @@ -193,6 +240,9 @@ def __init__( self.text = text self._parameters = parameters self._attached = attached + # A build sets the objective through `add_objective` before `_spec` is + # assigned, so only a call after the build ever flips this. + self._objective_replaced = False def __repr__(self) -> str: p = self.program @@ -211,13 +261,15 @@ def __repr__(self) -> str: def _reattach(self, model: Model, deep: bool = True) -> ModelSpec: """The same spec, read off *model*, holding its own copy of the parameters.""" - return ModelSpec( + copied = ModelSpec( model, self.program, self.text, self._parameters.copy(deep=deep), self._attached, ) + copied._objective_replaced = self._objective_replaced + return copied @property def parameters(self) -> xr.Dataset: @@ -271,16 +323,36 @@ def _declarations(self) -> list[str]: @property def unspecified(self) -> Unspecified: """ - The model's variables and constraints this spec does not declare. + How the model has drifted from this spec, see :class:`Unspecified`. - Empty for a model that is only what its spec says; anything added + Falsy for a model that is only what its spec says; everything added beside the spec lands here, and is what typesetting cannot show. """ + from linopy.constants import SOS_TYPE_ATTR + from linopy.piecewise import _get_piecewise_groups + + model, program = self._model, self.program + pw_variables, pw_constraints = _get_piecewise_groups(model) + declared_sos = {sos.variable for sos in program.sos.values()} return Unspecified( - tuple(n for n in self._model.variables if n not in self.program.variables), - tuple( - n for n in self._model.constraints if n not in self.program.constraints + variables=tuple( + n + for n in model.variables + if n not in program.variables and n not in pw_variables ), + constraints=tuple( + n + for n in model.constraints + if n not in program.constraints and n not in pw_constraints + ), + expressions=tuple(model.expressions), + sos=tuple( + n + for n in model.variables + if SOS_TYPE_ATTR in model.variables[n].attrs and n not in declared_sos + ), + piecewise=tuple(model._piecewise_formulations), + objective=self._objective_replaced, ) def typeset(self, fmt: FormatName, **options: Any) -> str: @@ -330,26 +402,31 @@ def _render( if tally is None: return rendered warnings.warn( - f"this model holds {tally} added outside the spec, which carry no math-spec " - f"declaration and are not typeset, so this is not the whole model.", + f"this model has drifted from the spec it was built from: {tally}. " + f"What is typeset is the spec, so it is not this model.", UserWarning, stacklevel=stacklevel, ) comment = _COMMENT.get(fmt) if comment is None: return rendered - return f"{comment.format(f'Added outside this spec and not shown: {tally}.')}\n{rendered}" + return f"{comment.format(_DRIFTED.format(tally))}\n{rendered}" def _tally(self) -> str | None: - """What the model holds beside the spec, counted and named; ``None`` when it holds nothing.""" + """How the model has drifted, counted and named; ``None`` when it has not.""" found = self.unspecified if not found: return None - counted = ( + parts = [ _counted(found.variables, "variable"), _counted(found.constraints, "constraint"), + _counted(found.expressions, "expression"), + _counted(found.sos, "SOS set"), + _counted(found.piecewise, "piecewise formulation"), + ] + return _joined( + [p for p in parts if p] + ["a replaced objective"] * found.objective ) - return " and ".join(c for c in counted if c) def _repr_markdown_(self) -> str: """The spec as Markdown, with a *visible* note where a notebook would swallow the warning.""" @@ -357,7 +434,7 @@ def _repr_markdown_(self) -> str: tally = self._tally() if tally is None: return rendered - return f"{rendered}\n\n*Added outside this spec and not shown: {tally}.*" + return f"{rendered}\n\n*{_DRIFTED.format(tally)}*" @property def _schema(self) -> dict[str, Any]: diff --git a/linopy/spec/netcdf.py b/linopy/spec/netcdf.py index 7f1a2813..a5ed7e06 100644 --- a/linopy/spec/netcdf.py +++ b/linopy/spec/netcdf.py @@ -40,6 +40,7 @@ from linopy.spec.accessor import ModelSpec, restore PREFIX = "spec" +OBJECTIVE_ATTR = "_linopy_spec_objective_replaced" COORD = "coords__" PARAM = "param__" CODES = "codes__" @@ -72,7 +73,13 @@ def encode(spec: ModelSpec) -> xr.Dataset: 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}) + written = with_prefix(xr.Dataset(arrays), PREFIX).assign_attrs( + {SPEC_ATTR: spec.text} + ) + if spec.unspecified.objective: + # Only when true, so a file written from an untouched spec is unchanged. + written = written.assign_attrs({OBJECTIVE_ATTR: 1}) + return written def decode(model: Model, ds: xr.Dataset, text: str) -> ModelSpec: @@ -103,7 +110,12 @@ def decode(model: Model, ds: xr.Dataset, text: str) -> ModelSpec: } ) restamp_coords(model, coords) - return restore(model, text, xr.Dataset(arrays).assign_coords(coords)) + return restore( + model, + text, + xr.Dataset(arrays).assign_coords(coords), + bool(ds.attrs.get(OBJECTIVE_ATTR, 0)), + ) def _coded(spec: ModelSpec) -> set[str]: diff --git a/linopy/testing.py b/linopy/testing.py index 2cf5d28c..bcd471e5 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 a._spec.unspecified == b._spec.unspecified assert_datasetequal(a._spec.parameters, b._spec.parameters) assert a.status == b.status diff --git a/test/test_spec_accessor.py b/test/test_spec_accessor.py index 8ffc9d58..5bca044b 100644 --- a/test/test_spec_accessor.py +++ b/test/test_spec_accessor.py @@ -28,7 +28,7 @@ with_, yaml_dict, ) -from linopy import Model # noqa: E402 +from linopy import Model, breakpoints # noqa: E402 from linopy.spec import ( # noqa: E402 ModelSpec, NamedExpression, @@ -320,7 +320,47 @@ def hybrid() -> Model: def test_unspecified_names_what_the_spec_does_not_declare() -> None: assert not Model.from_spec(yaml_dict(), DISPATCH_DATA).spec.unspecified - assert hybrid().spec.unspecified == Unspecified(("reserve",), ("reserve_cap",)) + assert hybrid().spec.unspecified == Unspecified( + variables=("reserve",), + constraints=("reserve_cap",), + expressions=(), + sos=(), + piecewise=(), + objective=False, + ) + + +def test_unspecified_sees_what_carries_no_name_of_its_own() -> None: + """An SOS is attributes on a variable, and a replaced objective is no name at all.""" + m = Model.from_spec(yaml_dict(), DISPATCH_DATA) + m.add_expressions(m.variables["p"].sum("generator"), name="hand_expr") + m.add_sos_constraints(m.variables["p"], sos_type=2, sos_dim="generator") + m.add_objective(m.variables["p"].sum() * 3.0, overwrite=True) + + found = m.spec.unspecified + assert found.expressions == ("hand_expr",) + assert found.sos == ("p",) + assert found.objective + assert found.variables == () and found.constraints == () + + +def test_a_piecewise_formulation_is_named_as_one_and_not_as_its_parts() -> None: + """Its own variables and constraints are the formulation's business, not the tally's.""" + m = Model.from_spec(yaml_dict(), DISPATCH_DATA) + k = pd.Index([0, 1], name="k") + pts = {"k": k, "_breakpoint": [0, 1, 2]} + x = m.add_variables(lower=0, upper=10, coords=[k], name="pw_x") + y = m.add_variables(lower=0, upper=10, coords=[k], name="pw_y") + m.add_piecewise_formulation( + (x, breakpoints(xr.DataArray([[0.0, 5.0, 10.0]] * 2, coords=pts))), + (y, breakpoints(xr.DataArray([[0.0, 1.0, 4.0]] * 2, coords=pts))), + name="curve", + ) + + found = m.spec.unspecified + assert found.piecewise == ("curve",) + assert found.variables == ("pw_x", "pw_y") + assert found.constraints == () @pytest.mark.parametrize( @@ -330,7 +370,7 @@ def test_typesetting_a_hybrid_model_warns_and_says_so_in_the_source( fmt: str, opener: str ) -> None: """The tally is a comment of the format's own: gone once compiled, there in the source.""" - with pytest.warns(UserWarning, match="not the whole model"): + with pytest.warns(UserWarning, match="drifted from the spec"): rendered = hybrid().spec.typeset(fmt) first = rendered.splitlines()[0] @@ -352,7 +392,7 @@ def test_a_notebook_sees_a_note_the_warning_would_not_reach() -> None: rendered = hybrid().spec._repr_markdown_() assert rendered.splitlines()[-1] == ( - "*Added outside this spec and not shown: " + "*This model has drifted from the spec typeset here: " "1 variable (reserve) and 1 constraint (reserve_cap).*" ) diff --git a/test/test_spec_builder.py b/test/test_spec_builder.py index b7ee54f5..8247b2fe 100644 --- a/test/test_spec_builder.py +++ b/test/test_spec_builder.py @@ -368,6 +368,8 @@ def test_a_sos2_curve_is_built_as_a_special_ordered_set() -> None: ) m = Model.from_spec(spec, {**CURVE_DATA, "bp_x": FULL_X, "bp_y": FULL_Y}) assert m.variables["cost_curve_lam"].attrs["sos_type"] == 2 + # math-spec lowers the block into ordinary declarations, so none of it is drift. + assert not m.spec.unspecified # --------------------------------------------------------------------------- diff --git a/test/test_spec_io.py b/test/test_spec_io.py index 4dddf94e..d0b3c8de 100644 --- a/test/test_spec_io.py +++ b/test/test_spec_io.py @@ -166,6 +166,20 @@ def test_the_caller_parameters_and_the_spec_ones_stay_apart( assert_arrayequal(p.spec.parameters["cost"], m.spec.parameters["cost"]) +@pytest.mark.parametrize("engine", ENGINES) +def test_a_replaced_objective_is_still_known_after_a_round_trip( + tmp_path: Path, engine: str +) -> None: + """Nothing else in the file would say the typeset objective is not the model's.""" + m = Model.from_spec(EXAMPLE_DISPATCH, DISPATCH_DATA, retain="all") + m.add_objective(m.variables["p"].sum() * 2.0, overwrite=True) + p = roundtrip(m, tmp_path, engine) + + assert m.spec.unspecified.objective + assert p.spec.unspecified.objective + assert_model_equal(m, p) + + @pytest.mark.parametrize("engine", ENGINES) @pytest.mark.parametrize("mapped", [3, 2, 0], ids=["full", "partial", "empty"]) @pytest.mark.parametrize("name", LOOKUP_OVER)