mathspec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mathspec/validation.py ADDED
@@ -0,0 +1,338 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The front door, and the rules a declaration is held to against the others before any expression is read.
6
+
7
+ :func:`to_spec` reads a model definition into a :class:`~mathspec.model.Spec`.
8
+ :func:`reference_errors` holds the rules one declaration is held to against
9
+ the others — a name declared once, a frame over declared dimensions, a bound
10
+ naming a numeric parameter, a set over one dim of one variable, a curve
11
+ through parameters carrying its breakpoints — which lowering runs before it
12
+ reads any expression, since resolution assumes every one of them.
13
+ :func:`emitted_name_errors` is read off the program instead: what a block's
14
+ expansion writes is decided by the block as lowered. The rules that need a
15
+ typed expression stay with the expressions in :func:`~mathspec.lowering.lower`:
16
+ a macro formal against a dimension, a curve's links, and every dim rule.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections import Counter
22
+ from collections.abc import Mapping
23
+ from typing import TYPE_CHECKING
24
+
25
+ from mathspec._yaml import read_model
26
+ from mathspec.errors import SchemaError
27
+ from mathspec.model import NUMERIC_DTYPES, Spec, side_columns
28
+ from mathspec.operators import BUILTIN_NAMES
29
+ from mathspec.piecewise import Emitted as EmittedCurve
30
+ from mathspec.sos import Emitted as EmittedSet
31
+ from mathspec.sos import coefficients
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Iterable, Iterator
35
+ from pathlib import Path
36
+
37
+ from mathspec.program import Program
38
+
39
+
40
+ def to_spec(model: str | Path | Mapping[str, object] | Spec) -> Spec:
41
+ """Load and validate a model definition — the language's front door.
42
+
43
+ Everything decidable without data is decided here: schema shape, every
44
+ rule one declaration is held to against the others, every expression and
45
+ where string, and every macro template.
46
+
47
+ Args:
48
+ model: A YAML path — a :class:`~pathlib.Path`, or a ``str`` with no
49
+ newline in it — the YAML text itself as a ``str`` with one, a
50
+ mapping, or a loaded :class:`Spec`.
51
+
52
+ Returns:
53
+ The schema *as the file declares it*, ``piecewise:`` intact.
54
+
55
+ Raises:
56
+ LanguageError: Anything the language does not accept, a text that is
57
+ not a mapping of sections included.
58
+ FileNotFoundError: A ``str`` with no newline that names no file.
59
+ """
60
+ if isinstance(model, (list, tuple)):
61
+ msg = 'a model is one file, one dict or one Spec, never a list of them; merge the declarations into one dict.'
62
+ raise SchemaError(msg)
63
+ if isinstance(model, Spec):
64
+ return model
65
+ return Spec.model_validate(model if isinstance(model, Mapping) else read_model(model))
66
+
67
+
68
+ def emitted_name_errors(schema: Spec, program: Program) -> list[str]:
69
+ """Every name a set or curve of *program* would write out that *schema* already declares.
70
+
71
+ Read off the program rather than the file, since what a curve writes is
72
+ decided by the curve as lowered — its links, its method, its mask.
73
+ """
74
+ by_block = [
75
+ *((f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) for name, block in program.sos.items()),
76
+ *((f"piecewise '{name}'", EmittedCurve.of(name, curve).by_kind) for name, curve in program.piecewise.items()),
77
+ ]
78
+ return [error for context, by_kind in by_block for error in _collisions(schema, context, by_kind)]
79
+
80
+
81
+ def reference_errors(schema: Spec) -> list[str]:
82
+ """Every cross-declaration rule *schema* breaks, collected rather than raised on the first."""
83
+ return [
84
+ *_name_collisions(schema),
85
+ *_frame_dimensions(schema),
86
+ *_relation_targets(schema),
87
+ *_bound_names(schema),
88
+ *_sos_shapes(schema),
89
+ *_sos_bounds(schema),
90
+ *_piecewise_references(schema),
91
+ ]
92
+
93
+
94
+ def undeclared_dimension(kind: str, name: str, dimension: str) -> str:
95
+ """The one wording for a declaration naming a dimension the file does not declare."""
96
+ return f"{kind} '{name}' references undeclared dimension '{dimension}'. Declare it under 'dimensions:'."
97
+
98
+
99
+ def _flat_namespace(schema: Spec) -> list[tuple[str, Iterable[str]]]:
100
+ """Each kind of declaration whose names share the one namespace an expression reads, in declaration order."""
101
+ return [
102
+ ('dimension', schema.dimensions),
103
+ ('relation', schema.relations),
104
+ ('parameter', schema.parameters),
105
+ ('variable', schema.variables),
106
+ ('named expression', schema.expressions),
107
+ ('macro', schema.macros),
108
+ ]
109
+
110
+
111
+ def _name_collisions(schema: Spec) -> Iterator[str]:
112
+ """A name is declared once, and never as a built-in operator."""
113
+ seen: dict[str, str] = {}
114
+ for kind, group in _flat_namespace(schema):
115
+ for name in group:
116
+ if name in BUILTIN_NAMES:
117
+ yield (
118
+ f"{kind.capitalize()} '{name}' collides with the built-in operator "
119
+ f"'{name}'. The operator set is closed and its names are reserved; "
120
+ f'rename the {kind}.'
121
+ )
122
+ if name in seen:
123
+ yield (
124
+ f"{kind.capitalize()} '{name}' collides with the {seen[name]} of "
125
+ f'the same name. Names share one flat namespace — rename one of them.'
126
+ )
127
+ else:
128
+ seen[name] = kind
129
+
130
+
131
+ def _frame_dimensions(schema: Spec) -> Iterator[str]:
132
+ """Every frame is a product of distinct, declared dimensions."""
133
+ frames = [
134
+ *(('Parameter', name, p.dims) for name, p in schema.parameters.items()),
135
+ *(('Variable', name, v.dims) for name, v in schema.variables.items()),
136
+ *(('Constraint', name, c.dims) for name, c in schema.constraints.items()),
137
+ *(('Named expression', name, e.dims or []) for name, e in schema.expressions.items()),
138
+ ]
139
+ for kind, name, dims in frames:
140
+ yield from (undeclared_dimension(kind, name, d) for d in dims if d not in schema.dimensions)
141
+ yield from (
142
+ f"{kind} '{name}' names dimension '{d}' twice. A frame is a product of distinct dimensions."
143
+ for d, count in Counter(dims).items()
144
+ if count > 1
145
+ )
146
+
147
+
148
+ def _relation_targets(schema: Spec) -> Iterator[str]:
149
+ """A relation has at least two columns over declared dimensions, each named once, and a key naming some of them."""
150
+ for lname, lk in schema.relations.items():
151
+ if len(lk.pairs) < 2:
152
+ yield (
153
+ f"Relation '{lname}' has {len(lk.pairs)} column(s). A relation relates dimensions, so 'key:' and "
154
+ f"'values:' name at least two between them — a label on one dimension is a parameter over it."
155
+ )
156
+ if not lk.key_roles:
157
+ yield (
158
+ f"Relation '{lname}' names no key column. A relation is keyed by the columns a row is identified "
159
+ f"by — name them under 'key:', and leave the columns they determine to 'values:'."
160
+ )
161
+ for side, written in (('key', lk.key), ('values', lk.values)):
162
+ yield from (
163
+ f"Relation '{lname}' names dimension '{d}' twice under '{side}:'. Give the two columns roles: "
164
+ f'{side}: {{{d}0: {d}, {d}1: {d}}}.'
165
+ for d, count in Counter(dim for _, dim in side_columns(written)).items()
166
+ if count > 1 and not isinstance(written, dict)
167
+ )
168
+ yield from (
169
+ f"Relation '{lname}' names column '{role}' under both 'key:' and 'values:'. A relation names each "
170
+ f'column once — name the value column after what it holds: values: {{<name>: {dict(lk.pairs)[role]}}}.'
171
+ for role in dict.fromkeys(lk.key_roles)
172
+ if role in lk.value_roles
173
+ )
174
+ yield from (
175
+ undeclared_dimension('Relation', lname, d) for d in dict.fromkeys(lk.dims) if d not in schema.dimensions
176
+ )
177
+ yield from (
178
+ f"Relation '{lname}' names column '{role}' after dimension '{role}', but the column is over "
179
+ f"'{dim}'. A column named like a dimension is read as over it — name it after what it holds."
180
+ for role, dim in lk.pairs
181
+ if role in schema.dimensions and role != dim
182
+ )
183
+ if lk.value_roles:
184
+ yield from (
185
+ f"Relation '{lname}' has two key columns over '{d}' "
186
+ f'({[k for k in lk.key_roles if dict(lk.pairs)[k] == d]}). A key that determines a value is read '
187
+ f'its dimensions, and no frame carries a dimension twice — key the table by one column over '
188
+ f'each, or leave one of them a value column.'
189
+ for d, count in Counter(dict(lk.pairs)[k] for k in lk.key_roles).items()
190
+ if count > 1
191
+ )
192
+
193
+
194
+ def _bound_names(schema: Spec) -> Iterator[str]:
195
+ """A named bound is a numeric parameter."""
196
+ for vname, vdef in schema.variables.items():
197
+ for side in ('lower', 'upper'):
198
+ val = getattr(vdef.bounds, side)
199
+ if not isinstance(val, str):
200
+ continue
201
+ if val in schema.parameters:
202
+ dtype = schema.parameters[val].dtype
203
+ if dtype not in NUMERIC_DTYPES:
204
+ yield (
205
+ f"Variable '{vname}' bounds.{side}: '{val}' is a {dtype} parameter, and a bound "
206
+ f'is a number. Declare it dtype: float or int, or bound the variable by another.'
207
+ )
208
+ continue
209
+ detail = (
210
+ f"'{val}' is not a declared parameter"
211
+ if val.isidentifier()
212
+ else f'bounds accept a parameter name or a number, not an expression (got {val!r}). '
213
+ f'Precompute it as a parameter'
214
+ )
215
+ yield (f"Variable '{vname}' bounds.{side}: {detail}.")
216
+
217
+
218
+ def _sos_shapes(schema: Spec) -> Iterator[str]:
219
+ """A set runs along one dim of one declared variable, and a variable carries one set."""
220
+ claimed: dict[str, str] = {}
221
+ for sname, block in schema.sos.items():
222
+ context = f"Sos '{sname}'"
223
+ if block.along not in schema.dimensions:
224
+ yield (undeclared_dimension('Sos', sname, block.along))
225
+ elif block.variable not in schema.variables:
226
+ yield (
227
+ f"{context}: '{block.variable}' is not a declared variable.\n"
228
+ f' Variables: {sorted(schema.variables)}\n'
229
+ f'A set is over one variable, so a parameter or an expression cannot carry one.'
230
+ )
231
+ elif block.along not in schema.variables[block.variable].dims:
232
+ yield (
233
+ f"{context}: along '{block.along}' is not a dim of variable "
234
+ f"'{block.variable}' (dims {schema.variables[block.variable].dims}). The set runs "
235
+ f"along one of the variable's own dims — one set per coordinate of the rest."
236
+ )
237
+ elif block.variable in claimed:
238
+ yield (
239
+ f"{context}: variable '{block.variable}' already carries the set declared by "
240
+ f"'{claimed[block.variable]}'. A variable holds one set — declare a second "
241
+ f'variable, or state the other restriction as a constraint.'
242
+ )
243
+ else:
244
+ claimed[block.variable] = sname
245
+
246
+
247
+ def _sos_bounds(schema: Spec) -> Iterator[str]:
248
+ """A set states what the binaries it expands to state: each side of a member carries a coefficient.
249
+
250
+ The rewrite holds an unpicked member at zero from both sides, so a side
251
+ the model leaves open leaves the member free of it. Either coefficient
252
+ may be a parameter, because a row multiplies by it rather than reading
253
+ it. Decided here rather than where the rewrite runs, so a set the
254
+ language cannot state twice is refused before any data exists.
255
+ """
256
+ for sname, block in schema.sos.items():
257
+ if (member := schema.variables.get(block.variable)) is None:
258
+ continue
259
+ context = f"Sos '{sname}'"
260
+ below, above = coefficients(member.domain, member.bounds.lower, member.bounds.upper)
261
+ if below is None:
262
+ yield (
263
+ f"{context}: variable '{block.variable}' has no lower bound, and the set expands to rows "
264
+ f'that hold an unpicked member at zero from below as well as above. Declare bounds.lower, '
265
+ f'as a number or a parameter.'
266
+ )
267
+ if above is None:
268
+ yield (
269
+ f"{context}: variable '{block.variable}' has no upper bound, and the set expands to rows "
270
+ f'that hold an unpicked member at zero from above as well as below. Declare bounds.upper, '
271
+ f'as a number or a parameter.'
272
+ )
273
+
274
+
275
+ def _piecewise_references(schema: Spec) -> Iterator[str]:
276
+ """A curve runs along a declared dimension through numeric values parameters carrying it, gated by a binary, masked by a bool."""
277
+ for name, pw in schema.piecewise.items():
278
+ context = f"piecewise '{name}'"
279
+ if pw.over not in schema.dimensions:
280
+ yield undeclared_dimension('piecewise', name, pw.over)
281
+ continue
282
+ for i, link in enumerate(pw.links):
283
+ if link.values not in schema.parameters:
284
+ yield f"{context}: link {i} values references undeclared parameter '{link.values}'"
285
+ elif (dtype := schema.parameters[link.values].dtype) not in NUMERIC_DTYPES:
286
+ yield (
287
+ f"{context}: link {i} values parameter '{link.values}' is declared dtype: {dtype}, and a "
288
+ f'breakpoint is a number. Declare it dtype: float or int.'
289
+ )
290
+ elif pw.over not in schema.parameters[link.values].dims:
291
+ yield (
292
+ f"{context}: link {i} values parameter '{link.values}' must carry dim "
293
+ f"'{pw.over}' (has {schema.parameters[link.values].dims})"
294
+ )
295
+ if (activity := pw.activity) is not None:
296
+ if activity not in schema.variables:
297
+ yield (
298
+ f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; "
299
+ f'declare it, or drop activity: for weights that sum to 1.'
300
+ )
301
+ elif schema.variables[activity].domain != 'binary':
302
+ yield f"{context}: activity variable '{activity}' must be binary"
303
+ if (points := pw.points) is None or pw.nominated is not None:
304
+ continue
305
+ if points not in schema.parameters:
306
+ yield f"{context}: points references undeclared parameter '{points}'"
307
+ elif (dtype := schema.parameters[points].dtype) != 'bool':
308
+ yield (
309
+ f"{context}: points parameter '{points}' is {dtype}, and a mask is a bool parameter — one "
310
+ f'saying, per breakpoint, whether the curve reaches it. Declare it dtype: bool.'
311
+ )
312
+ elif pw.over not in schema.parameters[points].dims:
313
+ yield (
314
+ f"{context}: points parameter '{points}' must carry dim '{pw.over}' — "
315
+ f'it says how far each curve runs along it (has {schema.parameters[points].dims})'
316
+ )
317
+
318
+
319
+ def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]:
320
+ """The refusal for each name *context*'s expansion writes that the file already declares, by kind.
321
+
322
+ An emitted variable joins the flat namespace, so any declaration there
323
+ takes its name; a constraint, a set and an assumption each have their own.
324
+ """
325
+ sections = {'named expression': 'expressions', 'sos': 'sos'}
326
+ declared: dict[str, dict[str, str]] = {
327
+ 'variable': {name: kind for kind, group in _flat_namespace(schema) for name in group},
328
+ 'constraint': dict.fromkeys(schema.constraints, 'constraint'),
329
+ 'sos': dict.fromkeys(schema.sos, 'sos'),
330
+ 'assumption': dict.fromkeys(schema.assumptions, 'assumption'),
331
+ }
332
+ for kind, names in by_kind:
333
+ yield from (
334
+ f"{context}: its expansion writes {kind} '{one}', which this file already declares under "
335
+ f"'{sections.get(declared[kind][one], declared[kind][one] + 's')}:'. Rename one of them."
336
+ for one in names
337
+ if one in declared[kind]
338
+ )
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.5
2
+ Name: mathspec
3
+ Version: 0.1.0
4
+ Summary: Write an optimisation model as a YAML file. Check it and print it as math, with no data and no solver.
5
+ Project-URL: Documentation, https://mathspec.readthedocs.io
6
+ Project-URL: Repository, https://github.com/energy-models/mathspec
7
+ Project-URL: Issues, https://github.com/energy-models/mathspec/issues
8
+ Project-URL: Changelog, https://github.com/energy-models/mathspec/blob/main/CHANGELOG.md
9
+ Author-email: Felix Bumann <117816358+FBumann@users.noreply.github.com>, Bryn Pickering <17178478+brynpickering@users.noreply.github.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: energy system modelling,latex,linear programming,optimisation,yaml
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: pydantic>=2.1
22
+ Requires-Dist: pyparsing>=3.1
23
+ Requires-Dist: pyyaml>=6.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ <!--
27
+ SPDX-FileCopyrightText: mathspec contributors
28
+ SPDX-License-Identifier: CC-BY-4.0
29
+ -->
30
+
31
+ # mathspec
32
+
33
+ <!--- --8<-- [start:badges] -->
34
+
35
+ [![CI](https://img.shields.io/github/actions/workflow/status/energy-models/mathspec/ci.yml?style=flat-square&branch=main)](https://github.com/energy-models/mathspec/actions/workflows/ci.yml)
36
+ [![conda-forge](https://img.shields.io/conda/vn/conda-forge/mathspec?logoColor=white&logo=conda-forge&style=flat-square)](https://prefix.dev/channels/conda-forge/packages/mathspec)
37
+ [![pypi-version](https://img.shields.io/pypi/v/mathspec.svg?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/mathspec)
38
+ [![python-version](https://img.shields.io/pypi/pyversions/mathspec?logoColor=white&logo=python&style=flat-square)](https://pypi.org/project/mathspec)
39
+ [![Documentation build status](https://readthedocs.org/projects/mathspec/badge/?version=latest&style=flat-square)](https://mathspec.readthedocs.io)
40
+
41
+ <!--- --8<-- [end:badges] -->
42
+
43
+ **Write an optimisation model as a YAML file. Check it and print it as math,
44
+ with no data and no solver.**
45
+
46
+ A mathspec file declares four things: the axes the model runs over, such as
47
+ `snapshot` and `generator`; the data it expects, such as `load` and `cost`; the
48
+ decisions the solver makes, such as `dispatch`; and the rules those decisions
49
+ obey, such as `sum(dispatch, over=generator) == load`. The file
50
+ [below](#example) is a complete model.
51
+
52
+ <!--- --8<-- [start:engines] -->
53
+
54
+ mathspec builds nothing and solves nothing itself.
55
+ [specsolve](https://github.com/fluxopt/specsolve) and
56
+ [linopy](https://github.com/PyPSA/linopy) build and solve a mathspec model.
57
+ Support in both is work in progress.
58
+
59
+ <!--- --8<-- [end:engines] -->
60
+
61
+ <!--- --8<-- [start:benefits] -->
62
+
63
+ - **Check models in CI, with no data.** A wrong name or
64
+ dimension fails when the file loads, and the error names the fix.
65
+ [Errors →](https://mathspec.readthedocs.io/en/latest/reference/language/errors/)
66
+ - **Publish the math you solve.** The equations in the paper
67
+ print from the file the solver reads.
68
+ [Typeset →](https://mathspec.readthedocs.io/en/latest/reference/typeset/)
69
+ - **One model, many tools.** Engines, renderers and analysers read the model
70
+ through one public API, so no two of them can read the file differently.
71
+ [Program API →](https://mathspec.readthedocs.io/en/latest/reference/program/)
72
+ - **Write full-size models.** PyPSA's `n.optimize()` model is
73
+ one file, with stochastic, multi-period and quadratic variants.
74
+ [PyPSA in one file →](https://mathspec.readthedocs.io/en/latest/examples/pypsa/)
75
+
76
+ <!--- --8<-- [end:benefits] -->
77
+
78
+ ## Example
79
+
80
+ <!--- --8<-- [start:model] -->
81
+
82
+ ```yaml title="dispatch.yaml"
83
+ description: Least-cost dispatch of a generator fleet against an hourly load.
84
+
85
+ dimensions:
86
+ snapshot: { dtype: int, description: dispatch periods }
87
+ generator: { description: generating units }
88
+
89
+ parameters:
90
+ capacity: { dims: [generator], description: installed capacity }
91
+ load: { dims: [snapshot], description: demand to be met }
92
+ cost: { dims: [generator], description: marginal cost }
93
+
94
+ variables:
95
+ dispatch:
96
+ description: output of a generator in a snapshot
97
+ dims: [snapshot, generator]
98
+ where: "capacity > 0"
99
+ bounds: { lower: 0, upper: capacity }
100
+
101
+ constraints:
102
+ power_balance:
103
+ dims: [snapshot]
104
+ expression: sum(dispatch, over=generator) == load
105
+
106
+ objective:
107
+ sense: minimize
108
+ expression: sum(dispatch * cost)
109
+ ```
110
+
111
+ <!--- --8<-- [end:model] -->
112
+
113
+ ### The math it prints
114
+
115
+ The typesetter prints the file above as math, with no data and no solver.
116
+ Markdown is one of three formats, and GitHub renders it here.
117
+
118
+ <!-- Prettier pads the legend tables that the generator emits unpadded, so the
119
+ two would rewrite each other forever. The range keeps this file formatted
120
+ and the block below byte-for-byte what the typesetter printed. -->
121
+ <!-- prettier-ignore-start -->
122
+ <!-- readme-math:begin -->
123
+
124
+ Least-cost dispatch of a generator fleet against an hourly load.
125
+
126
+ #### Objective
127
+
128
+ ```math
129
+ \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g}
130
+ ```
131
+
132
+ #### Subject to
133
+
134
+ **`power_balance`**
135
+
136
+ ```math
137
+ \sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T}
138
+ ```
139
+
140
+ #### Variable domains
141
+
142
+ **`dispatch`**
143
+
144
+ ```math
145
+ 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
146
+ ```
147
+
148
+ <details>
149
+ <summary>The whole document: a symbol table, and the legend it prints</summary>
150
+
151
+ Least-cost dispatch of a generator fleet against an hourly load.
152
+
153
+ #### Sets
154
+
155
+ | Symbol | Meaning |
156
+ |---|---|
157
+ | $`\mathcal{S}`$ | index $`s`$ — `snapshot` — dispatch periods |
158
+ | $`\mathcal{G}`$ | index $`g`$ — `generator` — generating units |
159
+
160
+ #### Parameters
161
+
162
+ | Symbol | Meaning |
163
+ |---|---|
164
+ | $`\bar p`$ | `capacity` over $`\mathcal{G}`$ — installed capacity |
165
+ | $`\ell`$ | `load` over $`\mathcal{S}`$ — demand to be met |
166
+ | $`c`$ | `cost` over $`\mathcal{G}`$ — marginal cost |
167
+
168
+ #### Variables
169
+
170
+ | Symbol | Meaning |
171
+ |---|---|
172
+ | $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot |
173
+
174
+ #### Objective
175
+
176
+ $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}`$
177
+
178
+ #### Subject to
179
+
180
+ **`power_balance`**
181
+
182
+ $`\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S}`$
183
+
184
+ #### Variable domains
185
+
186
+ **`dispatch`**
187
+
188
+ $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
189
+
190
+ </details>
191
+
192
+ <!-- readme-math:end -->
193
+ <!-- prettier-ignore-end -->
194
+
195
+ Each format is one call:
196
+
197
+ ```python
198
+ import mathspec as ms
199
+
200
+ spec = ms.to_spec('dispatch.yaml')
201
+
202
+ ms.to_markdown(spec)
203
+ ms.to_latex(spec)
204
+ ms.to_typst(spec)
205
+ ```
206
+
207
+ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#symbol-tables) gives the names their
208
+ conventional spelling, as in the folded block.
209
+ [Print a model as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
210
+
211
+ ## Documentation
212
+
213
+ The documentation is at <https://mathspec.readthedocs.io>.
214
+
215
+ ## Installation
216
+
217
+ See [installation](https://mathspec.readthedocs.io/en/latest/howto/installation/). To work on mathspec, see
218
+ [contributing](https://mathspec.readthedocs.io/en/latest/contributing/#setting-up-a-development-environment).
219
+
220
+ ## Prior art
221
+
222
+ Every file under `src/` was written in [specsolve](https://github.com/fluxopt/specsolve)
223
+ and extracted here. The keys themselves,
224
+ which are YAML math, a block per component, `dims:` and a `where:` string,
225
+ come from [Calliope](https://github.com/calliope-project/calliope).
226
+ [linopy](https://github.com/PyPSA/linopy) supplies the vocabulary that
227
+ `sum(over=)` and the dimension rules are named against.
228
+
229
+ ## Status
230
+
231
+ Alpha, pre-1.0.
232
+
233
+ <!--- --8<-- [start:status] -->
234
+
235
+ **Breaking changes land without a deprecation cycle.** Pin an exact version if
236
+ you depend on this, and read the
237
+ [changelog](https://github.com/energy-models/mathspec/blob/main/CHANGELOG.md)
238
+ before upgrading. Every construct round-trips through the schema, the parsers
239
+ and all three typeset formats, and the LaTeX is compiled. The accepted YAML is
240
+ not yet frozen.
241
+
242
+ <!--- --8<-- [end:status] -->
243
+
244
+ ## Licence
245
+
246
+ The code is [MIT](https://github.com/energy-models/mathspec/blob/main/LICENSE), and the prose is
247
+ [CC-BY-4.0](https://github.com/energy-models/mathspec/blob/main/LICENSES/CC-BY-4.0.txt).
@@ -0,0 +1,38 @@
1
+ mathspec/__init__.py,sha256=Jd2Y28k8XhQ3HOu_PXYJa43_uyH-sbZpu2cun6KjqPw,1583
2
+ mathspec/__main__.py,sha256=LM-csTCgz1KW4CW19f5SxmXc_RQ43SNOAOSE1iw781o,3292
3
+ mathspec/_expression_parser.py,sha256=S1zJRn1jLCA-Ou9YwXfY-0oCGZsfj9i85H4GCABbwyo,16682
4
+ mathspec/_expression_resolver.py,sha256=yPhLVDa7qXdmHUjInwbqGwPtcuz9QKdKizo-dU1xZCE,34354
5
+ mathspec/_sealed.py,sha256=gWNRdwnmEKtIemF8uyyzm1tcb_MbCRNOyEcf8Gy9kiM,1867
6
+ mathspec/_where_parser.py,sha256=Pvrn9PCZnNJFhzXIhSSCbKQD7hVE3V2_gD6p1QaoDm8,10851
7
+ mathspec/_where_resolver.py,sha256=29VZPAlYoJBs2khmTZ7e_WiLMM2R7NYKuUyfaVQFQNA,34892
8
+ mathspec/_yaml.py,sha256=OzPtA2zj_FVqec1FOskTlg-FZLryXHzfydLyeSt2KVw,5567
9
+ mathspec/advice.py,sha256=t568Orp2_wzOtrMtO5BMj3w2PUL8LkxHCbqpbIKVaP4,2904
10
+ mathspec/boundedness.py,sha256=D3KOpMucUak9USQ6xexDegQAr-PcLFYL1SesCcZ-670,6318
11
+ mathspec/degree.py,sha256=AbYQiCnTJdbo7Rm7Ux1zDHcyJJaGhO7r5PXD0GJaEXg,8911
12
+ mathspec/dimensions.py,sha256=-LmJTVmNkTnjwYNbtmUU1oymlb0VVEuxvewJ9HD1ejs,16289
13
+ mathspec/errors.py,sha256=KlRMkHbr7YeeLxVcCz_VxsHElbAgWBH4WA4bZQLGpDI,3466
14
+ mathspec/exclusivity.py,sha256=y8o5NmHXHTGFiFm1VyNES1CK7BJGlvGpqg-vlynd4jk,24193
15
+ mathspec/expansion.py,sha256=nSc5PIrRQQVHr4JVTlh4hJnZFR9JOyWe1E9j31E8_Wo,4878
16
+ mathspec/lowering.py,sha256=kdLruVuXZ5i88lyj_RjtE1FVB04j8UpMJlO3XMRqpVg,12544
17
+ mathspec/model.py,sha256=9BnNrI_l8XARAPBZG_CP3r4mFzvB7X7ZZA7Lfu8gMEI,36629
18
+ mathspec/operators.py,sha256=1R1PjXyPlSG_3qh7Tea-y4iHb9wNX-lJ56P7vtu4woM,9731
19
+ mathspec/piecewise.py,sha256=UTFJclwGzJifFc7KwY4GcM--IOQbLngKYgwSurrcjwI,23653
20
+ mathspec/program.py,sha256=WNNA4Qja6rxlAnp_ZYmTdnT8ENgJegLNBPaTH7HaFJ4,57120
21
+ mathspec/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ mathspec/resolution.py,sha256=NtyK7US1vWn0sCELJl2SZmtvDdjjjw_x64hgdNj4Lt0,20362
23
+ mathspec/separability.py,sha256=SDc-FZcCWz3CrbuMU4qCbPY_2zR1JQCprZSgQqiaSxY,7914
24
+ mathspec/sos.py,sha256=HgKh5bNd4mcVMKfKfiQ3mCbadLH2e9nGq6SS-ow2eo0,6650
25
+ mathspec/validation.py,sha256=0WozInIA3QghmzsZQoKy5ebvdehg4Z6r2oBzdIgI4Kc,16423
26
+ mathspec/typesetting/README.md,sha256=VwaKV8GQaVeAL622P1Zl6xMw0JzdiMj5jsS6Qee_Qq4,5664
27
+ mathspec/typesetting/__init__.py,sha256=qiHzvlaoEHohO3k3-A3PdAyFEFBLhUN_bcEozpM50uQ,8888
28
+ mathspec/typesetting/format.py,sha256=9oCbX032IP69kfaZWcI75jqaEmTnuMkXQmmNxcGEbqk,8884
29
+ mathspec/typesetting/latex.py,sha256=l65UPmIAtilHTb8nhNdRXzjYZbqZg9aKQF5GcDv90xo,4664
30
+ mathspec/typesetting/legend.py,sha256=_IiC-UO7tgQbkxc81oX1O-NqLioqZkMHVRuDKn2kMd8,13259
31
+ mathspec/typesetting/markdown.py,sha256=yOh_0AQJFooZZYwDk2H7pa7gV-mPNI9hK7Zy31Ya42w,4129
32
+ mathspec/typesetting/symbols.py,sha256=kBDkmTXGu0aU6NpvpRVKEntFBWbVg2I8Sp7qTVR4yFQ,12962
33
+ mathspec/typesetting/typst.py,sha256=zSwAQDpiq2L27k_l69nKrJKWT5aLpGsy1GpWeQiOi2s,5037
34
+ mathspec/typesetting/walk.py,sha256=mfjfRGkDZEjI_n4IXRNg3IHF2OqIrhrzjT9WHw2pB8I,44457
35
+ mathspec-0.1.0.dist-info/METADATA,sha256=P3yy8AILnLA1A8xuiHm7k7Y_Fd-9UyfnD-MM72y1o14,8713
36
+ mathspec-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
37
+ mathspec-0.1.0.dist-info/licenses/LICENSE,sha256=UnTZoIrNZRqtydvUET5bawvnclcEVLKfhzVaKe2Yk7M,1070
38
+ mathspec-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 energy-models
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.