makepst 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.
- makepst/__init__.py +25 -0
- makepst/__main__.py +3 -0
- makepst/checks.py +411 -0
- makepst/cli.py +351 -0
- makepst/diff.py +151 -0
- makepst/excel.py +336 -0
- makepst/phi.py +89 -0
- makepst/provenance.py +89 -0
- makepst/pst.py +449 -0
- makepst/pyemu_bridge.py +39 -0
- makepst/reader.py +236 -0
- makepst/sections.py +264 -0
- makepst/starter.py +295 -0
- makepst/writer.py +164 -0
- makepst-0.1.0.dist-info/METADATA +514 -0
- makepst-0.1.0.dist-info/RECORD +20 -0
- makepst-0.1.0.dist-info/WHEEL +5 -0
- makepst-0.1.0.dist-info/entry_points.txt +2 -0
- makepst-0.1.0.dist-info/licenses/LICENSE +21 -0
- makepst-0.1.0.dist-info/top_level.txt +1 -0
makepst/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Build, read and round-trip PEST control files from spreadsheet tables."""
|
|
2
|
+
try: # the installed package's metadata (pyproject.toml is the source)
|
|
3
|
+
from importlib.metadata import version as _dist_version
|
|
4
|
+
__version__ = _dist_version('makepst')
|
|
5
|
+
except Exception: # a checkout run without installing
|
|
6
|
+
__version__ = '0.1.0'
|
|
7
|
+
|
|
8
|
+
from .excel import load_table, to_workbook, update_workbook
|
|
9
|
+
from .provenance import Manifest
|
|
10
|
+
from .pst import Pst, read_ensemble, read_obs_ensemble, read_par, read_res
|
|
11
|
+
from .reader import from_text, read_pst
|
|
12
|
+
from .writer import to_text, write_pst
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_pyemu(pst):
|
|
16
|
+
from .pyemu_bridge import to_pyemu as _to
|
|
17
|
+
return _to(pst)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def from_pyemu(ppst):
|
|
21
|
+
from .pyemu_bridge import from_pyemu as _from
|
|
22
|
+
return _from(ppst)
|
|
23
|
+
|
|
24
|
+
__all__ = ['Pst', 'read_pst', 'from_text', 'write_pst', 'to_text',
|
|
25
|
+
'load_table', 'to_workbook', 'update_workbook', 'read_par', 'read_res', 'read_ensemble', 'read_obs_ensemble', 'Manifest', '__version__', 'to_pyemu', 'from_pyemu']
|
makepst/__main__.py
ADDED
makepst/checks.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""`makepst validate`: what pestchek would say, before the control file is written.
|
|
2
|
+
|
|
3
|
+
Checks the tables (duplicates, groups, ties, bounds, prior equations, name lengths), the
|
|
4
|
+
files the control file points at (templates, instruction files, model command), and the
|
|
5
|
+
names those files cite against the tables. With `outputs=True`, instruction files are also
|
|
6
|
+
run against the model output files that exist, with a small interpreter that follows the
|
|
7
|
+
PEST manual; it is not PEST, so its findings are warnings.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from .pst import ADJUSTABLE, Pst, equation_params, is_number
|
|
15
|
+
from .sections import ALL_SECTIONS
|
|
16
|
+
|
|
17
|
+
KNOWN_SECTIONS = {s.name for s in ALL_SECTIONS} | {
|
|
18
|
+
'regularization', 'parameter groups', 'parameter data', 'observation groups', 'observation data',
|
|
19
|
+
'model command line', 'model input/output', 'prior information',
|
|
20
|
+
'sensitivity reuse', 'derivatives command line', 'predictive analysis', 'pareto',
|
|
21
|
+
'control data keyword',
|
|
22
|
+
}
|
|
23
|
+
LIMITS = {'parameter': 12, 'observation': 20, 'group': 12} # classic PEST; PEST++ allows 200
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Finding:
|
|
27
|
+
__slots__ = ('severity', 'where', 'message')
|
|
28
|
+
|
|
29
|
+
def __init__(self, severity, where, message):
|
|
30
|
+
self.severity, self.where, self.message = severity, where, message
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
return f'{self.severity.upper():<8}{self.where}: {self.message}'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _names(items, n=6):
|
|
37
|
+
items = list(items)
|
|
38
|
+
s = ', '.join(str(i) for i in items[:n])
|
|
39
|
+
return s + (f' ... ({len(items)} total)' if len(items) > n else '')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------- tables
|
|
43
|
+
def check_tables(pst: Pst):
|
|
44
|
+
"""Internal consistency of the tables; the same rules as Pst.validate(), without changing anything."""
|
|
45
|
+
out = []
|
|
46
|
+
err = lambda where, msg: out.append(Finding('error', where, msg)) # noqa: E731
|
|
47
|
+
warn = lambda where, msg: out.append(Finding('warning', where, msg)) # noqa: E731
|
|
48
|
+
par, obs = pst.par, pst.obs
|
|
49
|
+
|
|
50
|
+
if pst.npar == 0:
|
|
51
|
+
err('parameters', 'no parameters')
|
|
52
|
+
if pst.nobs == 0:
|
|
53
|
+
err('observations', 'no observations')
|
|
54
|
+
for what, col in (('parameters', par['PARNME']), ('observations', obs['OBSNME'])):
|
|
55
|
+
dup = col[col.duplicated()].unique()
|
|
56
|
+
if len(dup):
|
|
57
|
+
err(what, f'duplicate names: {_names(dup)}')
|
|
58
|
+
|
|
59
|
+
groups = set(pst.pargp['PARGPNME'])
|
|
60
|
+
missing = [g for g in dict.fromkeys(par['PARGP']) if g not in groups]
|
|
61
|
+
if missing:
|
|
62
|
+
err('parameter groups', f'used but not defined: {_names(missing)}')
|
|
63
|
+
unused = [g for g in pst.pargp['PARGPNME'] if g not in set(par['PARGP'])]
|
|
64
|
+
if unused:
|
|
65
|
+
warn('parameter groups', f'defined but unused (dropped on write): {_names(unused)}')
|
|
66
|
+
|
|
67
|
+
trans = par.set_index('PARNME')['PARTRANS'] if not par['PARNME'].duplicated().any() else None
|
|
68
|
+
bad_trans = par.loc[~par['PARTRANS'].isin(('log', 'none', 'fixed', 'tied')), 'PARNME']
|
|
69
|
+
if len(bad_trans):
|
|
70
|
+
err('parameters', f'PARTRANS not log/none/fixed/tied: {_names(bad_trans)}')
|
|
71
|
+
tied = par[par['PARTRANS'] == 'tied']
|
|
72
|
+
if len(tied):
|
|
73
|
+
if 'TIETO' not in par:
|
|
74
|
+
err('parameters', f'{len(tied)} tied parameters but no TIETO column / tied table')
|
|
75
|
+
elif trans is not None:
|
|
76
|
+
target = tied['TIETO'].astype(str).str.strip().str.lower().map(trans)
|
|
77
|
+
gone = tied.loc[target.isna(), 'PARNME']
|
|
78
|
+
if len(gone):
|
|
79
|
+
err('parameters', f'tied to a parameter that does not exist: {_names(gone)}')
|
|
80
|
+
chain = tied.loc[(target == 'tied').values, 'PARNME']
|
|
81
|
+
if len(chain):
|
|
82
|
+
err('parameters', f'tied to a tied parameter: {_names(chain)}')
|
|
83
|
+
to_fix = tied.loc[(target == 'fixed').values, 'PARNME']
|
|
84
|
+
if len(to_fix):
|
|
85
|
+
warn('parameters', f'tied to a fixed parameter (become fixed on write): {_names(to_fix)}')
|
|
86
|
+
|
|
87
|
+
v, lb, ub = (pd.to_numeric(par[c], errors='coerce') for c in ('PARVAL1', 'PARLBND', 'PARUBND'))
|
|
88
|
+
nonnum = par.loc[v.isna() | lb.isna() | ub.isna(), 'PARNME']
|
|
89
|
+
if len(nonnum):
|
|
90
|
+
err('parameters', f'non-numeric PARVAL1 / PARLBND / PARUBND: {_names(nonnum)}')
|
|
91
|
+
adj = par['PARTRANS'].isin(ADJUSTABLE)
|
|
92
|
+
inverted = par.loc[(lb > ub).fillna(False), 'PARNME']
|
|
93
|
+
if len(inverted):
|
|
94
|
+
err('parameters', f'PARLBND above PARUBND: {_names(inverted)}')
|
|
95
|
+
outside = par.loc[(adj & ((v < lb) | (v > ub))).fillna(False), 'PARNME']
|
|
96
|
+
if len(outside):
|
|
97
|
+
err('parameters', f'adjustable PARVAL1 outside bounds: {_names(outside)}')
|
|
98
|
+
nonpos = par.loc[(adj & (par['PARTRANS'] == 'log') & (lb <= 0)).fillna(False), 'PARNME']
|
|
99
|
+
if len(nonpos):
|
|
100
|
+
err('parameters', f'log-transformed with a non-positive lower bound: {_names(nonpos)}')
|
|
101
|
+
|
|
102
|
+
w = pd.to_numeric(obs['WEIGHT'], errors='coerce')
|
|
103
|
+
ov = pd.to_numeric(obs['OBSVAL'], errors='coerce')
|
|
104
|
+
bad = obs.loc[w.isna() | ov.isna(), 'OBSNME']
|
|
105
|
+
if len(bad):
|
|
106
|
+
err('observations', f'non-numeric OBSVAL / WEIGHT: {_names(bad)}')
|
|
107
|
+
neg = obs.loc[(w < 0).fillna(False), 'OBSNME']
|
|
108
|
+
if len(neg):
|
|
109
|
+
err('observations', f'negative weight: {_names(neg)}')
|
|
110
|
+
if len(obs) and (w.fillna(0) <= 0).all():
|
|
111
|
+
warn('observations', 'every observation has zero weight')
|
|
112
|
+
|
|
113
|
+
if pst.nprior:
|
|
114
|
+
adjustable = set(par.loc[adj, 'PARNME'])
|
|
115
|
+
for r in pst.prior.itertuples():
|
|
116
|
+
eq = str(r.EQ)
|
|
117
|
+
refs = equation_params(eq)
|
|
118
|
+
if '=' not in eq or not refs:
|
|
119
|
+
err(f'prior {r.PINME}', f'malformed equation: {eq!r}')
|
|
120
|
+
continue
|
|
121
|
+
rhs = eq.split('=', 1)[1].strip()
|
|
122
|
+
if not is_number(rhs):
|
|
123
|
+
err(f'prior {r.PINME}', f'right-hand side is not a number: {rhs!r}')
|
|
124
|
+
gone = [p for p in refs if p not in set(par['PARNME'])]
|
|
125
|
+
if gone:
|
|
126
|
+
err(f'prior {r.PINME}', f'references unknown parameters: {_names(gone)}')
|
|
127
|
+
else:
|
|
128
|
+
notadj = [p for p in refs if p not in adjustable]
|
|
129
|
+
if notadj:
|
|
130
|
+
warn(f'prior {r.PINME}', f'references fixed/tied parameters (dropped on write): {_names(notadj)}')
|
|
131
|
+
logs = set(re.findall(r'log\((\w+)\)', eq.lower()))
|
|
132
|
+
wrong = [p for p in refs if (trans is not None) and ((trans.get(p) == 'log') != (p in logs))]
|
|
133
|
+
if wrong:
|
|
134
|
+
err(f'prior {r.PINME}', f'log() use does not match PARTRANS: {_names(wrong)}')
|
|
135
|
+
dup = pst.prior['PINME'][pst.prior['PINME'].duplicated()].unique()
|
|
136
|
+
if len(dup):
|
|
137
|
+
err('prior information', f'duplicate labels: {_names(dup)}')
|
|
138
|
+
|
|
139
|
+
for what, names, limit in (('parameter', par['PARNME'], LIMITS['parameter']),
|
|
140
|
+
('observation', obs['OBSNME'], LIMITS['observation']),
|
|
141
|
+
('group', pd.concat([pst.pargp['PARGPNME'], obs['OBGNME']]), LIMITS['group'])):
|
|
142
|
+
long = names[names.astype(str).str.len() > limit].unique()
|
|
143
|
+
if len(long):
|
|
144
|
+
warn(f'{what} names', f'longer than {limit} characters (PEST limit; PEST++ allows 200): {_names(long)}')
|
|
145
|
+
if 'DERCOM' in par:
|
|
146
|
+
ncom = max(len(pst.cmd), 1)
|
|
147
|
+
badcom = par.loc[(pd.to_numeric(par['DERCOM'], errors='coerce') > ncom).fillna(False), 'PARNME']
|
|
148
|
+
if len(badcom):
|
|
149
|
+
err('parameters', f'DERCOM exceeds the number of model command lines ({ncom}): {_names(badcom)}')
|
|
150
|
+
if not pst.tpl:
|
|
151
|
+
err('model input/output', 'no template files')
|
|
152
|
+
if not pst.ins:
|
|
153
|
+
err('model input/output', 'no instruction files')
|
|
154
|
+
if not pst.cmd:
|
|
155
|
+
err('model command line', 'no model command line')
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------- templates / instructions
|
|
160
|
+
def _first_line(path):
|
|
161
|
+
with open(path, errors='replace') as f:
|
|
162
|
+
return f.readline().rstrip('\r\n')
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def template_names(path):
|
|
166
|
+
"""Parameter names cited in a template file (lower-case), in order of appearance."""
|
|
167
|
+
with open(path, errors='replace') as f:
|
|
168
|
+
head = f.readline().split()
|
|
169
|
+
body = f.read()
|
|
170
|
+
if len(head) != 2 or head[0].lower() not in ('ptf', 'jtf'):
|
|
171
|
+
raise ValueError(f'first line must be "ptf <delimiter>", got {" ".join(head)!r}')
|
|
172
|
+
d = re.escape(head[1])
|
|
173
|
+
return [m.strip().lower() for m in re.findall(f'{d}(.*?){d}', body)]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_INS_NAME = re.compile(r'!([^!\s]+)!|\[([^\]\s]+)\]|\(([^)\s]+)\)')
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def instruction_names(path):
|
|
180
|
+
"""Observation names cited in an instruction file (lower-case, 'dum' excluded), in order."""
|
|
181
|
+
with open(path, errors='replace') as f:
|
|
182
|
+
head = f.readline().split()
|
|
183
|
+
body = f.read()
|
|
184
|
+
if len(head) != 2 or head[0].lower() not in ('pif', 'jif'):
|
|
185
|
+
raise ValueError(f'first line must be "pif <delimiter>", got {" ".join(head)!r}')
|
|
186
|
+
d = re.escape(head[1])
|
|
187
|
+
body = re.sub(f'{d}[^{d}]*{d}', ' ', body) # markers may contain ! [ ( characters
|
|
188
|
+
names = [next(g for g in m.groups() if g) for m in _INS_NAME.finditer(body)]
|
|
189
|
+
return [n.lower() for n in names if n.lower() != 'dum']
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_files(pst: Pst, base_dir='.', outputs=False):
|
|
193
|
+
"""Files the control file points at, and the names they cite."""
|
|
194
|
+
out = []
|
|
195
|
+
err = lambda where, msg: out.append(Finding('error', where, msg)) # noqa: E731
|
|
196
|
+
warn = lambda where, msg: out.append(Finding('warning', where, msg)) # noqa: E731
|
|
197
|
+
info = lambda where, msg: out.append(Finding('info', where, msg)) # noqa: E731
|
|
198
|
+
rel = lambda p: os.path.join(base_dir, p) # noqa: E731
|
|
199
|
+
|
|
200
|
+
par_names = set(pst.par['PARNME'])
|
|
201
|
+
cited = {}
|
|
202
|
+
for tpl, model_in in pst.tpl:
|
|
203
|
+
if not os.path.exists(rel(tpl)):
|
|
204
|
+
err(tpl, 'template file not found')
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
names = template_names(rel(tpl))
|
|
208
|
+
except ValueError as e:
|
|
209
|
+
err(tpl, str(e))
|
|
210
|
+
continue
|
|
211
|
+
if not names:
|
|
212
|
+
warn(tpl, 'cites no parameters')
|
|
213
|
+
unknown = sorted(set(names) - par_names)
|
|
214
|
+
if unknown:
|
|
215
|
+
err(tpl, f'cites parameters not in the control file: {_names(unknown)}')
|
|
216
|
+
for n in names:
|
|
217
|
+
cited.setdefault(n, set()).add(tpl)
|
|
218
|
+
folder = os.path.dirname(rel(model_in)) if model_in else ''
|
|
219
|
+
if folder and not os.path.isdir(folder):
|
|
220
|
+
warn(tpl, f'folder of model input file {model_in} does not exist')
|
|
221
|
+
if cited: # only meaningful once a template was read
|
|
222
|
+
missing = [n for n in pst.par['PARNME'] if n not in cited]
|
|
223
|
+
if missing:
|
|
224
|
+
err('templates', f'parameters cited in no template file: {_names(missing)}')
|
|
225
|
+
|
|
226
|
+
obs_names = set(pst.obs['OBSNME'])
|
|
227
|
+
seen = {}
|
|
228
|
+
for ins, model_out in pst.ins:
|
|
229
|
+
if not os.path.exists(rel(ins)):
|
|
230
|
+
err(ins, 'instruction file not found')
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
names = instruction_names(rel(ins))
|
|
234
|
+
except ValueError as e:
|
|
235
|
+
err(ins, str(e))
|
|
236
|
+
continue
|
|
237
|
+
if not names:
|
|
238
|
+
warn(ins, 'reads no observations')
|
|
239
|
+
unknown = sorted(set(names) - obs_names)
|
|
240
|
+
if unknown:
|
|
241
|
+
err(ins, f'reads observations not in the control file: {_names(unknown)}')
|
|
242
|
+
counts = pd.Series(names).value_counts()
|
|
243
|
+
dup_here = sorted(counts[counts > 1].index)
|
|
244
|
+
if dup_here:
|
|
245
|
+
err(ins, f'observation read more than once: {_names(dup_here)}')
|
|
246
|
+
for n in set(names):
|
|
247
|
+
seen.setdefault(n, []).append(ins)
|
|
248
|
+
if outputs:
|
|
249
|
+
if not os.path.exists(rel(model_out)):
|
|
250
|
+
info(ins, f'model output {model_out} not present; instructions not run')
|
|
251
|
+
else:
|
|
252
|
+
try:
|
|
253
|
+
values = run_instructions(rel(ins), rel(model_out))
|
|
254
|
+
info(ins, f'read {len(values)} of {len(set(names))} observations from {model_out}')
|
|
255
|
+
except InstructionError as e:
|
|
256
|
+
warn(ins, f'reading {model_out} failed: {e}')
|
|
257
|
+
if seen:
|
|
258
|
+
dup_across = sorted(n for n, files in seen.items() if len(files) > 1)
|
|
259
|
+
if dup_across:
|
|
260
|
+
err('instructions', f'observation read by more than one instruction file: {_names(dup_across)}')
|
|
261
|
+
missing = [n for n in pst.obs['OBSNME'] if n not in seen]
|
|
262
|
+
if missing:
|
|
263
|
+
err('instructions', f'observations read by no instruction file: {_names(missing)}')
|
|
264
|
+
|
|
265
|
+
for cmd in pst.cmd:
|
|
266
|
+
# only judge tokens that are clearly file paths; 'python model.py' is checked on model.py
|
|
267
|
+
for tok in cmd.split()[:2]:
|
|
268
|
+
looks_like_file = ('/' in tok or '\\' in tok or tok.lower().endswith(('.bat', '.exe', '.py', '.sh')))
|
|
269
|
+
if looks_like_file and not os.path.exists(rel(tok)):
|
|
270
|
+
warn('model command line', f'{tok} not found relative to the control file')
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def check_sections(pst_path):
|
|
275
|
+
"""Sections in a control file that makepst does not understand (dropped on read)."""
|
|
276
|
+
out = []
|
|
277
|
+
with open(pst_path, errors='replace') as f:
|
|
278
|
+
for line in f:
|
|
279
|
+
s = line.strip()
|
|
280
|
+
if s.startswith('*'):
|
|
281
|
+
name = s[1:].strip().lower()
|
|
282
|
+
base = name[:-len(' external')] if name.endswith(' external') else name
|
|
283
|
+
if base not in KNOWN_SECTIONS:
|
|
284
|
+
out.append(Finding('warning', 'sections', f'unknown section dropped on read: * {name}'))
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------- instruction interpreter
|
|
289
|
+
class InstructionError(Exception):
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def run_instructions(ins_path, out_path):
|
|
294
|
+
"""Read a model output file with an instruction file; returns {observation: value}.
|
|
295
|
+
|
|
296
|
+
Follows the PEST manual: primary/secondary markers, l, w, t, !name!, [name]c1:c2,
|
|
297
|
+
(name)c1:c2, dum, & continuation. Raises InstructionError where PEST would stop.
|
|
298
|
+
"""
|
|
299
|
+
with open(ins_path, errors='replace') as f:
|
|
300
|
+
head = f.readline().split()
|
|
301
|
+
raw = f.read().splitlines()
|
|
302
|
+
if len(head) != 2:
|
|
303
|
+
raise InstructionError('bad pif line')
|
|
304
|
+
d = head[1]
|
|
305
|
+
lines = []
|
|
306
|
+
for ln in raw:
|
|
307
|
+
if ln.strip().startswith('&') and lines:
|
|
308
|
+
lines[-1] += ' ' + ln.strip()[1:]
|
|
309
|
+
elif ln.strip():
|
|
310
|
+
lines.append(ln.strip())
|
|
311
|
+
with open(out_path, errors='replace') as f:
|
|
312
|
+
output = f.read().splitlines()
|
|
313
|
+
|
|
314
|
+
tok_re = re.compile(re.escape(d) + '[^' + re.escape(d) + ']*' + re.escape(d) + r'|\S+')
|
|
315
|
+
values = {}
|
|
316
|
+
row, col = -1, 0 # row: index into output (-1 = before the first line)
|
|
317
|
+
|
|
318
|
+
def cur():
|
|
319
|
+
if row < 0 or row >= len(output):
|
|
320
|
+
raise InstructionError(f'instruction line {li + 1}: beyond end of {os.path.basename(out_path)}')
|
|
321
|
+
return output[row]
|
|
322
|
+
|
|
323
|
+
for li, ln in enumerate(lines):
|
|
324
|
+
toks = tok_re.findall(ln)
|
|
325
|
+
for ti, t in enumerate(toks):
|
|
326
|
+
low = t.lower()
|
|
327
|
+
if t.startswith(d) and t.endswith(d):
|
|
328
|
+
marker = t[1:-1]
|
|
329
|
+
if ti == 0: # primary marker: search forward
|
|
330
|
+
r = row + 1
|
|
331
|
+
while r < len(output) and marker not in output[r]:
|
|
332
|
+
r += 1
|
|
333
|
+
if r >= len(output):
|
|
334
|
+
raise InstructionError(f'instruction line {li + 1}: primary marker {marker!r} not found')
|
|
335
|
+
row, col = r, output[r].index(marker) + len(marker)
|
|
336
|
+
else: # secondary marker: search in the current line
|
|
337
|
+
line = cur()
|
|
338
|
+
i = line.find(marker, col)
|
|
339
|
+
if i < 0:
|
|
340
|
+
raise InstructionError(f'instruction line {li + 1}: secondary marker {marker!r} not found')
|
|
341
|
+
col = i + len(marker)
|
|
342
|
+
elif low[0] == 'l' and low[1:].isdigit():
|
|
343
|
+
row += int(low[1:])
|
|
344
|
+
col = 0
|
|
345
|
+
cur()
|
|
346
|
+
elif low == 'w':
|
|
347
|
+
line = cur()
|
|
348
|
+
i = col
|
|
349
|
+
while i < len(line) and not line[i].isspace():
|
|
350
|
+
i += 1
|
|
351
|
+
if i >= len(line):
|
|
352
|
+
raise InstructionError(f'instruction line {li + 1}: no whitespace after column {col + 1}')
|
|
353
|
+
while i < len(line) and line[i].isspace():
|
|
354
|
+
i += 1
|
|
355
|
+
col = i
|
|
356
|
+
elif low[0] == 't' and low[1:].isdigit():
|
|
357
|
+
col = int(low[1:]) - 1
|
|
358
|
+
elif t.startswith('!') and t.endswith('!'):
|
|
359
|
+
line = cur()
|
|
360
|
+
m = re.compile(r'\S+').search(line, col)
|
|
361
|
+
if not m:
|
|
362
|
+
raise InstructionError(f'instruction line {li + 1}: nothing to read for {t}')
|
|
363
|
+
_store(values, t[1:-1], m.group(), li)
|
|
364
|
+
col = m.end()
|
|
365
|
+
elif t.startswith('[') or t.startswith('('):
|
|
366
|
+
m = re.match(r'[\[(]([^\])]+)[\])](\d+):(\d+)$', t)
|
|
367
|
+
if not m:
|
|
368
|
+
raise InstructionError(f'instruction line {li + 1}: bad instruction {t!r}')
|
|
369
|
+
name, c1, c2 = m.group(1), int(m.group(2)) - 1, int(m.group(3))
|
|
370
|
+
line = cur()
|
|
371
|
+
if t.startswith('['):
|
|
372
|
+
_store(values, name, line[c1:c2], li)
|
|
373
|
+
col = c2
|
|
374
|
+
else:
|
|
375
|
+
seg = re.compile(r'\S+')
|
|
376
|
+
mm = seg.search(line, c1)
|
|
377
|
+
if not mm or mm.start() >= c2:
|
|
378
|
+
raise InstructionError(f'instruction line {li + 1}: nothing in columns {c1 + 1}-{c2} for {name}')
|
|
379
|
+
start = mm.start()
|
|
380
|
+
while start > 0 and not line[start - 1].isspace(): # token may begin before c1
|
|
381
|
+
start -= 1
|
|
382
|
+
end = seg.search(line, start).end()
|
|
383
|
+
_store(values, name, line[start:end], li)
|
|
384
|
+
col = end
|
|
385
|
+
else:
|
|
386
|
+
raise InstructionError(f'instruction line {li + 1}: unknown instruction {t!r}')
|
|
387
|
+
return values
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _store(values, name, text, li):
|
|
391
|
+
name = name.lower()
|
|
392
|
+
if name == 'dum':
|
|
393
|
+
return
|
|
394
|
+
try:
|
|
395
|
+
values[name] = float(text)
|
|
396
|
+
except ValueError:
|
|
397
|
+
raise InstructionError(f'instruction line {li + 1}: {name} read {text.strip()!r}, not a number') from None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ---------------------------------------------------------------------- driver
|
|
401
|
+
def validate(pst: Pst, base_dir='.', pst_path=None, outputs=False):
|
|
402
|
+
findings = check_tables(pst)
|
|
403
|
+
if pst_path:
|
|
404
|
+
findings += check_sections(pst_path)
|
|
405
|
+
findings += check_files(pst, base_dir, outputs)
|
|
406
|
+
return findings
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def summary(findings):
|
|
410
|
+
n = {s: sum(1 for f in findings if f.severity == s) for s in ('error', 'warning', 'info')}
|
|
411
|
+
return n, f"{n['error']} errors, {n['warning']} warnings, {n['info']} notes"
|