makepst 0.4.0__tar.gz → 0.5.0__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.
Files changed (83) hide show
  1. {makepst-0.4.0 → makepst-0.5.0}/CHANGELOG.md +36 -0
  2. {makepst-0.4.0 → makepst-0.5.0}/CITATION.cff +2 -2
  3. {makepst-0.4.0 → makepst-0.5.0}/MANIFEST.in +1 -0
  4. {makepst-0.4.0 → makepst-0.5.0}/PKG-INFO +28 -7
  5. {makepst-0.4.0 → makepst-0.5.0}/README.md +27 -6
  6. {makepst-0.4.0 → makepst-0.5.0}/makepst/__init__.py +1 -1
  7. {makepst-0.4.0 → makepst-0.5.0}/makepst/cli.py +42 -15
  8. {makepst-0.4.0 → makepst-0.5.0}/makepst/excel.py +3 -0
  9. {makepst-0.4.0 → makepst-0.5.0}/makepst/pestpp.py +4 -1
  10. {makepst-0.4.0 → makepst-0.5.0}/makepst/provenance.py +9 -0
  11. {makepst-0.4.0 → makepst-0.5.0}/makepst/pst.py +39 -5
  12. {makepst-0.4.0 → makepst-0.5.0}/makepst/reader.py +9 -1
  13. {makepst-0.4.0 → makepst-0.5.0}/makepst/rules.py +84 -4
  14. {makepst-0.4.0 → makepst-0.5.0}/makepst/sections.py +63 -19
  15. {makepst-0.4.0 → makepst-0.5.0}/makepst/starter.py +9 -0
  16. {makepst-0.4.0 → makepst-0.5.0}/makepst/writer.py +17 -7
  17. {makepst-0.4.0 → makepst-0.5.0}/makepst.egg-info/PKG-INFO +28 -7
  18. {makepst-0.4.0 → makepst-0.5.0}/makepst.egg-info/SOURCES.txt +6 -1
  19. {makepst-0.4.0 → makepst-0.5.0}/pyproject.toml +1 -1
  20. makepst-0.5.0/tests/test_convenience.py +113 -0
  21. {makepst-0.4.0 → makepst-0.5.0}/tests/test_makepst.py +12 -0
  22. makepst-0.5.0/tests/test_pest_hp.py +172 -0
  23. makepst-0.5.0/tests/test_rule_sources.py +152 -0
  24. makepst-0.5.0/tools/rule_sources.py +353 -0
  25. makepst-0.5.0/tools/snapshots/pestchek.json +719 -0
  26. makepst-0.5.0/tools/snapshots/pestpp.json +322 -0
  27. makepst-0.4.0/tests/test_convenience.py +0 -60
  28. {makepst-0.4.0 → makepst-0.5.0}/LICENSE +0 -0
  29. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/control.csv +0 -0
  30. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/flow.ins +0 -0
  31. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/flow.out +0 -0
  32. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/heads.ins +0 -0
  33. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/heads.out +0 -0
  34. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/io.csv +0 -0
  35. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/model.in +0 -0
  36. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/model.par +0 -0
  37. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/model.py +0 -0
  38. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/model.res +0 -0
  39. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/model.tpl +0 -0
  40. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/obs.csv +0 -0
  41. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/par.csv +0 -0
  42. {makepst-0.4.0 → makepst-0.5.0}/examples/minimal/pargp.csv +0 -0
  43. {makepst-0.4.0 → makepst-0.5.0}/makepst/__main__.py +0 -0
  44. {makepst-0.4.0 → makepst-0.5.0}/makepst/checks.py +0 -0
  45. {makepst-0.4.0 → makepst-0.5.0}/makepst/diff.py +0 -0
  46. {makepst-0.4.0 → makepst-0.5.0}/makepst/hpstart.py +0 -0
  47. {makepst-0.4.0 → makepst-0.5.0}/makepst/model_files.py +0 -0
  48. {makepst-0.4.0 → makepst-0.5.0}/makepst/phi.py +0 -0
  49. {makepst-0.4.0 → makepst-0.5.0}/makepst/pyemu_bridge.py +0 -0
  50. {makepst-0.4.0 → makepst-0.5.0}/makepst/reweight.py +0 -0
  51. {makepst-0.4.0 → makepst-0.5.0}/makepst.egg-info/dependency_links.txt +0 -0
  52. {makepst-0.4.0 → makepst-0.5.0}/makepst.egg-info/entry_points.txt +0 -0
  53. {makepst-0.4.0 → makepst-0.5.0}/makepst.egg-info/requires.txt +0 -0
  54. {makepst-0.4.0 → makepst-0.5.0}/makepst.egg-info/top_level.txt +0 -0
  55. {makepst-0.4.0 → makepst-0.5.0}/setup.cfg +0 -0
  56. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.3.obs.csv +0 -0
  57. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.3.par.csv +0 -0
  58. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.par +0 -0
  59. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.phi.actual.csv +0 -0
  60. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.pst +0 -0
  61. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.res +0 -0
  62. {makepst-0.4.0 → makepst-0.5.0}/tests/data/demo.xlsx +0 -0
  63. {makepst-0.4.0 → makepst-0.5.0}/tests/make_fixture.py +0 -0
  64. {makepst-0.4.0 → makepst-0.5.0}/tests/test_checks.py +0 -0
  65. {makepst-0.4.0 → makepst-0.5.0}/tests/test_diff.py +0 -0
  66. {makepst-0.4.0 → makepst-0.5.0}/tests/test_examples.py +0 -0
  67. {makepst-0.4.0 → makepst-0.5.0}/tests/test_fixture.py +0 -0
  68. {makepst-0.4.0 → makepst-0.5.0}/tests/test_hpstart.py +0 -0
  69. {makepst-0.4.0 → makepst-0.5.0}/tests/test_log_bundle.py +0 -0
  70. {makepst-0.4.0 → makepst-0.5.0}/tests/test_model_files.py +0 -0
  71. {makepst-0.4.0 → makepst-0.5.0}/tests/test_optional_positions.py +0 -0
  72. {makepst-0.4.0 → makepst-0.5.0}/tests/test_optional_positions_variations.py +0 -0
  73. {makepst-0.4.0 → makepst-0.5.0}/tests/test_pestpp.py +0 -0
  74. {makepst-0.4.0 → makepst-0.5.0}/tests/test_phi.py +0 -0
  75. {makepst-0.4.0 → makepst-0.5.0}/tests/test_provenance.py +0 -0
  76. {makepst-0.4.0 → makepst-0.5.0}/tests/test_pyemu.py +0 -0
  77. {makepst-0.4.0 → makepst-0.5.0}/tests/test_rescale.py +0 -0
  78. {makepst-0.4.0 → makepst-0.5.0}/tests/test_reweight.py +0 -0
  79. {makepst-0.4.0 → makepst-0.5.0}/tests/test_rules.py +0 -0
  80. {makepst-0.4.0 → makepst-0.5.0}/tests/test_set_par.py +0 -0
  81. {makepst-0.4.0 → makepst-0.5.0}/tests/test_starter.py +0 -0
  82. {makepst-0.4.0 → makepst-0.5.0}/tests/test_v2.py +0 -0
  83. {makepst-0.4.0 → makepst-0.5.0}/tests/test_version.py +0 -0
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-09-29)
4
+
5
+ - `tools/rule_sources.py`: are `validate`'s rules still current? `pestpp` re-reads PEST++'s option
6
+ parser from GitHub (names, aliases, types, choices, deprecations); `pestchek --source DIR` reads a
7
+ PEST download's `pestchek.F` / `cheksub.F` (control-data keywords and every message). Each is
8
+ compared with the reviewed snapshot in `tools/snapshots/` and with makePst's own tables; exit 1 on
9
+ a change, 2 when the source cannot be read; `--write` records a reviewed source. Tests hold
10
+ `makepst/pestpp.py` and the control-data schema to the snapshots, and a weekly GitHub workflow
11
+ (`.github/workflows/rule-sources.yml`) opens an issue when PEST++'s parser changes. The PEST++
12
+ registry is unchanged; its provenance now names commit 02e1455 (5.2.29) - the commit named before,
13
+ ad8428d, has identical option parsing but is labelled 5.2.28 in the source.
14
+
15
+ - `build --dry_run`: the `validate` report on the control file a build would write, from a workbook's
16
+ BUILD sheet or from tables named on the command line; nothing is written, no manifest; exit 1 on
17
+ errors. A workbook with no BUILD sheet now says how to name its tables and check them this way.
18
+ - `validate` on a workbook judges it as `build` would write it, after the same normalization
19
+ (regularisation equations from `PRIOR`/`WEIGHT`, unused groups removed, ties to fixed parameters
20
+ fixed), listing those as notes. It used to judge the tables before that and report errors building
21
+ removes: a regularisation file with no "regul" group, prior information not generated.
22
+
23
+ - PEST_HP control variables are treated as `++` options are: a control-data token makePst does not
24
+ know (`newhpvar=7`, a new flag) is kept with the line it came from and written back there, instead
25
+ of being dropped, and `validate` warns about it with the closest known name. `dump` writes it to
26
+ the CONTROL sheet with its line (`control data line 4`), so `build` puts it back.
27
+ - Every PEST_HP variable PEST 17.2's pestchek reads is now known: `run_slow_fac`, `run_abandon_fac`,
28
+ `jcowarnthresh`, `jcozerothresh`, `zerosenval`, `hardstophours`, `softstophours`, `reg2measrat`,
29
+ `orr_not_first`, and `obsreref_N`; `name = value` with spaces around `=` is read, as PEST_HP does.
30
+ - `validate` checks that PEST can read each control value: an integer or a number where one is
31
+ expected (Fortran `1d-3` included), a known word for `precis`, `dpoint` and the other flags,
32
+ `absparmax(n)=value` with n 1-10; and the PEST_HP ranges and combinations (`RUN_ABANDON_FAC` 0 or
33
+ at least 1.2, not both stop-hours variables, `JCOZEROTHRESH` above `JCOWARNTHRESH`, `REG2MEASRAT`
34
+ below 1, `orr_not_first` and `ZEROSENVAL` only with what they need).
35
+ - The rule sources are pinned and reported: `PESTCHEK_SOURCE_VERSION` (17.2) beside
36
+ `PESTPP_REGISTRY_VERSION` (5.2.29); `validate` prints both and every manifest records them under
37
+ `checked_against`.
38
+
3
39
  ## 0.4.0 (2026-09-27)
4
40
 
5
41
  - `rescale` / `Pst.rescale_par()`: move parameter values into `SCALE` so each selected parameter
@@ -6,8 +6,8 @@ authors:
6
6
  - family-names: Ou
7
7
  given-names: Michael
8
8
  email: ougengxin@gmail.com
9
- version: 0.4.0
10
- date-released: 2026-09-27
9
+ version: 0.5.0
10
+ date-released: 2026-09-29
11
11
  repository-code: "https://github.com/ougx/makePst"
12
12
  license: MIT
13
13
  keywords:
@@ -1,5 +1,6 @@
1
1
  include LICENSE README.md CHANGELOG.md CITATION.cff
2
2
  recursive-include tests *.py *.xlsx *.pst *.par *.res *.csv
3
+ recursive-include tools *.py *.json
3
4
  recursive-include examples/minimal *.csv *.py *.tpl *.ins *.in *.out *.par *.res
4
5
  prune examples/mnnrd
5
6
  global-exclude __pycache__ *.py[cod] *.manifest.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: makepst
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Build PEST / PEST++ / PEST_HP control files from spreadsheet tables, read them back, and push results into the workbook
5
5
  Author-email: Michael Ou <ougengxin@gmail.com>
6
6
  License: MIT
@@ -486,8 +486,8 @@ the workbook?" even after the file was renamed or overwritten. `--check` adds a
486
486
  `provenance`: `unchanged` / `changed` / `missing`.
487
487
 
488
488
  ```
489
- 2026-09-19 21:14 build pest/tr13.pst <- tr13.xlsx [1068 par 41747 obs 159 prior] makepst 0.4.0
490
- 2026-09-21 08:10 update tr13-xlwings.xlsx <- tr13.xlsx, tr13.par [1068 rows in 8 sheets] makepst 0.4.0
489
+ 2026-09-19 21:14 build pest/tr13.pst <- tr13.xlsx [1068 par 41747 obs 159 prior] makepst 0.5.0
490
+ 2026-09-21 08:10 update tr13-xlwings.xlsx <- tr13.xlsx, tr13.par [1068 rows in 8 sheets] makepst 0.5.0
491
491
  ```
492
492
 
493
493
  ### bundle — a run, zipped for review
@@ -563,7 +563,8 @@ explicitly before writing.
563
563
  | parameter group defined but unused | fix available: remove the group |
564
564
  | prior equation referencing a fixed, tied or missing parameter | error and fix available: rebuild prior information, dropping those equations |
565
565
  | adjustable `PARVAL1` outside `[PARLBND, PARUBND]` | warning |
566
- | control value that is computed (`npar` …) or unknown | ignored (warning) |
566
+ | control value that is computed (`npar` …) | ignored (warning) |
567
+ | control variable makePst does not know (a newer PEST_HP keyword): kept on the line it came from; a CONTROL-sheet row gives that line in `LINE` (`control data line 4`) or is ignored | warning |
567
568
 
568
569
  `makepst validate` uses the same table checks and adds the **pestchek-style report**: it
569
570
  changes nothing, looks beyond the tables at the files the control file points to, and exits 1
@@ -582,7 +583,9 @@ makepst validate tr13.xlsx # a workbook with a BUILD sheet is built f
582
583
  | malformed prior equation; non-numeric right-hand side; `log()` used on a non-log parameter or vice versa | error |
583
584
  | `DERCOM` beyond the number of model command lines | error |
584
585
  | name longer than PEST's limit (12 parameter / 20 observation / 12 group; PEST++ allows 200) | warning |
585
- | **pestchek's rules** (from PEST 17's `pestchek.F` / `cheksub.F`): `PARCHGLIM` vocabulary and `absolute(n)` needing `absparmax(n)=`; log parameters factor-limited with positive values; factor-limited bounds of one sign and non-zero; `SCALE ≠ 0`; tied to itself, tied or parent with initial value 0; group `none` reserved; all parameters fixed/tied; `INCTYP` / `FORCEN` / `DERMTHD` vocabularies and their compatibility; split-column ranges; duplicate groups; `dum` as an observation name; prior weights, labels (≤ 20, not an observation name), duplicated parameters, all-zero factors, group `predict`; regularisation needs a `regul*` group, prediction exactly one `predict` observation; ~60 control-variable range and consistency rules (`RLAMFAC`, `PHIRATSUF`, `FACPARMAX`, `NPHISTP`, `RELPARSTP < RELPARMAX`, `EIGTHRESH`, `PHIMLIM < PHIMACCEPT < 1.2·PHIMLIM`, `WFMIN ≤ WFINIT ≤ WFMAX`, `UPTESTMIN`/`UPTESTLIM`, SVD vs AUI vs LSQR exclusivity, …) | error |
586
+ | **pestchek's rules** (from PEST 17.2's `pestchek.F` / `cheksub.F`): `PARCHGLIM` vocabulary and `absolute(n)` needing `absparmax(n)=`; log parameters factor-limited with positive values; factor-limited bounds of one sign and non-zero; `SCALE ≠ 0`; tied to itself, tied or parent with initial value 0; group `none` reserved; all parameters fixed/tied; `INCTYP` / `FORCEN` / `DERMTHD` vocabularies and their compatibility; split-column ranges; duplicate groups; `dum` as an observation name; prior weights, labels (≤ 20, not an observation name), duplicated parameters, all-zero factors, group `predict`; regularisation needs a `regul*` group, prediction exactly one `predict` observation; ~60 control-variable range and consistency rules (`RLAMFAC`, `PHIRATSUF`, `FACPARMAX`, `NPHISTP`, `RELPARSTP < RELPARMAX`, `EIGTHRESH`, `PHIMLIM < PHIMACCEPT < 1.2·PHIMLIM`, `WFMIN ≤ WFINIT ≤ WFMAX`, `UPTESTMIN`/`UPTESTLIM`, SVD vs AUI vs LSQR exclusivity, …) | error |
587
+ | control value PEST cannot read: not an integer or a number where one is expected (Fortran `1d-3` is fine), a word outside its field's vocabulary (`precis`, `dpoint`, `obsreref`, …), `absparmax(n)=` malformed or n outside 1-10; the PEST_HP limits and combinations (`RUN_SLOW_FAC`, `RUN_ABANDON_FAC`, `HARDSTOPHOURS` / `SOFTSTOPHOURS` not both, `JCOZEROTHRESH` above `JCOWARNTHRESH`, `ZEROSENVAL`, `REG2MEASRAT` below 1, `orr_not_first` only with `obsreref`) | error |
588
+ | control-data token not in PEST 17.2's pestchek — kept as written, with the closest known name (`"win_mrun_hour=2" … did you mean "win_mrun_hours"?`) | warning |
586
589
  | pestchek's warnings: derivative increment larger than a third of the range; initial value 0 with a relative increment; group with only fixed/tied parameters; log parameters with a non-relative increment; observation group listed but empty (dropped on write); `MAXSING` above the adjustable-parameter count; more than 300 adjustable parameters with `ICOV`/`ICOR`/`IEIG` on; `NOPTMAX 0` | warning |
587
590
  | PEST_HP-only control variables present (`WIN_MRUN_HOURS`, `UPTESTMIN`, `RRFSAVE`, …; plain PEST needs `/hpstart`); observation group whose weights are all zero | note |
588
591
  | template: parameter space narrower than 3 characters or containing a tab | error |
@@ -605,6 +608,22 @@ too, without refusing to write: plain PEST ignores `++` lines, and a newer PEST+
605
608
  option this list does not. PEST++ itself stops on an option it does not accept, so fix or remove
606
609
  what is flagged before a run.
607
610
 
611
+ `validate` prints the two releases its rules were read from, and every manifest records them
612
+ (`checked_against`). Whether they are still current is checked by `tools/rule_sources.py`, which
613
+ re-reads the upstream source and compares it with the snapshot the rules were written against
614
+ (`tools/snapshots/`), exiting 1 on any change:
615
+
616
+ ```
617
+ python tools/rule_sources.py pestpp # usgs/pestpp develop, from GitHub
618
+ python tools/rule_sources.py pestchek --source pest_source # the source folder of a PEST download
619
+ ```
620
+
621
+ It reports added and removed `++` options, aliases, value types and choices; for PEST, the
622
+ control-data keywords pestchek reads and its messages (each message is a rule); and anything
623
+ makePst's own tables no longer agree with. It edits nothing: port what changed, then run it
624
+ again with `--write` to record the new source as reviewed, and the tests hold the code to that
625
+ snapshot. The PEST++ check runs weekly on GitHub and opens an issue when the parser changes.
626
+
608
627
  The `--outputs` interpreter follows the PEST manual (primary and secondary markers, `l`, `w`,
609
628
  `t`, `!name!`, `[name]c1:c2`, `(name)c1:c2`, `dum`, `&` continuation) and catches the classic
610
629
  mistakes — a `w` too few so a label is read instead of a number, a marker that never appears
@@ -623,12 +642,13 @@ from?" after the fact:
623
642
 
624
643
  ```json
625
644
  {
626
- "makepst": "0.4.0",
645
+ "makepst": "0.5.0",
627
646
  "command": "build",
628
647
  "argv": ["build", "tr13.pst", "regul", "--set_ctl_xls", "tr13.xlsx,CONTROL", "..."],
629
648
  "created": "2026-09-19T13:51:39-04:00",
630
649
  "user": "hydro", "host": "OU13700", "cwd": "D:\\...\\0023-makePst",
631
650
  "python": "3.12.12", "pandas": "3.0.5", "platform": "Windows-11-10.0.26200-SP0",
651
+ "checked_against": {"pestchek": "17.2", "pestpp": "5.2.29"},
632
652
  "sources": [
633
653
  {"path": "D:\\...\\tr13.xlsx", "sha256": "9d641a2f...", "size": 3089317,
634
654
  "modified": "2026-07-12T03:32:10-04:00", "role": "control",
@@ -738,6 +758,7 @@ makepst/
738
758
  starter.py the `init` workbook: control-variable descriptions, example rows, drop-downs
739
759
  examples/minimal/ the tutorial inputs
740
760
  tests/ suite + fixture generator
761
+ tools/ rule_sources.py (are the rules current with PEST / PEST++?) and its snapshots
741
762
  ```
742
763
 
743
764
  ## Releasing
@@ -747,7 +768,7 @@ stored anywhere. To release: bump the version in `pyproject.toml` and the fallba
747
768
  `makepst/__init__.py` (a test keeps them equal), add a `CHANGELOG.md` entry, commit, then
748
769
 
749
770
  ```
750
- git tag v0.4.0 && git push origin main v0.4.0
771
+ git tag v0.5.0 && git push origin main v0.5.0
751
772
  ```
752
773
 
753
774
  The `publish` workflow checks that the tag matches the package version, runs the tests, builds
@@ -455,8 +455,8 @@ the workbook?" even after the file was renamed or overwritten. `--check` adds a
455
455
  `provenance`: `unchanged` / `changed` / `missing`.
456
456
 
457
457
  ```
458
- 2026-09-19 21:14 build pest/tr13.pst <- tr13.xlsx [1068 par 41747 obs 159 prior] makepst 0.4.0
459
- 2026-09-21 08:10 update tr13-xlwings.xlsx <- tr13.xlsx, tr13.par [1068 rows in 8 sheets] makepst 0.4.0
458
+ 2026-09-19 21:14 build pest/tr13.pst <- tr13.xlsx [1068 par 41747 obs 159 prior] makepst 0.5.0
459
+ 2026-09-21 08:10 update tr13-xlwings.xlsx <- tr13.xlsx, tr13.par [1068 rows in 8 sheets] makepst 0.5.0
460
460
  ```
461
461
 
462
462
  ### bundle — a run, zipped for review
@@ -532,7 +532,8 @@ explicitly before writing.
532
532
  | parameter group defined but unused | fix available: remove the group |
533
533
  | prior equation referencing a fixed, tied or missing parameter | error and fix available: rebuild prior information, dropping those equations |
534
534
  | adjustable `PARVAL1` outside `[PARLBND, PARUBND]` | warning |
535
- | control value that is computed (`npar` …) or unknown | ignored (warning) |
535
+ | control value that is computed (`npar` …) | ignored (warning) |
536
+ | control variable makePst does not know (a newer PEST_HP keyword): kept on the line it came from; a CONTROL-sheet row gives that line in `LINE` (`control data line 4`) or is ignored | warning |
536
537
 
537
538
  `makepst validate` uses the same table checks and adds the **pestchek-style report**: it
538
539
  changes nothing, looks beyond the tables at the files the control file points to, and exits 1
@@ -551,7 +552,9 @@ makepst validate tr13.xlsx # a workbook with a BUILD sheet is built f
551
552
  | malformed prior equation; non-numeric right-hand side; `log()` used on a non-log parameter or vice versa | error |
552
553
  | `DERCOM` beyond the number of model command lines | error |
553
554
  | name longer than PEST's limit (12 parameter / 20 observation / 12 group; PEST++ allows 200) | warning |
554
- | **pestchek's rules** (from PEST 17's `pestchek.F` / `cheksub.F`): `PARCHGLIM` vocabulary and `absolute(n)` needing `absparmax(n)=`; log parameters factor-limited with positive values; factor-limited bounds of one sign and non-zero; `SCALE ≠ 0`; tied to itself, tied or parent with initial value 0; group `none` reserved; all parameters fixed/tied; `INCTYP` / `FORCEN` / `DERMTHD` vocabularies and their compatibility; split-column ranges; duplicate groups; `dum` as an observation name; prior weights, labels (≤ 20, not an observation name), duplicated parameters, all-zero factors, group `predict`; regularisation needs a `regul*` group, prediction exactly one `predict` observation; ~60 control-variable range and consistency rules (`RLAMFAC`, `PHIRATSUF`, `FACPARMAX`, `NPHISTP`, `RELPARSTP < RELPARMAX`, `EIGTHRESH`, `PHIMLIM < PHIMACCEPT < 1.2·PHIMLIM`, `WFMIN ≤ WFINIT ≤ WFMAX`, `UPTESTMIN`/`UPTESTLIM`, SVD vs AUI vs LSQR exclusivity, …) | error |
555
+ | **pestchek's rules** (from PEST 17.2's `pestchek.F` / `cheksub.F`): `PARCHGLIM` vocabulary and `absolute(n)` needing `absparmax(n)=`; log parameters factor-limited with positive values; factor-limited bounds of one sign and non-zero; `SCALE ≠ 0`; tied to itself, tied or parent with initial value 0; group `none` reserved; all parameters fixed/tied; `INCTYP` / `FORCEN` / `DERMTHD` vocabularies and their compatibility; split-column ranges; duplicate groups; `dum` as an observation name; prior weights, labels (≤ 20, not an observation name), duplicated parameters, all-zero factors, group `predict`; regularisation needs a `regul*` group, prediction exactly one `predict` observation; ~60 control-variable range and consistency rules (`RLAMFAC`, `PHIRATSUF`, `FACPARMAX`, `NPHISTP`, `RELPARSTP < RELPARMAX`, `EIGTHRESH`, `PHIMLIM < PHIMACCEPT < 1.2·PHIMLIM`, `WFMIN ≤ WFINIT ≤ WFMAX`, `UPTESTMIN`/`UPTESTLIM`, SVD vs AUI vs LSQR exclusivity, …) | error |
556
+ | control value PEST cannot read: not an integer or a number where one is expected (Fortran `1d-3` is fine), a word outside its field's vocabulary (`precis`, `dpoint`, `obsreref`, …), `absparmax(n)=` malformed or n outside 1-10; the PEST_HP limits and combinations (`RUN_SLOW_FAC`, `RUN_ABANDON_FAC`, `HARDSTOPHOURS` / `SOFTSTOPHOURS` not both, `JCOZEROTHRESH` above `JCOWARNTHRESH`, `ZEROSENVAL`, `REG2MEASRAT` below 1, `orr_not_first` only with `obsreref`) | error |
557
+ | control-data token not in PEST 17.2's pestchek — kept as written, with the closest known name (`"win_mrun_hour=2" … did you mean "win_mrun_hours"?`) | warning |
555
558
  | pestchek's warnings: derivative increment larger than a third of the range; initial value 0 with a relative increment; group with only fixed/tied parameters; log parameters with a non-relative increment; observation group listed but empty (dropped on write); `MAXSING` above the adjustable-parameter count; more than 300 adjustable parameters with `ICOV`/`ICOR`/`IEIG` on; `NOPTMAX 0` | warning |
556
559
  | PEST_HP-only control variables present (`WIN_MRUN_HOURS`, `UPTESTMIN`, `RRFSAVE`, …; plain PEST needs `/hpstart`); observation group whose weights are all zero | note |
557
560
  | template: parameter space narrower than 3 characters or containing a tab | error |
@@ -574,6 +577,22 @@ too, without refusing to write: plain PEST ignores `++` lines, and a newer PEST+
574
577
  option this list does not. PEST++ itself stops on an option it does not accept, so fix or remove
575
578
  what is flagged before a run.
576
579
 
580
+ `validate` prints the two releases its rules were read from, and every manifest records them
581
+ (`checked_against`). Whether they are still current is checked by `tools/rule_sources.py`, which
582
+ re-reads the upstream source and compares it with the snapshot the rules were written against
583
+ (`tools/snapshots/`), exiting 1 on any change:
584
+
585
+ ```
586
+ python tools/rule_sources.py pestpp # usgs/pestpp develop, from GitHub
587
+ python tools/rule_sources.py pestchek --source pest_source # the source folder of a PEST download
588
+ ```
589
+
590
+ It reports added and removed `++` options, aliases, value types and choices; for PEST, the
591
+ control-data keywords pestchek reads and its messages (each message is a rule); and anything
592
+ makePst's own tables no longer agree with. It edits nothing: port what changed, then run it
593
+ again with `--write` to record the new source as reviewed, and the tests hold the code to that
594
+ snapshot. The PEST++ check runs weekly on GitHub and opens an issue when the parser changes.
595
+
577
596
  The `--outputs` interpreter follows the PEST manual (primary and secondary markers, `l`, `w`,
578
597
  `t`, `!name!`, `[name]c1:c2`, `(name)c1:c2`, `dum`, `&` continuation) and catches the classic
579
598
  mistakes — a `w` too few so a label is read instead of a number, a marker that never appears
@@ -592,12 +611,13 @@ from?" after the fact:
592
611
 
593
612
  ```json
594
613
  {
595
- "makepst": "0.4.0",
614
+ "makepst": "0.5.0",
596
615
  "command": "build",
597
616
  "argv": ["build", "tr13.pst", "regul", "--set_ctl_xls", "tr13.xlsx,CONTROL", "..."],
598
617
  "created": "2026-09-19T13:51:39-04:00",
599
618
  "user": "hydro", "host": "OU13700", "cwd": "D:\\...\\0023-makePst",
600
619
  "python": "3.12.12", "pandas": "3.0.5", "platform": "Windows-11-10.0.26200-SP0",
620
+ "checked_against": {"pestchek": "17.2", "pestpp": "5.2.29"},
601
621
  "sources": [
602
622
  {"path": "D:\\...\\tr13.xlsx", "sha256": "9d641a2f...", "size": 3089317,
603
623
  "modified": "2026-07-12T03:32:10-04:00", "role": "control",
@@ -707,6 +727,7 @@ makepst/
707
727
  starter.py the `init` workbook: control-variable descriptions, example rows, drop-downs
708
728
  examples/minimal/ the tutorial inputs
709
729
  tests/ suite + fixture generator
730
+ tools/ rule_sources.py (are the rules current with PEST / PEST++?) and its snapshots
710
731
  ```
711
732
 
712
733
  ## Releasing
@@ -716,7 +737,7 @@ stored anywhere. To release: bump the version in `pyproject.toml` and the fallba
716
737
  `makepst/__init__.py` (a test keeps them equal), add a `CHANGELOG.md` entry, commit, then
717
738
 
718
739
  ```
719
- git tag v0.4.0 && git push origin main v0.4.0
740
+ git tag v0.5.0 && git push origin main v0.5.0
720
741
  ```
721
742
 
722
743
  The `publish` workflow checks that the tag matches the package version, runs the tests, builds
@@ -3,7 +3,7 @@ try: # the installed package's metadata (
3
3
  from importlib.metadata import version as _dist_version
4
4
  __version__ = _dist_version('makepst')
5
5
  except Exception: # a checkout run without installing
6
- __version__ = '0.4.0'
6
+ __version__ = '0.5.0'
7
7
 
8
8
  from .excel import load_table, to_workbook, update_workbook
9
9
  from .hpstart import write_hpstart
@@ -49,6 +49,8 @@ def _build_parser(p):
49
49
  p.add_argument('--no_dump_tpl', action='store_true', help="don't write dump.tpl next to the pst")
50
50
  p.add_argument('--no_manifest', action='store_true', help="don't write <pst>.manifest.json")
51
51
  p.add_argument('--out', metavar='PST', help='with a workbook: where to write (default: the name in the BUILD sheet)')
52
+ p.add_argument('--dry_run', action='store_true',
53
+ help="report as `makepst validate` would on the control file this builds; write nothing")
52
54
  fmt_ = p.add_mutually_exclusive_group()
53
55
  fmt_.add_argument('--v2', action='store_true', help='write a PEST++ version-2 file (external csv tables beside it)')
54
56
  fmt_.add_argument('--v1', action='store_true', help='write the classic format (default for tables)')
@@ -111,15 +113,19 @@ def _finish_pst(pst, path, manifest, dump_tpl, version=None, eol=None):
111
113
 
112
114
 
113
115
  def build(args):
114
- manifest = None if args.no_manifest else Manifest('build', args._argv)
116
+ manifest = None if args.no_manifest or args.dry_run else Manifest('build', args._argv)
115
117
  if args.pstfile.lower().endswith(WORKBOOK_EXT):
116
118
  # `makepst build book.xlsx [--out x.pst]`: run the command in the workbook's BUILD sheet
117
119
  pst, ns = build_from_workbook(args.pstfile, manifest)
120
+ if args.dry_run:
121
+ return check_report(pst, args.pstfile, built=True, dry_run=True)
118
122
  out = args.out or os.path.join(os.path.dirname(os.path.abspath(args.pstfile)), ns.pstfile)
119
123
  _finish_pst(pst, out, manifest, dump_tpl=not (args.no_dump_tpl or ns.no_dump_tpl),
120
124
  version=_version(args) or _version(ns))
121
125
  return
122
126
  pst = build_pst(args, manifest)
127
+ if args.dry_run:
128
+ return check_report(pst, args.pstfile, built=True, dry_run=True)
123
129
  _finish_pst(pst, args.pstfile, manifest, dump_tpl=not args.no_dump_tpl, version=_version(args))
124
130
 
125
131
 
@@ -130,8 +136,10 @@ def build_from_workbook(book, manifest=None):
130
136
  try:
131
137
  rows = pd.read_excel(book, 'BUILD', engine='openpyxl').iloc[:, 0].dropna()
132
138
  except ValueError:
133
- raise SystemExit(f'{book} has no BUILD sheet; add one holding the build command '
134
- f'(as `makepst dump` does), or use `makepst build ... --fill_parval`') from None
139
+ raise SystemExit(f'{book} has no BUILD sheet; add one holding the build command (as `makepst dump` '
140
+ f'does), or name the tables on the command line: `makepst build OUT.pst --set_ctl_xls '
141
+ f'"{os.path.basename(book)},CONTROL" --add_par_xls ...`, with --dry_run to check them '
142
+ f'without writing') from None
135
143
  argv = [a.strip('"') for a in shlex.split(' '.join(str(r) for r in rows), posix=False)]
136
144
  while argv and (argv[0].lower() in ('python', 'makepst', 'build') or argv[0].lower().endswith('makepst.py')):
137
145
  argv.pop(0)
@@ -403,27 +411,46 @@ def guess_base_dir(pst, target):
403
411
 
404
412
  def validate(args):
405
413
  """pestchek-style report for a control file or a workbook (built via its BUILD sheet)."""
406
- from .checks import Finding, summary, validate as run_checks
407
414
  target = args.target
408
415
  if target.lower().endswith(WORKBOOK_EXT):
409
- pst, _ = build_from_workbook(target)
410
- pst_path = None
416
+ check_report(build_from_workbook(target)[0], target, built=True, base_dir=args.base_dir,
417
+ outputs=args.outputs, quiet=args.quiet, strict=args.strict)
418
+ else:
419
+ check_report(read_pst(target), target, pst_path=target, base_dir=args.base_dir,
420
+ outputs=args.outputs, quiet=args.quiet, strict=args.strict)
421
+
422
+
423
+ def check_report(pst, target, built=False, pst_path=None, base_dir=None, outputs=False, quiet=False,
424
+ strict=False, dry_run=False):
425
+ """Print the validate report for `pst`; exit 1 on errors (or warnings with `strict`).
426
+
427
+ `built`: the Pst comes from tables, not from a control file. It is then judged as `build` would write it,
428
+ after the same normalization (regularisation equations from PRIOR/WEIGHT, unused groups removed, ...);
429
+ judging the tables before it would report errors that building removes. The fixes are listed as notes.
430
+ """
431
+ from .checks import Finding, summary, validate as run_checks
432
+ from .provenance import checked_against
433
+ fixes = pst.validate().fixes_available
434
+ if built:
435
+ pst.normalize()
436
+ notes = [Finding('info', 'normalization', f'{fix} (done by build)') for fix in fixes]
411
437
  else:
412
- pst = read_pst(target)
413
- pst_path = target
414
- base = args.base_dir or guess_base_dir(pst, target)
415
- findings = run_checks(pst, base_dir=base, pst_path=pst_path, outputs=args.outputs)
416
- findings += [Finding('info', 'normalization', str(fix)) for fix in pst.validate().fixes_available]
417
- if not args.base_dir and not args.quiet:
438
+ notes = [Finding('info', 'normalization', str(fix)) for fix in fixes]
439
+ base = base_dir or guess_base_dir(pst, target)
440
+ findings = run_checks(pst, base_dir=base, pst_path=pst_path, outputs=outputs) + notes
441
+ if not base_dir and not quiet:
418
442
  print(f'INFO file paths resolved relative to {base} (override with --base_dir)')
443
+ if not quiet:
444
+ v = checked_against()
445
+ print(f"INFO rules from PEST {v['pestchek']}'s pestchek and PEST++ {v['pestpp']}'s options")
419
446
  order = {'error': 0, 'warning': 1, 'info': 2}
420
447
  for f in sorted(findings, key=lambda f: order[f.severity]):
421
- if f.severity == 'info' and args.quiet:
448
+ if f.severity == 'info' and quiet:
422
449
  continue
423
450
  print(f)
424
451
  n, text = summary(findings)
425
- print(f'{target}: {text}')
426
- if n['error'] or (args.strict and n['warning']):
452
+ print(f'{target}: {text}' + (' (dry run: nothing written)' if dry_run else ''))
453
+ if n['error'] or (strict and n['warning']):
427
454
  raise SystemExit(1)
428
455
 
429
456
 
@@ -86,6 +86,9 @@ def control_table(pst: Pst):
86
86
  val = pst.control.get(k)
87
87
  default = pst.counts.get(k, sec.defaults.get(k))
88
88
  rows.append((sec.name, k, default, val))
89
+ # tokens this makePst does not know, kept with the line PEST reads them on so build puts them back there
90
+ for k, (section, n) in getattr(pst, 'control_line', {}).items():
91
+ rows.append((f'{section} line {n}', k, None, pst.control.get(k)))
89
92
  return pd.DataFrame(rows, columns=['LINE', 'NAME', 'DEFAULT', 'VALUE'])
90
93
 
91
94
 
@@ -1,6 +1,6 @@
1
1
  """PEST++ `++` options: what PEST++ accepts, and a check of a control file's options against it.
2
2
 
3
- The registry is read off PEST++ 5.2.29 (usgs/pestpp develop, commit ad8428d, 2026-07-09), from
3
+ The registry is read off PEST++ 5.2.29 (usgs/pestpp develop, commit 02e1455, 2026-09-03), from
4
4
  PestppOptions::assign_value_by_key and the ies / da / mou / sqp functions it falls through to in
5
5
  src/libs/pestpp_common/pest_data_structs.cpp. What that code does on start-up decides the severities:
6
6
 
@@ -14,6 +14,9 @@ src/libs/pestpp_common/pest_data_structs.cpp. What that code does on start-up de
14
14
 
15
15
  `++` lines go to PestppOptions only. Control variables (noptmax, maxsing, ...) are accepted in a
16
16
  version-2 file's `* control data keyword` section, not as `++` options.
17
+
18
+ `python tools/rule_sources.py pestpp` re-reads that source and reports what has changed since, and whether
19
+ the names, aliases, types and choices below still match what was extracted (tools/snapshots/pestpp.json).
17
20
  """
18
21
  import difflib
19
22
  import re
@@ -31,6 +31,14 @@ def _file_record(path):
31
31
  }
32
32
 
33
33
 
34
+ def checked_against():
35
+ """The releases makePst's checks were read from: PEST's pestchek (control data, PEST_HP variables) and
36
+ PEST++'s option list. A check is only as current as these; the manifest records them with each output."""
37
+ from .pestpp import PESTPP_REGISTRY_VERSION
38
+ from .sections import PESTCHEK_SOURCE_VERSION
39
+ return {'pestchek': PESTCHEK_SOURCE_VERSION, 'pestpp': PESTPP_REGISTRY_VERSION}
40
+
41
+
34
42
  class Manifest:
35
43
  def __init__(self, command, argv=None):
36
44
  from . import __version__
@@ -47,6 +55,7 @@ class Manifest:
47
55
  'python': platform.python_version(),
48
56
  'pandas': pandas.__version__,
49
57
  'openpyxl': openpyxl.__version__,
58
+ 'checked_against': checked_against(),
50
59
  'platform': platform.platform(),
51
60
  'sources': [],
52
61
  'output': None,
@@ -13,7 +13,7 @@ from dataclasses import dataclass, field
13
13
  import numpy as np
14
14
  import pandas as pd
15
15
 
16
- from .sections import COMPUTED, FIELD_SECTION, _num, fmt, is_number, normalize_mode
16
+ from .sections import COMPUTED, FIELD_SECTION, SECTIONS, _num, fmt, is_number, normalize_mode
17
17
 
18
18
  PAR_COLS = 'PARNME PARTRANS PARCHGLIM PARVAL1 PARLBND PARUBND PARGP SCALE OFFSET DERCOM'.split()
19
19
  PAR_EXTRA = ['TIETO', 'PRIOR', 'WEIGHT'] # optional columns carried on self.par
@@ -31,6 +31,14 @@ def _clean(s):
31
31
  return s.astype(str).str.strip().str.lower()
32
32
 
33
33
 
34
+ def _control_place(text):
35
+ """('control data', 4) from a CONTROL sheet's LINE cell such as 'control data line 4', else None."""
36
+ m = re.fullmatch(r'\*?\s*(.*?)[\s,]+(?:line\s*)?(\d+)', str(text or '').strip().lower())
37
+ if m and m.group(1) in SECTIONS and int(m.group(2)) > 0:
38
+ return SECTIONS[m.group(1)].name, int(m.group(2))
39
+ return None
40
+
41
+
34
42
  def _is_storage(names):
35
43
  return names.str.startswith('ss') | names.str.startswith('sy')
36
44
 
@@ -185,6 +193,7 @@ class Pst:
185
193
  self.ss = ss # steady state: drop storage (ss*/sy*) parameters and groups
186
194
  self.comments = [] # '# ...' lines written after 'pcf'
187
195
  self.control = {} # user values for every control-style section, flat
196
+ self.control_line = {} # control tokens the schema does not know: name -> (section, line number)
188
197
  self.use_svd = True
189
198
  self.par = _empty(PAR_COLS)
190
199
  self.pargp = _empty(PARGP_COLS)
@@ -246,11 +255,16 @@ class Pst:
246
255
  # ------------------------------------------------------------------ control
247
256
  def set_control(self, values):
248
257
  """Set control-style values from a dict or a CONTROL sheet (NAME/VALUE columns)."""
258
+ lines = {}
249
259
  if isinstance(values, pd.DataFrame):
250
260
  df = values.iloc[:, :4].copy()
251
261
  df.columns = ['LINE', 'NAME', 'DEFAULT', 'VALUE'][:df.shape[1]]
252
- values = {str(r.NAME).strip().lower(): r.VALUE
253
- for r in df.dropna(subset=['NAME', 'VALUE']).itertuples()}
262
+ rows = df.dropna(subset=['NAME', 'VALUE']).itertuples()
263
+ values = {}
264
+ for r in rows:
265
+ name = str(r.NAME).strip().lower()
266
+ values[name] = r.VALUE
267
+ lines[name] = getattr(r, 'LINE', None)
254
268
  for k, v in values.items():
255
269
  k = str(k).strip().lower()
256
270
  if k == 'pestmode':
@@ -258,7 +272,16 @@ class Pst:
258
272
  elif k in COMPUTED:
259
273
  warnings.warn(f'control value {k}={v!r} ignored; it is computed from the tables')
260
274
  elif k not in FIELD_SECTION:
261
- warnings.warn(f'unknown control variable {k!r} ignored')
275
+ # A token this makePst does not know (a newer PEST_HP variable, say) is kept, like an unknown
276
+ # `++` option - but PEST reads it only on its own line, so it needs one: a control file gives
277
+ # it, a CONTROL sheet says it in the LINE column ('control data line 4').
278
+ place = _control_place(lines.get(k)) or self.control_line.get(k)
279
+ if place is None:
280
+ warnings.warn(f'unknown control variable {k!r} ignored: give the line it belongs on, '
281
+ f"as 'control data line 4' in the CONTROL sheet's LINE column")
282
+ elif fmt(v) != '':
283
+ self.control[k] = _num(v) if isinstance(v, str) else v
284
+ self.control_line[k] = place
262
285
  elif fmt(v) != '':
263
286
  self.control[k] = _num(v) if isinstance(v, str) else v # csv gives strings
264
287
 
@@ -632,8 +655,8 @@ class Pst:
632
655
  'tied_to_fixed', 'Convert parameters tied to fixed parameters to fixed', fixed))
633
656
 
634
657
  prior_rows = []
658
+ adjustable = set(self.par.loc[self.par['PARTRANS'].isin(ADJUSTABLE), 'PARNME'])
635
659
  if self.nprior:
636
- adjustable = set(self.par.loc[self.par['PARTRANS'].isin(ADJUSTABLE), 'PARNME'])
637
660
  for row in self.prior.itertuples():
638
661
  refs = equation_params(str(row.EQ))
639
662
  if any(name not in adjustable for name in refs):
@@ -644,6 +667,17 @@ class Pst:
644
667
  warnings.simplefilter('always')
645
668
  expected = self._auto_prior()
646
669
  report.warnings.extend(Finding('warning', 'prior information', str(w.message)) for w in caught)
670
+ # _build_prior drops a PRIOR that names a fixed, tied or missing parameter, so
671
+ # compare against what it would keep, or normalize() could never satisfy this check
672
+ buildable = expected['EQ'].map(
673
+ lambda eq: all(p in adjustable for p in equation_params(eq))).astype(bool)
674
+ if (~buildable).any():
675
+ skipped = expected.loc[~buildable, 'PINME'].tolist()
676
+ report.warnings.append(Finding(
677
+ 'warning', 'prior information',
678
+ f'{len(skipped)} PRIOR entries name a fixed, tied or missing parameter and '
679
+ f'get no equation: {skipped[:5]}'))
680
+ expected = expected[buildable]
647
681
  current = self.prior[self.prior['PINME'].isin(self._auto_labels)]
648
682
  manual = self.prior[~self.prior['PINME'].isin(self._auto_labels)]
649
683
  collisions = tuple(sorted(set(expected['PINME']) & set(manual['PINME'])))
@@ -161,11 +161,19 @@ def from_text(text, base_dir='.'):
161
161
  counts = {k: ctl.get(k) for k in COMPUTED}
162
162
  pst.use_svd = 'singular value decomposition' in sections or any(
163
163
  k in ctl for k in ('svdmode', 'maxsing', 'eigthresh'))
164
+ extras = [(CONTROL.name, ctl.pop('_extra', {}))]
164
165
  for name in list(sections):
165
166
  sec = SECTIONS.get(name)
166
167
  if sec is not None:
167
- ctl.update(sec.parse(sections.pop(name)))
168
+ values = sec.parse(sections.pop(name))
169
+ extras.append((sec.name, values.pop('_extra', {})))
170
+ ctl.update(values)
168
171
  pst.control = {k: v for k, v in ctl.items() if k not in COMPUTED and not k.startswith('_')}
172
+ for section, lines in extras:
173
+ for n, tokens in lines.items():
174
+ for key, value in tokens:
175
+ pst.control[key] = value
176
+ pst.control_line[key] = (section, n)
169
177
 
170
178
  def take(section):
171
179
  """Body of a classic section, or the table of its external variant (None if absent)."""