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/__init__.py +59 -0
- mathspec/__main__.py +87 -0
- mathspec/_expression_parser.py +436 -0
- mathspec/_expression_resolver.py +739 -0
- mathspec/_sealed.py +62 -0
- mathspec/_where_parser.py +288 -0
- mathspec/_where_resolver.py +745 -0
- mathspec/_yaml.py +141 -0
- mathspec/advice.py +79 -0
- mathspec/boundedness.py +168 -0
- mathspec/degree.py +209 -0
- mathspec/dimensions.py +377 -0
- mathspec/errors.py +105 -0
- mathspec/exclusivity.py +587 -0
- mathspec/expansion.py +124 -0
- mathspec/lowering.py +302 -0
- mathspec/model.py +905 -0
- mathspec/operators.py +235 -0
- mathspec/piecewise.py +505 -0
- mathspec/program.py +1514 -0
- mathspec/py.typed +0 -0
- mathspec/resolution.py +475 -0
- mathspec/separability.py +185 -0
- mathspec/sos.py +159 -0
- mathspec/typesetting/README.md +110 -0
- mathspec/typesetting/__init__.py +223 -0
- mathspec/typesetting/format.py +273 -0
- mathspec/typesetting/latex.py +139 -0
- mathspec/typesetting/legend.py +286 -0
- mathspec/typesetting/markdown.py +105 -0
- mathspec/typesetting/symbols.py +310 -0
- mathspec/typesetting/typst.py +147 -0
- mathspec/typesetting/walk.py +941 -0
- mathspec/validation.py +338 -0
- mathspec-0.1.0.dist-info/METADATA +247 -0
- mathspec-0.1.0.dist-info/RECORD +38 -0
- mathspec-0.1.0.dist-info/WHEEL +4 -0
- mathspec-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://github.com/energy-models/mathspec/actions/workflows/ci.yml)
|
|
36
|
+
[](https://prefix.dev/channels/conda-forge/packages/mathspec)
|
|
37
|
+
[](https://pypi.org/project/mathspec)
|
|
38
|
+
[](https://pypi.org/project/mathspec)
|
|
39
|
+
[](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,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.
|