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/cli.py ADDED
@@ -0,0 +1,351 @@
1
+ """Command line: build (Excel/CSV -> pst), dump (pst -> Excel), update (values -> existing workbook),
2
+ parrep (.par values -> pst), init (starter workbook), validate (pestchek-style checks), diff (compare two)."""
3
+ import argparse
4
+ import os
5
+ import shlex
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .excel import expand_spec, load_table, to_workbook, update_workbook
10
+ from .provenance import Manifest
11
+ from .pst import Pst, read_par
12
+ from .reader import read_pst
13
+ from .writer import write_pst
14
+
15
+ TABLES = ('pargp', 'par', 'tied', 'prior', 'obs', 'obsgp', 'io', 'pp', 'comment')
16
+ WORKBOOK_EXT = ('.xlsx', '.xlsm', '.xls')
17
+
18
+
19
+ def _spec_parts(spec):
20
+ """'book.xlsx,SHEET' -> (book.xlsx, SHEET); 'file.csv' -> (file.csv, None)."""
21
+ if ',' in spec:
22
+ path, sheet = spec.rsplit(',', 1)
23
+ return path, sheet
24
+ return spec, None
25
+
26
+
27
+ def _build_parser(p):
28
+ p.add_argument('pstfile', help='control file to write, or a workbook whose BUILD sheet holds the command')
29
+ p.add_argument('mode', nargs='?', default=None,
30
+ help='estimation | regularisation | prediction | pareto (default: CONTROL sheet, else estimation)')
31
+ p.add_argument('--set_ctl_xls', metavar='BOOK,SHEET', help='CONTROL sheet: NAME / VALUE columns')
32
+ p.add_argument('--set_ctl_csv', metavar='CSV')
33
+ for t in TABLES:
34
+ p.add_argument(f'--add_{t}_xls', action='append', default=[], metavar='BOOK,SHEET',
35
+ help='sheet name may be a glob, e.g. book.xlsm,PAR_*' if t == 'par' else argparse.SUPPRESS)
36
+ p.add_argument(f'--add_{t}_csv', action='append', default=[], metavar='CSV')
37
+ p.add_argument('--add_comment', action='append', default=[], metavar='TEXT')
38
+ p.add_argument('--fill_parval', metavar='FILE',
39
+ help='take PARVAL1 from a PEST .par file or a PESTPP-IES case.N.par.csv')
40
+ p.add_argument('--real', metavar='NAME', help='realization for --fill_parval from an ensemble (default base)')
41
+ p.add_argument('--ss', action='store_true', help='steady state: drop ss*/sy* parameters and groups')
42
+ p.add_argument('--no_dump_tpl', action='store_true', help="don't write dump.tpl next to the pst")
43
+ p.add_argument('--no_manifest', action='store_true', help="don't write <pst>.manifest.json")
44
+ p.add_argument('--out', metavar='PST', help='with a workbook: where to write (default: the name in the BUILD sheet)')
45
+ fmt_ = p.add_mutually_exclusive_group()
46
+ fmt_.add_argument('--v2', action='store_true', help='write a PEST++ version-2 file (external csv tables beside it)')
47
+ fmt_.add_argument('--v1', action='store_true', help='write the classic format (default for tables)')
48
+
49
+
50
+ def build_pst(args, manifest=None):
51
+ """The Pst described by parsed `build` arguments (nothing written)."""
52
+ def table(spec, role):
53
+ if manifest is not None:
54
+ path, sheet = _spec_parts(spec)
55
+ manifest.add_source(path, sheet, role)
56
+ return load_table(spec)
57
+
58
+ control = None
59
+ if args.set_ctl_xls:
60
+ control = table(args.set_ctl_xls, 'control')
61
+ elif args.set_ctl_csv:
62
+ control = table(args.set_ctl_csv, 'control')
63
+
64
+ pst = Pst(ss=args.ss)
65
+ if control is not None:
66
+ pst.set_control(control)
67
+ if args.mode:
68
+ pst.pestmode = Pst(args.mode).pestmode
69
+ print(f'Building a {pst.pestmode} control file...')
70
+
71
+ for c in args.add_comment:
72
+ pst.add_comment(comment=c)
73
+ for t in TABLES:
74
+ for pattern in getattr(args, f'add_{t}_xls') + getattr(args, f'add_{t}_csv'):
75
+ for spec in expand_spec(pattern): # book,PAR_* -> every matching sheet
76
+ print(f'Adding {t:8s} from {spec}')
77
+ getattr(pst, f'add_{t}')(table(spec, t))
78
+ if args.fill_parval:
79
+ if manifest is not None:
80
+ manifest.add_source(args.fill_parval, role='parval')
81
+ pst.fill_parval(args.fill_parval, args.real)
82
+ return pst
83
+
84
+
85
+ def _version(args):
86
+ return 2 if getattr(args, 'v2', False) else 1 if getattr(args, 'v1', False) else None
87
+
88
+
89
+ def _finish_pst(pst, path, manifest, dump_tpl, version=None):
90
+ write_pst(pst, path, dump_tpl=dump_tpl, version=version)
91
+ if manifest is not None:
92
+ manifest.set_output(path, **pst.counts)
93
+ print(f'manifest written to {manifest.write()}')
94
+
95
+
96
+ def build(args):
97
+ manifest = None if args.no_manifest else Manifest('build', args._argv)
98
+ if args.pstfile.lower().endswith(WORKBOOK_EXT):
99
+ # `makepst build book.xlsx [--out x.pst]`: run the command in the workbook's BUILD sheet
100
+ pst, ns = build_from_workbook(args.pstfile, manifest)
101
+ out = args.out or os.path.join(os.path.dirname(os.path.abspath(args.pstfile)), ns.pstfile)
102
+ _finish_pst(pst, out, manifest, dump_tpl=not (args.no_dump_tpl or ns.no_dump_tpl),
103
+ version=_version(args) or _version(ns))
104
+ return
105
+ pst = build_pst(args, manifest)
106
+ _finish_pst(pst, args.pstfile, manifest, dump_tpl=not args.no_dump_tpl, version=_version(args))
107
+
108
+
109
+ def build_from_workbook(book, manifest=None):
110
+ """The Pst a workbook describes in its BUILD sheet (the command `dump` records there),
111
+ with the parsed build arguments; returns (pst, args)."""
112
+ import pandas as pd
113
+ try:
114
+ rows = pd.read_excel(book, 'BUILD', engine='openpyxl').iloc[:, 0].dropna()
115
+ except ValueError:
116
+ raise SystemExit(f'{book} has no BUILD sheet; add one holding the build command '
117
+ f'(as `makepst dump` does), or use `makepst build ... --fill_parval`') from None
118
+ argv = [a.strip('"') for a in shlex.split(' '.join(str(r) for r in rows), posix=False)]
119
+ while argv and (argv[0].lower() in ('python', 'makepst', 'build') or argv[0].lower().endswith('makepst.py')):
120
+ argv.pop(0)
121
+ argv = _resolve_paths(argv, book)
122
+ parser = argparse.ArgumentParser(prog='BUILD sheet')
123
+ _build_parser(parser)
124
+ ns = parser.parse_args(argv)
125
+ if manifest is not None:
126
+ manifest.add_source(book, 'BUILD', 'build command')
127
+ manifest.add(build_args=argv) # resolved paths, as executed
128
+ return build_pst(ns, manifest), ns
129
+
130
+
131
+ def _resolve_paths(argv, book):
132
+ """Make the file names in a BUILD command usable from anywhere: the workbook's own name means
133
+ this workbook, other relative names are taken relative to the workbook's folder."""
134
+ base = os.path.dirname(os.path.abspath(book))
135
+ out = []
136
+ for a in argv:
137
+ path, sep, sheet = a.rpartition(',')
138
+ if not sep:
139
+ path, sheet = a, ''
140
+ if os.path.basename(path).lower() == os.path.basename(book).lower():
141
+ path = book
142
+ elif not os.path.isabs(path) and os.path.exists(os.path.join(base, path)) and not os.path.exists(path):
143
+ path = os.path.join(base, path)
144
+ out.append(path + sep + sheet)
145
+ return out
146
+
147
+
148
+ def dump(args):
149
+ pst = read_pst(args.pstfile)
150
+ cmd = to_workbook(pst, args.workbook, split=args.split)
151
+ print('rebuild with:\n ' + cmd)
152
+ if not args.no_manifest:
153
+ m = Manifest('dump', args._argv)
154
+ m.add_source(args.pstfile, role='control file')
155
+ m.set_output(args.workbook, **pst.counts)
156
+ m.add(build_command=cmd)
157
+ print(f'manifest written to {m.write()}')
158
+
159
+
160
+ def init(args):
161
+ from .starter import write_starter
162
+ if os.path.exists(args.workbook) and not args.force:
163
+ raise SystemExit(f'{args.workbook} exists; use --force to overwrite')
164
+ cmd = write_starter(args.workbook, args.name)
165
+ print(f'starter workbook written to {args.workbook}')
166
+ print(f'build it with:\n {cmd}')
167
+
168
+
169
+ def parrep(args):
170
+ """PARREP: parameter values from a .par file (or IES ensemble) into a control file.
171
+
172
+ The base is a control file, or a workbook with a BUILD sheet (built first, then filled).
173
+ """
174
+ manifest = None if args.no_manifest else Manifest('parrep', args._argv)
175
+ if args.pstfile.lower().endswith(WORKBOOK_EXT):
176
+ pst, _ = build_from_workbook(args.pstfile, manifest)
177
+ else:
178
+ pst = read_pst(args.pstfile)
179
+ if manifest is not None:
180
+ manifest.add_source(args.pstfile, role='base control file')
181
+ if manifest is not None:
182
+ manifest.add_source(args.parfile, role='parameter values')
183
+ manifest.add(realization=args.real, set=args.set)
184
+ pst.fill_parval(args.parfile, args.real)
185
+ for kv in args.set:
186
+ k, _, v = kv.partition('=')
187
+ if not _:
188
+ raise SystemExit(f'--set expects NAME=VALUE, got {kv!r}')
189
+ pst.set_control({k: v})
190
+ _finish_pst(pst, args.out, manifest, dump_tpl=False, version=_version(args))
191
+
192
+
193
+ def validate(args):
194
+ """pestchek-style report for a control file or a workbook (built via its BUILD sheet)."""
195
+ from .checks import summary, validate as run_checks
196
+ target = args.target
197
+ if target.lower().endswith(WORKBOOK_EXT):
198
+ pst, _ = build_from_workbook(target)
199
+ pst_path = None
200
+ else:
201
+ pst = read_pst(target)
202
+ pst_path = target
203
+ base = args.base_dir or os.path.dirname(os.path.abspath(target))
204
+ findings = run_checks(pst, base_dir=base, pst_path=pst_path, outputs=args.outputs)
205
+ order = {'error': 0, 'warning': 1, 'info': 2}
206
+ for f in sorted(findings, key=lambda f: order[f.severity]):
207
+ if f.severity == 'info' and args.quiet:
208
+ continue
209
+ print(f)
210
+ n, text = summary(findings)
211
+ print(f'{target}: {text}')
212
+ if n['error'] or (args.strict and n['warning']):
213
+ raise SystemExit(1)
214
+
215
+
216
+ def _load_target(path):
217
+ """A control file or a workbook (built via its BUILD sheet) -> Pst."""
218
+ if path.lower().endswith(WORKBOOK_EXT):
219
+ return build_from_workbook(path)[0]
220
+ return read_pst(path)
221
+
222
+
223
+ def diff(args):
224
+ """Semantic comparison of two control files / workbooks; exit 1 when they differ."""
225
+ from .diff import compare
226
+ old, new = _load_target(args.old), _load_target(args.new)
227
+ old.validate()
228
+ new.validate()
229
+ d = compare(old, new, rtol=args.rtol)
230
+ print(d.to_text(max_rows=args.max_rows))
231
+ print(f'{args.old} -> {args.new}: {d.summary()}')
232
+ if args.xlsx:
233
+ d.to_workbook(args.xlsx)
234
+ print(f'written to {args.xlsx}')
235
+ if not d.empty:
236
+ raise SystemExit(1)
237
+
238
+
239
+ def update(args):
240
+ pst = read_pst(args.pst) if args.pst else None
241
+ par = args.par or pst
242
+ if args.par and pst is not None:
243
+ # .par values with the groups from the pst, so --group can apply to them
244
+ par = (read_par(args.par, args.real).reset_index()
245
+ .merge(pst.par[['PARNME', 'PARGP']], on='PARNME', how='left'))
246
+
247
+ def flat(items): # repeated flags, each possibly a comma list
248
+ return [x.strip() for s in items for x in s.split(',') if x.strip()] or None
249
+
250
+ manifest = None if args.no_manifest else Manifest('update', args._argv)
251
+ if manifest is not None:
252
+ manifest.add_source(args.workbook, role='workbook')
253
+ for path, role in ((args.par, 'parameter values'), (args.pst, 'control file'),
254
+ (args.res, 'residuals'), (args.obs_csv, 'observation ensemble')):
255
+ if path:
256
+ manifest.add_source(path, role=role)
257
+ result = update_workbook(args.workbook, par=par, obs=pst, res=args.res, obs_csv=args.obs_csv,
258
+ real=args.real, pst=pst, out=args.out, backend=args.backend,
259
+ par_cols=args.par_cols.upper().split(','), obs_cols=args.obs_cols.upper().split(','),
260
+ overwrite_formulas=args.overwrite_formulas,
261
+ sheets=flat(args.sheet), groups=flat(args.group), phi=not args.no_phi)
262
+ if manifest is not None:
263
+ manifest.set_output(args.out or args.workbook, sheets=result['sheets'], backend=result['backend'])
264
+ manifest.add(realization=args.real, par_cols=args.par_cols, obs_cols=args.obs_cols,
265
+ sheet=flat(args.sheet), group=flat(args.group), overwrite_formulas=args.overwrite_formulas)
266
+ print(f'manifest written to {manifest.write()}')
267
+
268
+
269
+ def main(argv=None):
270
+ argv = list(sys.argv[1:] if argv is None else argv)
271
+ # legacy form: makePst.py out.pst mode --add_par_xls ... (no subcommand)
272
+ if argv and argv[0] not in ('build', 'dump', 'update', 'parrep', 'init', 'validate', 'diff', '-h', '--help', '-v', '--version'):
273
+ argv.insert(0, 'build')
274
+
275
+ ap = argparse.ArgumentParser(prog='makepst', description=__doc__)
276
+ ap.add_argument('-v', '--version', action='version', version=f'%(prog)s {__version__}')
277
+ sub = ap.add_subparsers(dest='cmd', required=True)
278
+
279
+ _build_parser(sub.add_parser('build', help='Excel/CSV tables -> PEST control file'))
280
+
281
+ d = sub.add_parser('dump', help='PEST control file -> new workbook')
282
+ d.add_argument('pstfile')
283
+ d.add_argument('workbook', help='.xlsx to create')
284
+ d.add_argument('--split', action='store_true', help='one PAR_<group> / OBS_<group> sheet per group')
285
+ d.add_argument('--no_manifest', action='store_true', help="don't write <workbook>.manifest.json")
286
+
287
+ i = sub.add_parser('init', help='starter workbook with headers, defaults, drop-downs and a BUILD sheet')
288
+ i.add_argument('workbook', help='.xlsx to create')
289
+ i.add_argument('--name', help='case name for the control file in the BUILD command (default: workbook name)')
290
+ i.add_argument('--force', action='store_true', help='overwrite an existing file')
291
+
292
+ r = sub.add_parser('parrep', help='new control file with parameter values from a .par file (like PARREP)')
293
+ r.add_argument('pstfile', help='control file to read, or a workbook with a BUILD sheet to build it from')
294
+ r.add_argument('parfile', help='PEST .par file or PESTPP-IES case.N.par.csv')
295
+ r.add_argument('out', help='control file to write')
296
+ r.add_argument('--real', metavar='NAME', help='realization for an ensemble (base, 17, best; default base)')
297
+ r.add_argument('--set', action='append', default=[], metavar='NAME=VALUE',
298
+ help='also change a control value, e.g. --set noptmax=0 (repeatable)')
299
+ r.add_argument('--no_manifest', action='store_true', help="don't write <out>.manifest.json")
300
+ rf = r.add_mutually_exclusive_group()
301
+ rf.add_argument('--v2', action='store_true', help='write a PEST++ version-2 file')
302
+ rf.add_argument('--v1', action='store_true', help='write the classic format (default: same as the input)')
303
+
304
+ v = sub.add_parser('validate', help='pestchek-style checks on a control file or a workbook')
305
+ v.add_argument('target', help='control file, or a workbook with a BUILD sheet')
306
+ v.add_argument('--outputs', action='store_true',
307
+ help='also run each instruction file against its model output file when that exists')
308
+ v.add_argument('--base_dir', metavar='DIR', help='folder template/instruction paths are relative to '
309
+ '(default: the folder of the target)')
310
+ v.add_argument('--strict', action='store_true', help='exit 1 on warnings as well as errors')
311
+ v.add_argument('--quiet', action='store_true', help='hide informational lines')
312
+
313
+ f = sub.add_parser('diff', help='what changed between two control files (or workbooks)')
314
+ f.add_argument('old', help='control file or workbook')
315
+ f.add_argument('new', help='control file or workbook')
316
+ f.add_argument('--xlsx', metavar='FILE', help='also write the differences to a workbook, one sheet per table')
317
+ f.add_argument('--rtol', type=float, default=1e-9, help='relative tolerance for numbers (default 1e-9)')
318
+ f.add_argument('--max_rows', type=int, default=50, help='rows printed per table (default 50)')
319
+
320
+ u = sub.add_parser('update', help='write PEST results back into an existing workbook')
321
+ u.add_argument('workbook', help='.xlsm/.xlsx to update (sheets matched by PARNME / OBSNME columns)')
322
+ u.add_argument('--par', metavar='FILE', help='PEST .par file or PESTPP-IES case.N.par.csv -> PARVAL1')
323
+ u.add_argument('--pst', metavar='PSTFILE', help='control file -> parameter and observation columns')
324
+ u.add_argument('--res', metavar='RESFILE', help='PEST .res/.rei -> MODELLED / RESIDUAL columns')
325
+ u.add_argument('--obs_csv', metavar='FILE',
326
+ help='PESTPP-IES case.N.obs.csv -> MODELLED (and RESIDUAL when --pst is given)')
327
+ u.add_argument('--real', metavar='NAME',
328
+ help='realization to take from IES ensembles: a name such as base or 17, '
329
+ 'or best (lowest phi in case.phi.actual.csv); default base')
330
+ u.add_argument('--par_cols', default='PARVAL1', help='comma list of parameter columns to write')
331
+ u.add_argument('--obs_cols', default='OBSVAL,WEIGHT', help='comma list of observation columns to write')
332
+ u.add_argument('--sheet', action='append', default=[], metavar='GLOB',
333
+ help='only worksheets matching this name pattern, e.g. PAR_HK or "OBS_*" (repeatable, or a comma list)')
334
+ u.add_argument('--group', action='append', default=[], metavar='NAME',
335
+ help='only parameters/observations in this group (repeatable, or a comma list; '
336
+ 'with --par alone, also give --pst)')
337
+ u.add_argument('--overwrite_formulas', action='store_true',
338
+ help='also write into cells that currently hold formulas (default: leave them alone)')
339
+ u.add_argument('--out', help='save to this file instead of in place')
340
+ u.add_argument('--backend', choices=['xlwings', 'openpyxl'], help='default: xlwings if installed')
341
+ u.add_argument('--no_manifest', action='store_true', help="don't write <workbook>.manifest.json")
342
+ u.add_argument('--no_phi', action='store_true', help="don't write the PHI / PHI_IES sheets with --res / --obs_csv")
343
+
344
+ args = ap.parse_args(argv)
345
+ args._argv = argv # the command line as given, for the manifest
346
+ {'build': build, 'dump': dump, 'update': update, 'parrep': parrep, 'init': init,
347
+ 'validate': validate, 'diff': diff}[args.cmd](args)
348
+
349
+
350
+ if __name__ == '__main__':
351
+ main()
makepst/diff.py ADDED
@@ -0,0 +1,151 @@
1
+ """`makepst diff`: what changed between two control files (or a control file and a workbook).
2
+
3
+ Compares the tables, not the text: parameters and observations added / removed / changed
4
+ column by column, prior equations, control values, `++` options, template / instruction
5
+ pairs and command lines. Numbers are compared with a relative tolerance so a reformatted
6
+ value is not a change.
7
+ """
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from .pst import OBS_COLS, PAR_COLS, PRIOR_COLS, Pst
12
+ from .sections import fmt
13
+ from .writer import effective_control
14
+
15
+ PAR_COMPARE = [c for c in PAR_COLS if c != 'PARNME'] + ['TIETO']
16
+
17
+
18
+ class Diff:
19
+ """Result of compare(): DataFrames per table plus dicts for the scalar parts."""
20
+
21
+ def __init__(self):
22
+ self.par = pd.DataFrame(columns=['PARNME', 'change', 'column', 'old', 'new'])
23
+ self.obs = pd.DataFrame(columns=['OBSNME', 'change', 'column', 'old', 'new'])
24
+ self.prior = pd.DataFrame(columns=['PINME', 'change', 'column', 'old', 'new'])
25
+ self.pargp = pd.DataFrame(columns=['PARGPNME', 'change', 'column', 'old', 'new'])
26
+ self.control = pd.DataFrame(columns=['NAME', 'change', 'old', 'new'])
27
+ self.pestpp = pd.DataFrame(columns=['NAME', 'change', 'old', 'new'])
28
+ self.io = pd.DataFrame(columns=['kind', 'change', 'pest file', 'model file'])
29
+ self.cmd = pd.DataFrame(columns=['change', 'command'])
30
+ self.comments = pd.DataFrame(columns=['change', 'comment'])
31
+
32
+ @property
33
+ def empty(self):
34
+ return all(len(getattr(self, t)) == 0 for t in self.tables)
35
+
36
+ tables = ('par', 'obs', 'prior', 'pargp', 'control', 'pestpp', 'io', 'cmd', 'comments')
37
+
38
+ def summary(self):
39
+ parts = []
40
+ for t in self.tables:
41
+ df = getattr(self, t)
42
+ if len(df):
43
+ kinds = df['change'].value_counts().to_dict()
44
+ parts.append(f"{t}: " + ', '.join(f'{n} {k}' for k, n in kinds.items()))
45
+ return '; '.join(parts) if parts else 'no differences'
46
+
47
+ def to_text(self, max_rows=50):
48
+ out = []
49
+ for t in self.tables:
50
+ df = getattr(self, t)
51
+ if not len(df):
52
+ continue
53
+ out.append(f'== {t} ({len(df)})')
54
+ show = df.head(max_rows).astype(str)
55
+ widths = [max(len(c), show[c].str.len().max()) for c in show.columns]
56
+ out.append(' '.join(f'{c:<{w}}' for c, w in zip(show.columns, widths)))
57
+ for row in show.itertuples(index=False):
58
+ out.append(' '.join(f'{v:<{w}}' for v, w in zip(row, widths)))
59
+ if len(df) > max_rows:
60
+ out.append(f'... {len(df) - max_rows} more')
61
+ return '\n'.join(out) if out else 'no differences'
62
+
63
+ def to_workbook(self, path):
64
+ with pd.ExcelWriter(path, engine='openpyxl') as xw:
65
+ summary = pd.DataFrame({'table': list(self.tables),
66
+ 'changes': [len(getattr(self, t)) for t in self.tables]})
67
+ summary.to_excel(xw, sheet_name='SUMMARY', index=False)
68
+ for t in self.tables:
69
+ df = getattr(self, t)
70
+ if len(df):
71
+ df.to_excel(xw, sheet_name=t.upper(), index=False)
72
+
73
+
74
+ def _same(a, b, rtol):
75
+ if a is None and b is None:
76
+ return True
77
+ if isinstance(a, float) and np.isnan(a) and isinstance(b, float) and np.isnan(b):
78
+ return True
79
+ try:
80
+ fa, fb = float(a), float(b)
81
+ if np.isnan(fa) and np.isnan(fb):
82
+ return True
83
+ return np.isclose(fa, fb, rtol=rtol, atol=0)
84
+ except (TypeError, ValueError):
85
+ return fmt(a) == fmt(b)
86
+
87
+
88
+ def _table_diff(old, new, key, columns, rtol):
89
+ """Rows added / removed / changed between two DataFrames keyed on `key`."""
90
+ o = old.set_index(key)
91
+ n = new.set_index(key)
92
+ rows = []
93
+ for k in n.index.difference(o.index):
94
+ rows.append((k, 'added', '', '', ''))
95
+ for k in o.index.difference(n.index):
96
+ rows.append((k, 'removed', '', '', ''))
97
+ common = o.index.intersection(n.index)
98
+ for c in columns:
99
+ if c not in o and c not in n:
100
+ continue
101
+ a = o[c].reindex(common) if c in o else pd.Series(None, index=common, dtype=object)
102
+ b = n[c].reindex(common) if c in n else pd.Series(None, index=common, dtype=object)
103
+ for k, va, vb in zip(common, a, b):
104
+ if not _same(va, vb, rtol):
105
+ rows.append((k, 'changed', c, fmt(va), fmt(vb)))
106
+ return pd.DataFrame(rows, columns=[key, 'change', 'column', 'old', 'new'])
107
+
108
+
109
+ def _scalar_diff(old, new, rtol, key='NAME'):
110
+ rows = []
111
+ for k in sorted(set(old) | set(new)):
112
+ if k not in old:
113
+ rows.append((k, 'added', '', fmt(new[k])))
114
+ elif k not in new:
115
+ rows.append((k, 'removed', fmt(old[k]), ''))
116
+ elif not _same(old[k], new[k], rtol):
117
+ rows.append((k, 'changed', fmt(old[k]), fmt(new[k])))
118
+ return pd.DataFrame(rows, columns=[key, 'change', 'old', 'new'])
119
+
120
+
121
+ def _list_diff(old, new, col):
122
+ rows = [('added', x) for x in new if x not in old] + [('removed', x) for x in old if x not in new]
123
+ return pd.DataFrame(rows, columns=['change', col])
124
+
125
+
126
+ def _tie_only_when_tied(par):
127
+ """TIETO only matters for tied parameters; workbooks often fill it for every row."""
128
+ if 'TIETO' not in par:
129
+ return par
130
+ par = par.copy()
131
+ par.loc[par['PARTRANS'] != 'tied', 'TIETO'] = None
132
+ return par
133
+
134
+
135
+ def compare(old: Pst, new: Pst, rtol=1e-9):
136
+ """Differences from `old` to `new`, as a Diff."""
137
+ d = Diff()
138
+ d.par = _table_diff(_tie_only_when_tied(old.par), _tie_only_when_tied(new.par), 'PARNME', PAR_COMPARE, rtol)
139
+ d.obs = _table_diff(old.obs, new.obs, 'OBSNME', [c for c in OBS_COLS if c != 'OBSNME'], rtol)
140
+ d.prior = _table_diff(old.prior, new.prior, 'PINME', [c for c in PRIOR_COLS if c != 'PINME'], rtol)
141
+ d.pargp = _table_diff(old.pargp, new.pargp, 'PARGPNME',
142
+ [c for c in old.pargp.columns.union(new.pargp.columns) if c != 'PARGPNME'], rtol)
143
+ d.control = _scalar_diff(effective_control(old), effective_control(new), rtol)
144
+ d.pestpp = _scalar_diff(dict(old.pestpp), dict(new.pestpp), rtol)
145
+ io_old = [('tpl', *t) for t in old.tpl] + [('ins', *t) for t in old.ins]
146
+ io_new = [('tpl', *t) for t in new.tpl] + [('ins', *t) for t in new.ins]
147
+ rows = [('added', *x) for x in io_new if x not in io_old] + [('removed', *x) for x in io_old if x not in io_new]
148
+ d.io = pd.DataFrame([(k, ch, a, b) for ch, k, a, b in rows], columns=['kind', 'change', 'pest file', 'model file'])
149
+ d.cmd = _list_diff(old.cmd, new.cmd, 'command')
150
+ d.comments = _list_diff(old.comments, new.comments, 'comment')
151
+ return d