diff --git a/doc/api.rst b/doc/api.rst index f74c7c5c..ed43b625 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -131,6 +131,8 @@ data. Requires the ``spec`` dependency group. spec.ModelSpec spec.NamedExpressions spec.NamedExpression + spec.Declaration + spec.Unspecified spec.attach spec.Attached spec.SpecDataError diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9210ff80..f77fe7fb 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -27,7 +27,9 @@ 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. + +* Typesetting renders the *spec*, which need not be the whole model: a spec-built model goes on taking everything linopy can add to it, and none of that carries a math-spec declaration to typeset. ``model.spec.unspecified`` (a ``linopy.spec.Unspecified``) reports the drift -- variables, constraints and expressions the spec does not declare, special-ordered sets it does not declare, piecewise formulations added beside it, and whether ``add_objective`` has replaced its objective. Where there is any, typesetting warns, opens the rendered text with a comment of the format's own -- gone once compiled, there in the source -- and adds a visible note to the Markdown a notebook displays. A spec's own ``piecewise:`` and ``sos:`` are not drift: math-spec lowers them into ordinary declarations, which typeset like any other. *Numerical scaling* diff --git a/examples/building-models-from-specs.ipynb b/examples/building-models-from-specs.ipynb index cfb2ec04..97bf2a7c 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\nThe **spec**, which need not be the whole model: a spec-built model goes on\ntaking everything linopy can add to it, and none of that carries a math-spec\ndeclaration to typeset. `m.spec.unspecified` reports the drift — variables,\nconstraints and expressions the spec does not declare, special-ordered sets it\ndoes not declare, piecewise formulations added beside it, and whether\n`add_objective` has replaced its objective, which is the one that makes the\nrender *wrong* rather than incomplete. Where there is any, typesetting warns\nand opens the rendered text with a comment of the format's own — `%` in LaTeX,\ngone once compiled, there in the source.\n\nA spec's own `piecewise:` and `sos:` are not drift: math-spec lowers them into\nordinary declarations, which typeset like any other.\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. A declaration is reached through the spec, so it can never be out of\nstep with the model the way the whole-spec render can. 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), the drift `m.spec.unspecified` reports,\n 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/model.py b/linopy/model.py index 41c1e2bd..e5d117c7 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -716,12 +716,11 @@ def __repr__(self) -> str: if self._spec is not None: model_string += ", built from a math-spec" program = self._spec.program - spec_vars = set(program.variables) - spec_cons = set(program.constraints) - if any(v not in spec_vars for v in self.variables): - var_tag = spec_vars - if any(c not in spec_cons for c in self.constraints): - con_tag = spec_cons + unspecified = self._spec.unspecified + if unspecified.variables: + var_tag = set(program.variables) + if unspecified.constraints: + con_tag = set(program.constraints) eager = expr_string if len(self.expressions) else "" spec = "".join( f" * {name} ({', '.join(e.dims)}) [spec]\n" @@ -1628,6 +1627,10 @@ def add_objective( self.objective.expression = expr self.objective.sense = sense self.objective.scaling = scaling + if self._spec is not None: + # A spec sets its objective through here during its own build, + # while `_spec` is still unset, so only a later call reaches this. + self._spec._objective_replaced = True def remove_variables(self, name: str) -> None: """ diff --git a/linopy/spec/__init__.py b/linopy/spec/__init__.py index d3d76bbb..0ca35127 100644 --- a/linopy/spec/__init__.py +++ b/linopy/spec/__init__.py @@ -22,6 +22,7 @@ NamedExpression, NamedExpressions, SpecLike, + Unspecified, ) from linopy.spec.attach import Attached, Retain, attach from linopy.spec.errors import SpecDataError @@ -35,5 +36,6 @@ "Retain", "SpecDataError", "SpecLike", + "Unspecified", "attach", ] diff --git a/linopy/spec/accessor.py b/linopy/spec/accessor.py index b8cec579..265b1cf8 100644 --- a/linopy/spec/accessor.py +++ b/linopy/spec/accessor.py @@ -18,7 +18,9 @@ from __future__ import annotations import functools +import warnings from collections.abc import Iterator, Mapping +from dataclasses import dataclass from pathlib import Path from typing import Any, TypeAlias @@ -28,14 +30,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 @@ -52,6 +53,82 @@ SpecLike: TypeAlias = str | Path | Mapping[str, Any] | Spec +# A note about what is missing, spelled as a comment of the format's own. A +# format math-spec grows later renders without one rather than with a wrong one. +_DRIFTED = "This model has drifted from the spec typeset here: {}." + +_COMMENT: dict[str, str] = { + "latex": "% {}", + "markdown": "", + "typst": "// {}", +} + + +@dataclass(frozen=True) +class Unspecified: + """ + 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. + + 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 + 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: + """``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, @@ -91,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]: @@ -156,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 @@ -174,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: @@ -231,20 +320,121 @@ def _declarations(self) -> list[str]: p = self.program return [*p.named_expressions, *p.constraints, *p.variables] + @property + def unspecified(self) -> Unspecified: + """ + How the model has drifted from this spec, see :class:`Unspecified`. + + 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( + 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: + """ + 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"} + What spells the math, as ``math_spec.typeset`` takes it. + **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 self._render(fmt, options, 3) + 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._render("latex", options, 3) 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._render("markdown", options, 3) 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._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 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(_DRIFTED.format(tally))}\n{rendered}" + + def _tally(self) -> str | None: + """How the model has drifted, counted and named; ``None`` when it has not.""" + found = self.unspecified + if not found: + return None + 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 + ) 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*{_DRIFTED.format(tally)}*" @property def _schema(self) -> dict[str, Any]: @@ -343,19 +533,27 @@ 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. + + 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: """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/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 91605cec..5bca044b 100644 --- a/test/test_spec_accessor.py +++ b/test/test_spec_accessor.py @@ -28,8 +28,13 @@ with_, yaml_dict, ) -from linopy import Model # noqa: E402 -from linopy.spec import ModelSpec, NamedExpression, SpecDataError # noqa: E402 +from linopy import Model, breakpoints # noqa: E402 +from linopy.spec import ( # noqa: E402 + ModelSpec, + NamedExpression, + SpecDataError, + Unspecified, +) pytestmark = [ pytest.mark.v1, @@ -288,7 +293,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 +301,102 @@ 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}")() + + +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( + 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( + ("fmt", "opener"), [("latex", "%"), ("markdown", "