mathspec 0.2.0__tar.gz → 0.2.1__tar.gz
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-0.2.0 → mathspec-0.2.1}/PKG-INFO +30 -10
- {mathspec-0.2.0 → mathspec-0.2.1}/README.md +29 -9
- {mathspec-0.2.0 → mathspec-0.2.1}/pyproject.toml +3 -2
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/__init__.py +3 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/__main__.py +51 -14
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_expression_parser.py +8 -4
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_expression_resolver.py +24 -8
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_where_parser.py +2 -1
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_where_resolver.py +28 -6
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/advising.py +57 -5
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/boundedness.py +4 -2
- mathspec-0.2.1/src/mathspec/canonical.py +268 -0
- mathspec-0.2.1/src/mathspec/composition.py +900 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/degree.py +1 -27
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/dimensions.py +34 -8
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/errors.py +4 -4
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/exclusivity.py +0 -22
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/expansion.py +23 -4
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/lowering.py +107 -5
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/operators.py +2 -2
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/program.py +59 -9
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/resolution.py +13 -8
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/spec.py +144 -23
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/__init__.py +19 -2
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/legend.py +43 -2
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/symbols.py +34 -13
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/walk.py +36 -12
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/validation.py +27 -4
- {mathspec-0.2.0 → mathspec-0.2.1}/.gitignore +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/LICENSE +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/Apache-2.0.txt +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/CC-BY-4.0.txt +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/CC0-1.0.txt +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/LICENSES/MIT.txt +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_sealed.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/_yaml.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/piecewise.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/py.typed +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/separability.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/sos.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/README.md +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/format.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/latex.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/markdown.py +0 -0
- {mathspec-0.2.0 → mathspec-0.2.1}/src/mathspec/typesetting/typst.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mathspec
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Write the specification (spec) of an optimisation model as a YAML file. Check it and print it as math, with no data and no solver.
|
|
5
5
|
Project-URL: Documentation, https://mathspec.readthedocs.io
|
|
6
6
|
Project-URL: Repository, https://github.com/energy-models/mathspec
|
|
@@ -49,15 +49,6 @@ and `cost`; the decisions the solver makes, such as `dispatch`; and the rules
|
|
|
49
49
|
those decisions obey, such as `sum(dispatch, over=generator) == load`. The file
|
|
50
50
|
[below](#example) is a complete spec.
|
|
51
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 a model from a spec and its
|
|
57
|
-
data, and solve it. Support in both is work in progress.
|
|
58
|
-
|
|
59
|
-
<!--- --8<-- [end:engines] -->
|
|
60
|
-
|
|
61
52
|
<!--- --8<-- [start:benefits] -->
|
|
62
53
|
|
|
63
54
|
- **Check specs in CI, with no data.** A wrong name or
|
|
@@ -208,6 +199,35 @@ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#sy
|
|
|
208
199
|
conventional spelling, as in the folded block.
|
|
209
200
|
[Print a spec as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
|
|
210
201
|
|
|
202
|
+
## Engines and other tools
|
|
203
|
+
|
|
204
|
+
<!--- --8<-- [start:engines] -->
|
|
205
|
+
|
|
206
|
+
mathspec builds nothing and solves nothing itself.
|
|
207
|
+
[specsolve](https://github.com/fluxopt/specsolve) and
|
|
208
|
+
[linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
|
|
209
|
+
data, and solve it. Support in both is work in progress. Any other tool can
|
|
210
|
+
read the same spec through the
|
|
211
|
+
[Program API](https://mathspec.readthedocs.io/en/latest/reference/program/).
|
|
212
|
+
The solid boxes are mathspec; the dashed boxes are outside it.
|
|
213
|
+
|
|
214
|
+
```mermaid
|
|
215
|
+
flowchart LR
|
|
216
|
+
accTitle: What mathspec does, and what other tools do with a spec
|
|
217
|
+
accDescr: A YAML file loads into a Spec and the Program it lowers to. mathspec checks the spec and prints it as math, with no data. Outside mathspec, drawn dashed, an engine such as specsolve or linopy reads the same spec, takes your data and returns your answers, and any other tool, such as a renderer or an analyser, reads the same spec through the Program API.
|
|
218
|
+
Y(["your spec<br/>one YAML file"]) --> SPEC["<b>Spec</b> and the <b>Program</b> it lowers to<br/><i>checked before any data exists</i>"]
|
|
219
|
+
SPEC --> CHECK["<b>check it</b><br/>python -m mathspec check"]
|
|
220
|
+
SPEC --> SHOW["<b>print it as math</b><br/>LaTeX · Typst · Markdown"]
|
|
221
|
+
SPEC -.-> OTHER["<b>your own tool</b><br/>a renderer · an analyser · …<br/>reads the Program API"]
|
|
222
|
+
SPEC -.-> ENGINE["<b>an engine</b><br/>specsolve · linopy<br/>builds and solves the model"]
|
|
223
|
+
DATA[("your data")] -.-> ENGINE
|
|
224
|
+
ENGINE -.-> ANS(["your answers"])
|
|
225
|
+
classDef outside stroke-dasharray:5 4
|
|
226
|
+
class ENGINE,DATA,ANS,OTHER outside
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
<!--- --8<-- [end:engines] -->
|
|
230
|
+
|
|
211
231
|
## Documentation
|
|
212
232
|
|
|
213
233
|
The documentation is at <https://mathspec.readthedocs.io>.
|
|
@@ -24,15 +24,6 @@ and `cost`; the decisions the solver makes, such as `dispatch`; and the rules
|
|
|
24
24
|
those decisions obey, such as `sum(dispatch, over=generator) == load`. The file
|
|
25
25
|
[below](#example) is a complete spec.
|
|
26
26
|
|
|
27
|
-
<!--- --8<-- [start:engines] -->
|
|
28
|
-
|
|
29
|
-
mathspec builds nothing and solves nothing itself.
|
|
30
|
-
[specsolve](https://github.com/fluxopt/specsolve) and
|
|
31
|
-
[linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
|
|
32
|
-
data, and solve it. Support in both is work in progress.
|
|
33
|
-
|
|
34
|
-
<!--- --8<-- [end:engines] -->
|
|
35
|
-
|
|
36
27
|
<!--- --8<-- [start:benefits] -->
|
|
37
28
|
|
|
38
29
|
- **Check specs in CI, with no data.** A wrong name or
|
|
@@ -183,6 +174,35 @@ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#sy
|
|
|
183
174
|
conventional spelling, as in the folded block.
|
|
184
175
|
[Print a spec as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
|
|
185
176
|
|
|
177
|
+
## Engines and other tools
|
|
178
|
+
|
|
179
|
+
<!--- --8<-- [start:engines] -->
|
|
180
|
+
|
|
181
|
+
mathspec builds nothing and solves nothing itself.
|
|
182
|
+
[specsolve](https://github.com/fluxopt/specsolve) and
|
|
183
|
+
[linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
|
|
184
|
+
data, and solve it. Support in both is work in progress. Any other tool can
|
|
185
|
+
read the same spec through the
|
|
186
|
+
[Program API](https://mathspec.readthedocs.io/en/latest/reference/program/).
|
|
187
|
+
The solid boxes are mathspec; the dashed boxes are outside it.
|
|
188
|
+
|
|
189
|
+
```mermaid
|
|
190
|
+
flowchart LR
|
|
191
|
+
accTitle: What mathspec does, and what other tools do with a spec
|
|
192
|
+
accDescr: A YAML file loads into a Spec and the Program it lowers to. mathspec checks the spec and prints it as math, with no data. Outside mathspec, drawn dashed, an engine such as specsolve or linopy reads the same spec, takes your data and returns your answers, and any other tool, such as a renderer or an analyser, reads the same spec through the Program API.
|
|
193
|
+
Y(["your spec<br/>one YAML file"]) --> SPEC["<b>Spec</b> and the <b>Program</b> it lowers to<br/><i>checked before any data exists</i>"]
|
|
194
|
+
SPEC --> CHECK["<b>check it</b><br/>python -m mathspec check"]
|
|
195
|
+
SPEC --> SHOW["<b>print it as math</b><br/>LaTeX · Typst · Markdown"]
|
|
196
|
+
SPEC -.-> OTHER["<b>your own tool</b><br/>a renderer · an analyser · …<br/>reads the Program API"]
|
|
197
|
+
SPEC -.-> ENGINE["<b>an engine</b><br/>specsolve · linopy<br/>builds and solves the model"]
|
|
198
|
+
DATA[("your data")] -.-> ENGINE
|
|
199
|
+
ENGINE -.-> ANS(["your answers"])
|
|
200
|
+
classDef outside stroke-dasharray:5 4
|
|
201
|
+
class ENGINE,DATA,ANS,OTHER outside
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
<!--- --8<-- [end:engines] -->
|
|
205
|
+
|
|
186
206
|
## Documentation
|
|
187
207
|
|
|
188
208
|
The documentation is at <https://mathspec.readthedocs.io>.
|
|
@@ -211,12 +211,13 @@ extend-exclude = ["CHANGELOG.md"]
|
|
|
211
211
|
[tool.typos.default.extend-words]
|
|
212
212
|
# Each of these is deliberate, and none is reachable by fixing a spelling.
|
|
213
213
|
#
|
|
214
|
-
# `wher` and `
|
|
215
|
-
# did-you-mean suggester, so correcting them deletes the case. `missable` is the
|
|
214
|
+
# `wher`, `generatr` and `balnce` are misspellings *on purpose*: they are the
|
|
215
|
+
# input to the did-you-mean suggester, so correcting them deletes the case. `missable` is the
|
|
216
216
|
# word the sentence wants — a coordinate that can go missing — and is not
|
|
217
217
|
# "miscible". `unparseable` is the accepted variant and is the name of a test.
|
|
218
218
|
wher = "wher"
|
|
219
219
|
generatr = "generatr"
|
|
220
|
+
balnce = "balnce"
|
|
220
221
|
missable = "missable"
|
|
221
222
|
unparseable = "unparseable"
|
|
222
223
|
# `fom` is PyPSA's `fom_cost` — fixed operation and maintenance — quoted by name
|
|
@@ -13,6 +13,7 @@ program instead.
|
|
|
13
13
|
|
|
14
14
|
from mathspec import program
|
|
15
15
|
from mathspec.advising import advice
|
|
16
|
+
from mathspec.composition import merge, override
|
|
16
17
|
from mathspec.errors import Advice, AdviceKind, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean
|
|
17
18
|
from mathspec.operators import BUILTIN_NAMES
|
|
18
19
|
from mathspec.spec import Spec
|
|
@@ -40,6 +41,8 @@ __all__ = [
|
|
|
40
41
|
'SymbolTable',
|
|
41
42
|
'advice',
|
|
42
43
|
'did_you_mean',
|
|
44
|
+
'merge',
|
|
45
|
+
'override',
|
|
43
46
|
'program',
|
|
44
47
|
'to_latex',
|
|
45
48
|
'to_markdown',
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
"""``python -m mathspec <verb> spec.yaml`` — the shell front.
|
|
6
6
|
|
|
7
|
-
``check`` loads the file and prints the language's advice;
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
``check`` loads the file and prints the language's advice; ``canonical``
|
|
8
|
+
writes the spec in the form two files that state the same spec share, or with
|
|
9
|
+
``--check`` asks whether the file is already in that form; one further verb per
|
|
10
|
+
typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb reads
|
|
11
|
+
the file as written, and nothing here writes a formulation out unasked.
|
|
10
12
|
The typeset verbs take ``--expand``, because a shell cannot compose
|
|
11
13
|
[`expand`][mathspec.spec.Spec.expand] the way a caller does and the rows are a
|
|
12
14
|
different document; ``check`` has no such flag, because advice reads a block
|
|
@@ -33,6 +35,13 @@ def parser() -> argparse.ArgumentParser:
|
|
|
33
35
|
check = verbs.add_parser('check', help='load a spec, and print what the language advises')
|
|
34
36
|
check.add_argument('spec', help='path to a mathspec YAML file')
|
|
35
37
|
|
|
38
|
+
canonical = verbs.add_parser('canonical', help='write a spec in the form two files that state it share')
|
|
39
|
+
canonical.add_argument('spec', help='path to a mathspec YAML file')
|
|
40
|
+
target = canonical.add_mutually_exclusive_group()
|
|
41
|
+
target.add_argument('-o', '--out', help='write here instead of stdout')
|
|
42
|
+
target.add_argument('--write', action='store_true', help='rewrite the file in the form, dropping its comments')
|
|
43
|
+
target.add_argument('--check', action='store_true', help='exit 1 if the file is not in the form, writing nothing')
|
|
44
|
+
|
|
36
45
|
for name in FORMATS:
|
|
37
46
|
verb = verbs.add_parser(name, help=f'render a spec as {name}')
|
|
38
47
|
verb.add_argument('spec', help='path to a mathspec YAML file')
|
|
@@ -52,10 +61,26 @@ def parser() -> argparse.ArgumentParser:
|
|
|
52
61
|
return front
|
|
53
62
|
|
|
54
63
|
|
|
64
|
+
def _checked(path: str, text: str) -> int:
|
|
65
|
+
"""Exit status 0 if the file at *path* already holds *text*, the form, and 1 with the rewrite on stderr if not.
|
|
66
|
+
|
|
67
|
+
A CI job runs this, so it compares bytes and writes nothing: the job fails,
|
|
68
|
+
and the author runs the rewrite the message names.
|
|
69
|
+
"""
|
|
70
|
+
if Path(path).read_text(encoding='utf-8') == text:
|
|
71
|
+
return 0
|
|
72
|
+
sys.stderr.write(
|
|
73
|
+
f'{path} is not in the canonical form. Run `python -m mathspec canonical --write {path}` to rewrite it.\n'
|
|
74
|
+
)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
|
|
55
78
|
def main(argv: list[str] | None = None) -> int:
|
|
56
79
|
"""Run one verb; a refused file is its message on stderr and exit status 1.
|
|
57
80
|
|
|
58
|
-
Advice is not a refusal: ``check`` prints it and exits 0.
|
|
81
|
+
Advice is not a refusal: ``check`` prints it and exits 0. A file that
|
|
82
|
+
``canonical --check`` finds out of the form exits 1, with the rewrite on
|
|
83
|
+
stderr.
|
|
59
84
|
"""
|
|
60
85
|
args = parser().parse_args(argv)
|
|
61
86
|
if args.verb == 'check':
|
|
@@ -66,16 +91,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
66
91
|
return 1
|
|
67
92
|
sys.stdout.write(''.join(f'{note}\n' for note in notes))
|
|
68
93
|
return 0
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
if args.verb == 'canonical':
|
|
95
|
+
try:
|
|
96
|
+
text = to_spec(args.spec).to_yaml(canonical=True)
|
|
97
|
+
except MathSpecError as e:
|
|
98
|
+
sys.stderr.write(f'{e}\n')
|
|
99
|
+
return 1
|
|
100
|
+
if args.check:
|
|
101
|
+
return _checked(args.spec, text)
|
|
102
|
+
if args.write:
|
|
103
|
+
Path(args.spec).write_text(text, encoding='utf-8')
|
|
104
|
+
return 0
|
|
105
|
+
else:
|
|
106
|
+
spec = to_spec(args.spec).expand() if args.expand else args.spec
|
|
107
|
+
text = typeset(
|
|
108
|
+
spec,
|
|
109
|
+
args.verb,
|
|
110
|
+
symbols=args.symbols,
|
|
111
|
+
standalone=args.standalone,
|
|
112
|
+
legend=not args.no_legend,
|
|
113
|
+
numbered=not args.no_numbers,
|
|
114
|
+
inline_expressions=args.inline_expressions,
|
|
115
|
+
)
|
|
79
116
|
if args.out:
|
|
80
117
|
Path(args.out).write_text(text, encoding='utf-8')
|
|
81
118
|
else:
|
|
@@ -220,6 +220,13 @@ def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arit
|
|
|
220
220
|
# ---------------------------------------------------------------------------
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
#: A bracketed list of names as a kwarg value, in an expression and in a where
|
|
224
|
+
#: string alike.
|
|
225
|
+
NAME_LIST = (pp.Suppress('[') + pp.DelimitedList(pp.Regex(NAME)) + pp.Suppress(']')).set_parse_action(
|
|
226
|
+
lambda t: NameListNode(tuple(str(x) for x in t))
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
223
230
|
def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
|
|
224
231
|
"""The arithmetic grammar, and the expression grammar that puts one comparison over it.
|
|
225
232
|
|
|
@@ -235,10 +242,7 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
|
|
|
235
242
|
name = pp.Regex(NAME)
|
|
236
243
|
|
|
237
244
|
quoted = (pp.QuotedString("'") | pp.QuotedString('"')).set_parse_action(lambda t: KeywordNode(str(t[0])))
|
|
238
|
-
|
|
239
|
-
lambda t: NameListNode(tuple(str(x) for x in t))
|
|
240
|
-
)
|
|
241
|
-
kwarg = (name + pp.Suppress('=') + (quoted | name_list | arith)).set_parse_action(lambda t: (t[0], t[1]))
|
|
245
|
+
kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | arith)).set_parse_action(lambda t: (t[0], t[1]))
|
|
242
246
|
pos_arg = arith
|
|
243
247
|
arg_list = pp.Optional(pp.DelimitedList(kwarg | pos_arg))
|
|
244
248
|
func_call = (name + pp.Suppress('(') + arg_list + pp.Suppress(')')).set_parse_action(_make_func_call)
|
|
@@ -182,7 +182,7 @@ class ExpressionResolver:
|
|
|
182
182
|
"""
|
|
183
183
|
if node.name in self.formals:
|
|
184
184
|
return None
|
|
185
|
-
if node.name in self.ns.
|
|
185
|
+
if node.name in self.ns.bodies:
|
|
186
186
|
try:
|
|
187
187
|
return cast('Expression', self.ns.named(node.name, self.context))
|
|
188
188
|
except SchemaError as e:
|
|
@@ -243,7 +243,7 @@ class ExpressionResolver:
|
|
|
243
243
|
f'{self.context}: {node.name}({", ".join(f"{k}=" for k in roles)}) names a column of a relation, '
|
|
244
244
|
f'and no by= names the relation. Write {builtin.usage}'
|
|
245
245
|
)
|
|
246
|
-
dims: dict[str, str | None] = {}
|
|
246
|
+
dims: dict[str, tuple[str, ...] | None] = {}
|
|
247
247
|
amounts: dict[str, int | str | None] = {}
|
|
248
248
|
edge: _Edge | None = None
|
|
249
249
|
for key, value in node.kwargs.items():
|
|
@@ -251,14 +251,15 @@ class ExpressionResolver:
|
|
|
251
251
|
case 'edge':
|
|
252
252
|
edge = self._edge(value, node.name)
|
|
253
253
|
case 'dimension':
|
|
254
|
-
dims[key] = self.
|
|
254
|
+
dims[key] = self._dim_refs(value, node.name, key, several=key in builtin.dimension_or_role_kwargs)
|
|
255
255
|
case 'value':
|
|
256
256
|
amounts[key] = self._amount(value, node.name, key)
|
|
257
257
|
case 'relation' | 'role' | None:
|
|
258
258
|
pass
|
|
259
259
|
read = None
|
|
260
260
|
if 'by' in node.kwargs and builtin.kind_of('by') == 'relation':
|
|
261
|
-
|
|
261
|
+
along = dims.get('along')
|
|
262
|
+
read = self.relation_ref(node.kwargs['by'], node.name, 'by', roles, along[0] if along else None)
|
|
262
263
|
unread = (
|
|
263
264
|
shape_error is not None
|
|
264
265
|
or unrelated
|
|
@@ -277,7 +278,7 @@ class ExpressionResolver:
|
|
|
277
278
|
self,
|
|
278
279
|
operator: str,
|
|
279
280
|
operand: Expression,
|
|
280
|
-
dims: Mapping[str, str | None],
|
|
281
|
+
dims: Mapping[str, tuple[str, ...] | None],
|
|
281
282
|
amounts: Mapping[str, int | str | None],
|
|
282
283
|
edge: _Edge | None,
|
|
283
284
|
read: Direction | Partition | None,
|
|
@@ -288,14 +289,15 @@ class ExpressionResolver:
|
|
|
288
289
|
assert isinstance(read, Direction), 'a sum reads its relation in a direction'
|
|
289
290
|
return GroupSum(operand, read)
|
|
290
291
|
if (over := dims.get('over')) is not None:
|
|
291
|
-
return Sum(operand,
|
|
292
|
+
return Sum(operand, over)
|
|
292
293
|
return self._bare_sum(operand)
|
|
293
294
|
if operator == 'at':
|
|
294
295
|
assert isinstance(read, Direction), 'at reads its relation in a direction'
|
|
295
296
|
return Pullback(operand, read)
|
|
296
297
|
assert read is None or isinstance(read, Partition), 'a translation reads its relation as a partition'
|
|
297
|
-
|
|
298
|
-
assert
|
|
298
|
+
named = dims['along']
|
|
299
|
+
assert named is not None, 'a translation names the dimension it steps along'
|
|
300
|
+
(along,) = named
|
|
299
301
|
wrap, fill = edge if edge is not None else (False, None)
|
|
300
302
|
if operator == 'shift':
|
|
301
303
|
offset = amounts['offset']
|
|
@@ -421,6 +423,20 @@ class ExpressionResolver:
|
|
|
421
423
|
return None
|
|
422
424
|
return False, literal.value
|
|
423
425
|
|
|
426
|
+
def _dim_refs(self, value: ArithmeticNode, operator: str, key: str, *, several: bool) -> tuple[str, ...] | None:
|
|
427
|
+
"""An operator kwarg whose *value* names declared dimensions: one, or a list where *several* are allowed."""
|
|
428
|
+
if not (several and isinstance(value, NameListNode)):
|
|
429
|
+
found = self._dim_ref(value, operator, key)
|
|
430
|
+
return None if found is None else (found,)
|
|
431
|
+
if repeated := sorted({name for name in value.names if value.names.count(name) > 1}):
|
|
432
|
+
self.errors.append(
|
|
433
|
+
f'{self.context}: {operator}({key}={value}) names {", ".join(map(repr, repeated))} twice. '
|
|
434
|
+
f'Name each dimension once.'
|
|
435
|
+
)
|
|
436
|
+
return None
|
|
437
|
+
found = [self._dim_ref(NameNode(name), operator, key) for name in value.names]
|
|
438
|
+
return None if None in found else tuple(cast('list[str]', found))
|
|
439
|
+
|
|
424
440
|
def _dim_ref(self, value: ArithmeticNode, operator: str, key: str) -> str | None:
|
|
425
441
|
"""An operator kwarg whose *value* must name a declared dimension."""
|
|
426
442
|
if self._formal(value):
|
|
@@ -23,6 +23,7 @@ import pyparsing as pp
|
|
|
23
23
|
from mathspec._expression_parser import (
|
|
24
24
|
ARITHMETIC,
|
|
25
25
|
NAME,
|
|
26
|
+
NAME_LIST,
|
|
26
27
|
ArithmeticNode,
|
|
27
28
|
KeywordNode,
|
|
28
29
|
NameNode,
|
|
@@ -154,7 +155,7 @@ def _build_where_grammar() -> pp.ParserElement:
|
|
|
154
155
|
)
|
|
155
156
|
comparator = pp.one_of(list(get_args(PredicateOperator)))
|
|
156
157
|
|
|
157
|
-
kwarg = (name + pp.Suppress('=') + (quoted | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1]))
|
|
158
|
+
kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1]))
|
|
158
159
|
|
|
159
160
|
def _call(head: pp.ParserElement) -> pp.ParserElement:
|
|
160
161
|
"""``<head>(<predicate>[, <kwarg>…])`` — the one shape whose operand is a predicate.
|
|
@@ -72,6 +72,7 @@ from mathspec.program import (
|
|
|
72
72
|
TypedPredicate,
|
|
73
73
|
VariableDefined,
|
|
74
74
|
carries_variable,
|
|
75
|
+
variables_of,
|
|
75
76
|
walk,
|
|
76
77
|
)
|
|
77
78
|
|
|
@@ -81,6 +82,14 @@ if TYPE_CHECKING:
|
|
|
81
82
|
from mathspec.program import DeclaredDtype
|
|
82
83
|
from mathspec.resolution import Namespace
|
|
83
84
|
|
|
85
|
+
#: Why a mask may not read a given expression. The namespace files a given
|
|
86
|
+
#: expression with the variables, since it is read as a column, so the refusal
|
|
87
|
+
#: names it apart.
|
|
88
|
+
GIVEN_IN_A_MASK = (
|
|
89
|
+
'A given expression may hold a variable, and a where mask is built before variables exist — it may test '
|
|
90
|
+
'parameters and dimension coordinates only.'
|
|
91
|
+
)
|
|
92
|
+
|
|
84
93
|
|
|
85
94
|
@dataclass(frozen=True)
|
|
86
95
|
class WhereResolver:
|
|
@@ -323,9 +332,9 @@ class WhereResolver:
|
|
|
323
332
|
value = literal.value
|
|
324
333
|
else:
|
|
325
334
|
value = _side_name(right)
|
|
326
|
-
if value is not None and (value in ns.
|
|
335
|
+
if value is not None and (value in ns.bodies or ns.kind(value) == 'parameter'):
|
|
327
336
|
return None
|
|
328
|
-
if name is None or value is None or name in ns.
|
|
337
|
+
if name is None or value is None or name in ns.bodies:
|
|
329
338
|
return None
|
|
330
339
|
return _Plain(name, node.op, value, quoted)
|
|
331
340
|
|
|
@@ -361,7 +370,12 @@ class WhereResolver:
|
|
|
361
370
|
assert len(sides) == 2, 'a side of a where builds or refuses, since a where holds no formal'
|
|
362
371
|
dims: set[str] = set()
|
|
363
372
|
for side in sides:
|
|
364
|
-
if
|
|
373
|
+
if given := sorted(variables_of(side) & set(ns.schema.given.expressions)):
|
|
374
|
+
self.errors.append(
|
|
375
|
+
f'{context}: a where compares expressions, and one side reads the given expression '
|
|
376
|
+
f'{given[0]!r}. {GIVEN_IN_A_MASK}'
|
|
377
|
+
)
|
|
378
|
+
elif carries_variable(side):
|
|
365
379
|
self.errors.append(
|
|
366
380
|
f'{context}: a where compares expressions, and one side names a variable. A where mask '
|
|
367
381
|
f'is built before variables exist — it may test parameters and dimension coordinates only.'
|
|
@@ -453,7 +467,8 @@ class WhereResolver:
|
|
|
453
467
|
return node
|
|
454
468
|
dims = tuple(ns.relations[left_name].dim(k) for k in ns.relations[left_name].key)
|
|
455
469
|
return RelationPairComparison(left_name, left, right_name, right, plain.op, dims)
|
|
456
|
-
|
|
470
|
+
given = right_name in ns.schema.given.expressions
|
|
471
|
+
self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind, given=given))
|
|
457
472
|
return node
|
|
458
473
|
|
|
459
474
|
kind = ns.kind(left_name)
|
|
@@ -491,6 +506,8 @@ class WhereResolver:
|
|
|
491
506
|
assert column is not None
|
|
492
507
|
shape = ns.relations[left_name]
|
|
493
508
|
return RelationComparison(left_name, column, plain.op, value, tuple(shape.dim(k) for k in shape.key))
|
|
509
|
+
case 'variable' if left_name in ns.schema.given.expressions:
|
|
510
|
+
self.errors.append(f"{context}: where references the given expression '{left_name}'. {GIVEN_IN_A_MASK}")
|
|
494
511
|
case 'variable':
|
|
495
512
|
self.errors.append(
|
|
496
513
|
f"{context}: where references variable '{left_name}'. A where "
|
|
@@ -693,9 +710,14 @@ def _not_arithmetic(context: str, side: ColumnNode | KeywordNode) -> str:
|
|
|
693
710
|
)
|
|
694
711
|
|
|
695
712
|
|
|
696
|
-
def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str) -> str:
|
|
697
|
-
"""Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension.
|
|
713
|
+
def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str, *, given: bool = False) -> str:
|
|
714
|
+
"""Why the right-hand side of a where-comparison may not name a variable, a relation or a dimension.
|
|
715
|
+
|
|
716
|
+
A given expression is filed with the variables, and *given* says the name is one.
|
|
717
|
+
"""
|
|
698
718
|
comparison = f"'{node.name} {node.op} {value}'"
|
|
719
|
+
if given:
|
|
720
|
+
return f'{context}: {comparison} compares against the given expression {value!r}. {GIVEN_IN_A_MASK}'
|
|
699
721
|
if kind == 'variable':
|
|
700
722
|
return (
|
|
701
723
|
f'{context}: {comparison} compares against variable {value!r}. '
|
|
@@ -35,15 +35,57 @@ def advice(spec: str | Path | Mapping[str, object] | Spec | Program) -> tuple[Ad
|
|
|
35
35
|
nothing expanded.
|
|
36
36
|
|
|
37
37
|
Returns:
|
|
38
|
-
The never-an-axis advice in declaration order, then
|
|
39
|
-
|
|
38
|
+
The never-an-axis advice in declaration order, then one note per
|
|
39
|
+
declaration the program reads and does not build, then the
|
|
40
|
+
unboundedness advice; ``str()`` of each is its sentence.
|
|
40
41
|
|
|
41
42
|
Raises:
|
|
42
43
|
LanguageError: *spec* does not load; [`to_spec`][] says why.
|
|
43
44
|
FileNotFoundError: A ``str`` with no newline that names no file.
|
|
44
45
|
"""
|
|
45
46
|
program = spec if isinstance(spec, Program) else to_spec(spec).program
|
|
46
|
-
return tuple(_never_an_axis(program) + unbounded_notes(program))
|
|
47
|
+
return tuple(_never_an_axis(program) + _given(program) + unbounded_notes(program))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _given(program: Program) -> list[Advice]:
|
|
51
|
+
"""One note per declaration the program reads and does not build.
|
|
52
|
+
|
|
53
|
+
A note rather than a refusal: the file is a spec somebody meant, and only
|
|
54
|
+
the consumer can tell whether the model it is layered onto provides the
|
|
55
|
+
name. An expression this file adds a term to is completed by `merge`
|
|
56
|
+
rather than by a host, so its note says that instead.
|
|
57
|
+
"""
|
|
58
|
+
given = program.given
|
|
59
|
+
written = {entry.adds_to for entry in program.expressions.values()}
|
|
60
|
+
return [
|
|
61
|
+
*(Advice('given', name, _given_note('parameter', name)) for name in given.parameters),
|
|
62
|
+
*(Advice('given', name, _given_note('variable', name)) for name in given.variables),
|
|
63
|
+
*(
|
|
64
|
+
Advice(
|
|
65
|
+
'given',
|
|
66
|
+
name,
|
|
67
|
+
_term_note(name) if name in written else _given_note('expression', name),
|
|
68
|
+
)
|
|
69
|
+
for name in given.expressions
|
|
70
|
+
),
|
|
71
|
+
*(Advice('given', name, _given_note('row family', name)) for name in given.constraints),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _given_note(kind: str, name: str) -> str:
|
|
76
|
+
return (
|
|
77
|
+
f"{kind} '{name}' is read here and declared elsewhere: the model this one is layered onto "
|
|
78
|
+
f'provides it. A consumer checks that it does, on the same frame, and refuses the program where '
|
|
79
|
+
f'it does not. A fragment is composed instead: merge() folds this declaration into the one a '
|
|
80
|
+
f'sibling introduces{", or writes it from the terms siblings add" if kind == "expression" else ""}.'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _term_note(name: str) -> str:
|
|
85
|
+
return (
|
|
86
|
+
f"expression '{name}' is read here, and this file adds a term to it: merge() sums the term with what "
|
|
87
|
+
f'the other files write under the name. Until then, the program reads it and does not build it.'
|
|
88
|
+
)
|
|
47
89
|
|
|
48
90
|
|
|
49
91
|
def _never_an_axis(program: Program) -> list[Advice]:
|
|
@@ -51,10 +93,20 @@ def _never_an_axis(program: Program) -> list[Advice]:
|
|
|
51
93
|
|
|
52
94
|
A dimension a relation has a column over is reached: its members are the
|
|
53
95
|
labels that column is checked against, and a ``where`` selects on them,
|
|
54
|
-
so it is in use even where nothing is indexed by it.
|
|
96
|
+
so it is in use even where nothing is indexed by it. A dimension only a
|
|
97
|
+
given declaration indexes is reached too: the column exists, in another
|
|
98
|
+
file.
|
|
55
99
|
"""
|
|
56
100
|
reached: set[str] = set()
|
|
57
|
-
for declaration in (
|
|
101
|
+
for declaration in (
|
|
102
|
+
*program.parameters.values(),
|
|
103
|
+
*program.variables.values(),
|
|
104
|
+
*program.constraints.values(),
|
|
105
|
+
*program.given.parameters.values(),
|
|
106
|
+
*program.given.variables.values(),
|
|
107
|
+
*program.given.expressions.values(),
|
|
108
|
+
*program.given.constraints.values(),
|
|
109
|
+
):
|
|
58
110
|
reached.update(declaration.dims)
|
|
59
111
|
reached |= _produced_axes(program)
|
|
60
112
|
reached |= {dim for lk in program.relations.values() for dim in lk.dims}
|
|
@@ -59,7 +59,9 @@ def unbounded_notes(program: Program) -> list[Advice]:
|
|
|
59
59
|
|
|
60
60
|
Returns:
|
|
61
61
|
One note per variable that is unbounded on the side its objective term
|
|
62
|
-
improves toward and named by no constraint.
|
|
62
|
+
improves toward and named by no constraint. A given column, which the
|
|
63
|
+
program reads and does not declare, has its bounds where it is built,
|
|
64
|
+
so it gets none.
|
|
63
65
|
"""
|
|
64
66
|
if program.objective is None:
|
|
65
67
|
return []
|
|
@@ -76,7 +78,7 @@ def unbounded_notes(program: Program) -> list[Advice]:
|
|
|
76
78
|
minimize = program.objective.sense == 'minimize'
|
|
77
79
|
notes: list[Advice] = []
|
|
78
80
|
for vname, sign in signs.items():
|
|
79
|
-
if sign is None or vname in constrained:
|
|
81
|
+
if sign is None or vname in constrained or vname not in program.variables:
|
|
80
82
|
continue
|
|
81
83
|
side: BoundSide = 'lower' if minimize == (sign == '+') else 'upper'
|
|
82
84
|
if _is_open(program.variables[vname], side):
|