sfincstools 0.1.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.
- sfincstools-0.1.0/CHANGELOG.md +7 -0
- sfincstools-0.1.0/LICENSE +21 -0
- sfincstools-0.1.0/MANIFEST.in +4 -0
- sfincstools-0.1.0/PKG-INFO +63 -0
- sfincstools-0.1.0/README.md +46 -0
- sfincstools-0.1.0/pyproject.toml +33 -0
- sfincstools-0.1.0/requirements.txt +3 -0
- sfincstools-0.1.0/setup.cfg +4 -0
- sfincstools-0.1.0/sfincstools/__init__.py +7 -0
- sfincstools-0.1.0/sfincstools/sfcfg.py +633 -0
- sfincstools-0.1.0/sfincstools/sfpgs.py +146 -0
- sfincstools-0.1.0/sfincstools.egg-info/PKG-INFO +63 -0
- sfincstools-0.1.0/sfincstools.egg-info/SOURCES.txt +15 -0
- sfincstools-0.1.0/sfincstools.egg-info/dependency_links.txt +1 -0
- sfincstools-0.1.0/sfincstools.egg-info/entry_points.txt +3 -0
- sfincstools-0.1.0/sfincstools.egg-info/requires.txt +3 -0
- sfincstools-0.1.0/sfincstools.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- First release of the `sfincstools` package, with two command-line tools:
|
|
6
|
+
- Added **sfpgs** (from `prototype/check_sfincs_progress.py`): checks the progress of a SFINCS run. Reads `sfincs.log` / `sfincs_log.txt` (percent complete, SFINCS's remaining-time estimate, errors, warnings, final summary) and the last time stamp in `sfincs_map.nc` / `sfincs_his.nc`, so the model time reached is shown too. Safe to run while SFINCS is running (output files are opened read-only, a locked file is skipped). `--watch SECONDS` refreshes until the run finishes or fails; `--stall MINUTES` (default 10) flags a run whose log has not changed for that long. Python API: `main(argv)`, `report()`, `read_log()`, `read_inp()`.
|
|
7
|
+
- Added **sfcfg** (from `prototype/show_sfincs_config.py`): displays the configuration of a SFINCS model. Lists `sfincs.inp` grouped by topic (Grid, Time, Numerics, Physics, Output, Other) with derived values (cells, grid size and extent, run length, number of map / his frames), every referenced input file (present / missing, size), and a summary of each file: active cells and mask values, subgrid table, bed level, infiltration, Manning, boundary points and `bzs` range, discharge points and `dis` range, drainage structures per type, weirs / thin dams, observation points, uniform precipitation total, and NetCDF forcing (dimensions, time range, variable min / mean / max, with a warning when it does not cover `tstart` .. `tstop`). `--plot` shows a map (bed level or mask, boundary, discharges, drainage, weirs, obs) with the forcing time series; `--save FILE.png` writes it to a file. Unlike the prototype, the precipitation total also works with NumPy 2 (`np.trapezoid`). Python API: `main(argv)`, `read_inp()`, `show_inp()`, `show_files()`, `show_details()`, `plot()`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chih-Hung Hsu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sfincstools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line tools for inspecting and monitoring SFINCS models
|
|
5
|
+
Author-email: aaronchh <aaronhsu219@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AaronOET/sfincstools
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy>=1.20.0
|
|
14
|
+
Requires-Dist: netCDF4>=1.5.0
|
|
15
|
+
Requires-Dist: matplotlib>=3.4.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# sfincstools
|
|
19
|
+
|
|
20
|
+
Command-line tools for inspecting and monitoring [SFINCS](https://sfincs.readthedocs.io) models.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
### `sfpgs` — check the progress of a SFINCS run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
sfpgs [MODEL_DIR] [--watch SECONDS] [--stall MINUTES]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `MODEL_DIR` – folder with `sfincs.inp` (default: current folder)
|
|
37
|
+
- `--watch` – refresh every SECONDS until the run ends (default: report once)
|
|
38
|
+
- `--stall` – warn if the log has not changed for this many minutes (default 10)
|
|
39
|
+
|
|
40
|
+
Reads `sfincs.log` / `sfincs_log.txt` (percent complete, remaining time, errors, summary)
|
|
41
|
+
and the last time stamp in `sfincs_map.nc` / `sfincs_his.nc`. Safe to run while SFINCS is running.
|
|
42
|
+
|
|
43
|
+
### `sfcfg` — display the configuration of a SFINCS model
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sfcfg [MODEL_DIR] [--plot] [--save FILE.png]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `MODEL_DIR` – folder with `sfincs.inp` (default: current folder)
|
|
50
|
+
- `--plot` – also show a map (bed level, mask, boundary, drainage, weirs, obs) and the forcing time series
|
|
51
|
+
- `--save` – write the figure to a file instead of opening a window
|
|
52
|
+
|
|
53
|
+
Reports `sfincs.inp` grouped by topic with derived values, the presence/size of every
|
|
54
|
+
referenced input file, and a short summary of each file's contents.
|
|
55
|
+
|
|
56
|
+
## Python usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sfincstools import sfpgs, sfcfg
|
|
60
|
+
|
|
61
|
+
sfpgs.main(["path/to/model"])
|
|
62
|
+
sfcfg.main(["path/to/model", "--save", "model.png"])
|
|
63
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# sfincstools
|
|
2
|
+
|
|
3
|
+
Command-line tools for inspecting and monitoring [SFINCS](https://sfincs.readthedocs.io) models.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
### `sfpgs` — check the progress of a SFINCS run
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
sfpgs [MODEL_DIR] [--watch SECONDS] [--stall MINUTES]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- `MODEL_DIR` – folder with `sfincs.inp` (default: current folder)
|
|
20
|
+
- `--watch` – refresh every SECONDS until the run ends (default: report once)
|
|
21
|
+
- `--stall` – warn if the log has not changed for this many minutes (default 10)
|
|
22
|
+
|
|
23
|
+
Reads `sfincs.log` / `sfincs_log.txt` (percent complete, remaining time, errors, summary)
|
|
24
|
+
and the last time stamp in `sfincs_map.nc` / `sfincs_his.nc`. Safe to run while SFINCS is running.
|
|
25
|
+
|
|
26
|
+
### `sfcfg` — display the configuration of a SFINCS model
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
sfcfg [MODEL_DIR] [--plot] [--save FILE.png]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `MODEL_DIR` – folder with `sfincs.inp` (default: current folder)
|
|
33
|
+
- `--plot` – also show a map (bed level, mask, boundary, drainage, weirs, obs) and the forcing time series
|
|
34
|
+
- `--save` – write the figure to a file instead of opening a window
|
|
35
|
+
|
|
36
|
+
Reports `sfincs.inp` grouped by topic with derived values, the presence/size of every
|
|
37
|
+
referenced input file, and a short summary of each file's contents.
|
|
38
|
+
|
|
39
|
+
## Python usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from sfincstools import sfpgs, sfcfg
|
|
43
|
+
|
|
44
|
+
sfpgs.main(["path/to/model"])
|
|
45
|
+
sfcfg.main(["path/to/model", "--save", "model.png"])
|
|
46
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sfincstools"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "aaronchh", email = "aaronhsu219@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Command-line tools for inspecting and monitoring SFINCS models"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
requires-python = ">=3.8"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy>=1.20.0",
|
|
17
|
+
"netCDF4>=1.5.0",
|
|
18
|
+
"matplotlib>=3.4.0",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
include = ["sfincstools*"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/AaronOET/sfincstools"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
sfpgs = "sfincstools.sfpgs:main" # Check the progress of a SFINCS run
|
|
33
|
+
sfcfg = "sfincstools.sfcfg:main" # Display the configuration of a SFINCS model
|
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Display the configuration of a SFINCS model.
|
|
3
|
+
|
|
4
|
+
sfcfg [MODEL_DIR] [--plot] [--save FILE.png]
|
|
5
|
+
|
|
6
|
+
MODEL_DIR folder with sfincs.inp (default: current folder)
|
|
7
|
+
--plot also show a map (bed level, mask, boundary, drainage, weirs, obs)
|
|
8
|
+
and the forcing time series
|
|
9
|
+
--save write the figure to a file instead of opening a window
|
|
10
|
+
|
|
11
|
+
Reports
|
|
12
|
+
- sfincs.inp, grouped by topic, with derived values (grid extent, duration, ...)
|
|
13
|
+
- every referenced input file: present / missing, size
|
|
14
|
+
- a short summary of each file: active cells, boundary points, water-level
|
|
15
|
+
forcing range, pumps/culverts, weirs, discharge points, NetCDF forcing
|
|
16
|
+
(variables, time range, totals), subgrid table, infiltration
|
|
17
|
+
"""
|
|
18
|
+
import os, sys, argparse
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
os.environ.setdefault('HDF5_USE_FILE_LOCKING', 'FALSE')
|
|
24
|
+
|
|
25
|
+
_trapz = getattr(np, 'trapezoid', None) or np.trapz # np.trapz was renamed in NumPy 2.0
|
|
26
|
+
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
# sfincs.inp keywords grouped by topic (anything not listed ends up in "Other")
|
|
29
|
+
GROUPS = [
|
|
30
|
+
('Grid', ['mmax', 'nmax', 'dx', 'dy', 'x0', 'y0', 'rotation', 'epsg', 'crsgeo',
|
|
31
|
+
'latitude', 'qtrfile']),
|
|
32
|
+
('Time', ['tref', 'tstart', 'tstop', 'tspinup', 'dtmax', 'dtmin', 'dtout', 'dthisout',
|
|
33
|
+
'dtmaxout', 'dtrstout', 'trstout', 'dtwnd', 't0out', 't1out']),
|
|
34
|
+
('Numerics', ['alpha', 'theta', 'huthresh', 'advection', 'viscosity', 'nuvisc',
|
|
35
|
+
'btfilter', 'stopdepth', 'baro', 'coriolis', 'slopelim', 'uvlim',
|
|
36
|
+
'nonh', 'wiggle_suppression', 'dtmin']),
|
|
37
|
+
('Physics', ['rgh_lev_land', 'manning', 'manning_land', 'manning_sea', 'zsini',
|
|
38
|
+
'rhoa', 'rhow', 'pavbnd', 'gapres', 'cdnrb', 'cdwnd', 'cdval',
|
|
39
|
+
'qinf', 'storecumprcp', 'utmzone']),
|
|
40
|
+
('Output', ['inputformat', 'outputformat', 'storevel', 'storevelmax', 'storetwet',
|
|
41
|
+
'storehsubgrid', 'storeqdrain', 'storezvolume', 'storemeteo',
|
|
42
|
+
'storemaxwind', 'storefw', 'storewavdir', 'outputtype_map', 'outputtype_his']),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# descriptions of the file keywords
|
|
46
|
+
FILEKEYS = {
|
|
47
|
+
'depfile': 'bed level (regular grid)',
|
|
48
|
+
'mskfile': 'active-cell mask',
|
|
49
|
+
'indexfile': 'active-cell index',
|
|
50
|
+
'sbgfile': 'subgrid tables',
|
|
51
|
+
'manningfile': 'Manning roughness',
|
|
52
|
+
'qinffile': 'constant infiltration',
|
|
53
|
+
'scsfile': 'SCS curve number',
|
|
54
|
+
'smaxfile': 'SCS max. storage',
|
|
55
|
+
'sefffile': 'SCS effective storage',
|
|
56
|
+
'f0file': 'Horton f0', 'fcfile': 'Horton fc', 'kdfile': 'Horton kd',
|
|
57
|
+
'ksfile': 'Green-Ampt ks', 'psifile': 'Green-Ampt psi', 'sigmafile': 'Green-Ampt sigma',
|
|
58
|
+
'bndfile': 'water-level boundary points',
|
|
59
|
+
'bzsfile': 'water-level boundary time series',
|
|
60
|
+
'bzifile': 'infragravity boundary time series',
|
|
61
|
+
'bwvfile': 'wave boundary points',
|
|
62
|
+
'srcfile': 'discharge points',
|
|
63
|
+
'disfile': 'discharge time series',
|
|
64
|
+
'drnfile': 'drainage structures (pumps / culverts)',
|
|
65
|
+
'weirfile': 'weirs / levees',
|
|
66
|
+
'thdfile': 'thin dams',
|
|
67
|
+
'obsfile': 'observation points',
|
|
68
|
+
'crsfile': 'cross sections',
|
|
69
|
+
'precfile': 'precipitation time series (uniform)',
|
|
70
|
+
'amprfile': 'precipitation (ascii grid)',
|
|
71
|
+
'netamprfile': 'precipitation (NetCDF grid)',
|
|
72
|
+
'netampfile': 'air pressure (NetCDF grid)',
|
|
73
|
+
'netamuamvfile': 'wind (NetCDF grid)',
|
|
74
|
+
'netbndbzsbzifile': 'water-level boundary (NetCDF)',
|
|
75
|
+
'netsrcdisfile': 'discharges (NetCDF)',
|
|
76
|
+
'spwfile': 'spiderweb (cyclone) wind',
|
|
77
|
+
'wndfile': 'wind time series (uniform)',
|
|
78
|
+
'amufile': 'wind u (ascii grid)', 'amvfile': 'wind v (ascii grid)',
|
|
79
|
+
'ampfile': 'air pressure (ascii grid)',
|
|
80
|
+
'rstfile': 'restart file',
|
|
81
|
+
'inifile': 'initial water level',
|
|
82
|
+
'ncinifile': 'initial conditions (NetCDF)',
|
|
83
|
+
'wfpfile': 'wave forcing points', 'whifile': 'wave height', 'wtifile': 'wave period',
|
|
84
|
+
'wstfile': 'wave set-up',
|
|
85
|
+
'qtrfile': 'quadtree grid',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
INT_KEYS = {'mmax', 'nmax', 'epsg', 'advection', 'viscosity', 'baro', 'pavbnd', 'crsgeo',
|
|
89
|
+
'cdnrb', 'storecumprcp', 'rotation'}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
def read_inp(model_dir):
|
|
94
|
+
inp = {}
|
|
95
|
+
with open(os.path.join(model_dir, 'sfincs.inp')) as f:
|
|
96
|
+
for line in f:
|
|
97
|
+
line = line.split('!')[0]
|
|
98
|
+
if '=' in line:
|
|
99
|
+
k, v = line.split('=', 1)
|
|
100
|
+
inp[k.strip().lower()] = v.strip()
|
|
101
|
+
return inp
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fnum(inp, key, default=None):
|
|
105
|
+
try:
|
|
106
|
+
return float(inp[key].split()[0])
|
|
107
|
+
except (KeyError, ValueError, IndexError):
|
|
108
|
+
return default
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ptime(s):
|
|
112
|
+
try:
|
|
113
|
+
return datetime.strptime(s.strip(), '%Y%m%d %H%M%S')
|
|
114
|
+
except Exception:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def human_size(n):
|
|
119
|
+
for unit in ('B', 'kB', 'MB', 'GB'):
|
|
120
|
+
if n < 1024 or unit == 'GB':
|
|
121
|
+
return f'{n:.0f} {unit}' if unit == 'B' else f'{n:.1f} {unit}'
|
|
122
|
+
n /= 1024
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def human_dur(seconds):
|
|
126
|
+
"""3725 -> '1 h 2 min 5 s', 259200 -> '3 d'"""
|
|
127
|
+
seconds = float(seconds)
|
|
128
|
+
sign, s = ('-' if seconds < 0 else ''), abs(seconds)
|
|
129
|
+
parts = []
|
|
130
|
+
for unit, size in (('d', 86400), ('h', 3600), ('min', 60)):
|
|
131
|
+
q, s = divmod(s, size)
|
|
132
|
+
if q:
|
|
133
|
+
parts.append(f'{int(q)} {unit}')
|
|
134
|
+
if s or not parts:
|
|
135
|
+
parts.append(f'{s:g} s')
|
|
136
|
+
return sign + ' '.join(parts)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def human_time(t):
|
|
140
|
+
return f'{t:%Y-%m-%d %H:%M:%S} ({t:%a})'
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def header(title):
|
|
144
|
+
print()
|
|
145
|
+
print(title)
|
|
146
|
+
print('-' * len(title))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def row(key, val, note=''):
|
|
150
|
+
note = f' # {note}' if note else ''
|
|
151
|
+
print(f' {key:<18} {val}{note}')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------- #
|
|
155
|
+
# grid helpers
|
|
156
|
+
def grid_info(inp):
|
|
157
|
+
mmax, nmax = int(fnum(inp, 'mmax', 0)), int(fnum(inp, 'nmax', 0))
|
|
158
|
+
dx, dy = fnum(inp, 'dx', 0), fnum(inp, 'dy', 0)
|
|
159
|
+
x0, y0 = fnum(inp, 'x0', 0), fnum(inp, 'y0', 0)
|
|
160
|
+
rot = np.deg2rad(fnum(inp, 'rotation', 0))
|
|
161
|
+
return mmax, nmax, dx, dy, x0, y0, rot
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cell_xy(inp, m, n):
|
|
165
|
+
"""cell-centre coordinates for 0-based column m and row n"""
|
|
166
|
+
mmax, nmax, dx, dy, x0, y0, rot = grid_info(inp)
|
|
167
|
+
xl, yl = (m + 0.5) * dx, (n + 0.5) * dy
|
|
168
|
+
return x0 + xl * np.cos(rot) - yl * np.sin(rot), y0 + xl * np.sin(rot) + yl * np.cos(rot)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def grid_corners(inp):
|
|
172
|
+
mmax, nmax, dx, dy, x0, y0, rot = grid_info(inp)
|
|
173
|
+
L, W = mmax * dx, nmax * dy
|
|
174
|
+
pts = np.array([[0, 0], [L, 0], [L, W], [0, W], [0, 0]], float)
|
|
175
|
+
x = x0 + pts[:, 0] * np.cos(rot) - pts[:, 1] * np.sin(rot)
|
|
176
|
+
y = y0 + pts[:, 0] * np.sin(rot) + pts[:, 1] * np.cos(rot)
|
|
177
|
+
return x, y
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def read_index(model_dir, inp):
|
|
181
|
+
"""0-based linear indices (column-major: n fastest) of the active cells, or None"""
|
|
182
|
+
f = os.path.join(model_dir, inp.get('indexfile', 'sfincs.ind'))
|
|
183
|
+
if not os.path.exists(f) or inp.get('inputformat', 'bin') != 'bin':
|
|
184
|
+
return None
|
|
185
|
+
raw = np.fromfile(f, dtype=np.int32)
|
|
186
|
+
return raw[1:1 + raw[0]] - 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def active_to_grid(inp, ind, values, fill=np.nan):
|
|
190
|
+
"""scatter values on active cells back to an (nmax, mmax) array"""
|
|
191
|
+
mmax, nmax = grid_info(inp)[:2]
|
|
192
|
+
a = np.full(mmax * nmax, fill, dtype=float)
|
|
193
|
+
a[ind] = values
|
|
194
|
+
return a.reshape(mmax, nmax).T
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_active_bin(model_dir, inp, key, dtype):
|
|
198
|
+
if key not in inp:
|
|
199
|
+
return None
|
|
200
|
+
f = os.path.join(model_dir, inp[key])
|
|
201
|
+
if not os.path.exists(f):
|
|
202
|
+
return None
|
|
203
|
+
return np.fromfile(f, dtype=dtype)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------------------- #
|
|
207
|
+
# readers for the ascii input files
|
|
208
|
+
def read_xy(path):
|
|
209
|
+
pts = []
|
|
210
|
+
with open(path) as f:
|
|
211
|
+
for line in f:
|
|
212
|
+
p = line.split()
|
|
213
|
+
if len(p) >= 2:
|
|
214
|
+
try:
|
|
215
|
+
pts.append((float(p[0]), float(p[1]), ' '.join(p[2:])))
|
|
216
|
+
except ValueError:
|
|
217
|
+
pass
|
|
218
|
+
return pts
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_timeseries(path):
|
|
222
|
+
a = np.loadtxt(path, ndmin=2)
|
|
223
|
+
return a[:, 0], a[:, 1:]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def read_drn(path):
|
|
227
|
+
rows = []
|
|
228
|
+
with open(path) as f:
|
|
229
|
+
for line in f:
|
|
230
|
+
p = line.split()
|
|
231
|
+
if len(p) >= 6:
|
|
232
|
+
rows.append([float(v) for v in p[:6]])
|
|
233
|
+
return np.array(rows).reshape(-1, 6)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def read_polylines(path, ncol_min=2):
|
|
237
|
+
"""tekal-style blocks: name / nrows ncols / rows"""
|
|
238
|
+
lines, out = open(path).read().splitlines(), []
|
|
239
|
+
i = 0
|
|
240
|
+
while i < len(lines):
|
|
241
|
+
name = lines[i].strip()
|
|
242
|
+
if not name:
|
|
243
|
+
i += 1
|
|
244
|
+
continue
|
|
245
|
+
nr, nc = (int(v) for v in lines[i + 1].split()[:2])
|
|
246
|
+
data = np.array([[float(v) for v in lines[i + 2 + k].split()[:nc]] for k in range(nr)])
|
|
247
|
+
out.append((name, data))
|
|
248
|
+
i += 2 + nr
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def nc_summary(path, tref=None):
|
|
253
|
+
import netCDF4 as nc
|
|
254
|
+
with nc.Dataset(path) as d:
|
|
255
|
+
dims = ', '.join(f'{k}={len(v)}' for k, v in d.dimensions.items())
|
|
256
|
+
print(f' dims : {dims}')
|
|
257
|
+
tinfo = None
|
|
258
|
+
if 'time' in d.variables and len(d['time']) > 0:
|
|
259
|
+
t = d['time']
|
|
260
|
+
units = getattr(t, 'units', None)
|
|
261
|
+
if units:
|
|
262
|
+
try:
|
|
263
|
+
t0 = nc.num2date(t[0], units, only_use_cftime_datetimes=False)
|
|
264
|
+
t1 = nc.num2date(t[-1], units, only_use_cftime_datetimes=False)
|
|
265
|
+
dt = (t[1] - t[0]) if len(t) > 1 else 0
|
|
266
|
+
print(f' time : {t0:%Y-%m-%d %H:%M} -> {t1:%Y-%m-%d %H:%M} '
|
|
267
|
+
f'({len(t)} steps, step {dt:g} {units.split()[0]})')
|
|
268
|
+
tinfo = (t0, t1)
|
|
269
|
+
except Exception:
|
|
270
|
+
print(f' time : {len(t)} steps ({units})')
|
|
271
|
+
for name, v in d.variables.items():
|
|
272
|
+
if name in d.dimensions or v.ndim == 0:
|
|
273
|
+
continue
|
|
274
|
+
units = getattr(v, 'units', '')
|
|
275
|
+
line = f' var : {name}{list(v.dimensions)} {units}'.rstrip()
|
|
276
|
+
if v.size <= 5e7 and np.issubdtype(v.dtype, np.number):
|
|
277
|
+
a = np.asarray(v[:], dtype=float)
|
|
278
|
+
if np.isfinite(a).any():
|
|
279
|
+
line += f' min {np.nanmin(a):.4g} mean {np.nanmean(a):.4g} max {np.nanmax(a):.4g}'
|
|
280
|
+
print(line)
|
|
281
|
+
return tinfo
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
TIME_LABELS = {
|
|
286
|
+
'tref': 'reference time', 'tstart': 'start', 'tstop': 'stop',
|
|
287
|
+
'tspinup': 'spin-up', 'dtmax': 'max. time step', 'dtmin': 'min. time step',
|
|
288
|
+
'dtout': 'map output every', 'dthisout': 'his output every',
|
|
289
|
+
'dtmaxout': 'max. map every', 'dtrstout': 'restart every', 'trstout': 'restart at',
|
|
290
|
+
'dtwnd': 'meteo update every', 't0out': 'output from', 't1out': 'output until',
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def show_time(inp, keys):
|
|
295
|
+
"""Time section in human-readable form; raw sfincs.inp value at the end of each line"""
|
|
296
|
+
tr, t0, t1 = (ptime(inp.get(k, '')) for k in ('tref', 'tstart', 'tstop'))
|
|
297
|
+
dur = (t1 - t0).total_seconds() if t0 and t1 else None
|
|
298
|
+
|
|
299
|
+
def line(k, text):
|
|
300
|
+
print(f' {k:<10} {TIME_LABELS.get(k, ""):<20} {text:<44} [{inp[k]}]')
|
|
301
|
+
|
|
302
|
+
for k in keys:
|
|
303
|
+
if k in ('tref', 'tstart', 'tstop', 't0out', 't1out'):
|
|
304
|
+
t = ptime(inp[k])
|
|
305
|
+
if t is None:
|
|
306
|
+
line(k, inp[k])
|
|
307
|
+
continue
|
|
308
|
+
text = human_time(t)
|
|
309
|
+
if tr and k != 'tref':
|
|
310
|
+
off = (t - tr).total_seconds()
|
|
311
|
+
text += f' = tref + {human_dur(off)}' if off else ' = tref'
|
|
312
|
+
line(k, text)
|
|
313
|
+
if k == 'tstop' and dur is not None:
|
|
314
|
+
print(f' {"":<10} {"-> run length":<20} {human_dur(dur)} ({dur / 3600:g} h)')
|
|
315
|
+
elif k == 'trstout':
|
|
316
|
+
v = fnum(inp, k, -999)
|
|
317
|
+
line(k, 'off' if v < 0 else f'tref + {human_dur(v)}'
|
|
318
|
+
+ (f' = {human_time(tr + timedelta(seconds=v))}' if tr else ''))
|
|
319
|
+
else:
|
|
320
|
+
v = fnum(inp, k, 0)
|
|
321
|
+
if v <= 0:
|
|
322
|
+
line(k, 'off')
|
|
323
|
+
continue
|
|
324
|
+
text = human_dur(v)
|
|
325
|
+
if dur and k in ('dtout', 'dthisout', 'dtmaxout', 'dtrstout'):
|
|
326
|
+
n = int(dur // v) + (1 if k in ('dtout', 'dthisout') else 0)
|
|
327
|
+
what = {'dtout': 'map frames', 'dthisout': 'his records',
|
|
328
|
+
'dtmaxout': 'max. map(s)', 'dtrstout': 'restart file(s)'}[k]
|
|
329
|
+
text += f' -> {n} {what}'
|
|
330
|
+
line(k, text)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def show_inp(inp):
|
|
334
|
+
used = set()
|
|
335
|
+
for title, keys in GROUPS:
|
|
336
|
+
present = [k for k in keys if k in inp and k not in used]
|
|
337
|
+
if not present:
|
|
338
|
+
continue
|
|
339
|
+
header(title)
|
|
340
|
+
if title == 'Time':
|
|
341
|
+
show_time(inp, present)
|
|
342
|
+
used.update(present)
|
|
343
|
+
continue
|
|
344
|
+
for k in present:
|
|
345
|
+
used.add(k)
|
|
346
|
+
note = ''
|
|
347
|
+
if k == 'epsg':
|
|
348
|
+
note = {'3826': 'TWD97 / TM2 zone 121', '4326': 'WGS 84', '3857': 'Web Mercator'}.get(inp[k], '')
|
|
349
|
+
if k == 'advection':
|
|
350
|
+
note = {'0': 'off', '1': '1D', '2': '2D'}.get(inp[k], '')
|
|
351
|
+
if k == 'inputformat':
|
|
352
|
+
note = 'binary msk/ind/dep' if inp[k] == 'bin' else 'ascii'
|
|
353
|
+
if k == 'outputformat':
|
|
354
|
+
note = {'net': 'NetCDF', 'bin': 'binary', 'asc': 'ascii'}.get(inp[k], '')
|
|
355
|
+
row(k, inp[k], note)
|
|
356
|
+
|
|
357
|
+
if title == 'Grid':
|
|
358
|
+
mmax, nmax, dx, dy, x0, y0, rot = grid_info(inp)
|
|
359
|
+
if mmax and nmax:
|
|
360
|
+
xs, ys = grid_corners(inp)
|
|
361
|
+
print(f' {"-> cells":<18} {mmax} x {nmax} = {mmax * nmax:,}')
|
|
362
|
+
print(f' {"-> size":<18} {mmax * dx / 1000:.2f} km x {nmax * dy / 1000:.2f} km')
|
|
363
|
+
print(f' {"-> extent x":<18} {xs.min():.1f} .. {xs.max():.1f}')
|
|
364
|
+
print(f' {"-> extent y":<18} {ys.min():.1f} .. {ys.max():.1f}')
|
|
365
|
+
|
|
366
|
+
other = [k for k in inp if k not in used and not k.endswith('file')]
|
|
367
|
+
if other:
|
|
368
|
+
header('Other')
|
|
369
|
+
for k in other:
|
|
370
|
+
row(k, inp[k])
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def show_files(model_dir, inp):
|
|
374
|
+
header('Input files')
|
|
375
|
+
keys = [k for k in inp if k.endswith('file')]
|
|
376
|
+
for k in keys:
|
|
377
|
+
f = os.path.join(model_dir, inp[k])
|
|
378
|
+
status = human_size(os.path.getsize(f)) if os.path.exists(f) else 'MISSING'
|
|
379
|
+
print(f' {k:<16} {inp[k]:<28} {status:>10} {FILEKEYS.get(k, "")}')
|
|
380
|
+
if not keys:
|
|
381
|
+
print(' (none)')
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def show_details(model_dir, inp, data):
|
|
385
|
+
header('File contents')
|
|
386
|
+
p = lambda k: os.path.join(model_dir, inp[k]) if k in inp and os.path.exists(os.path.join(model_dir, inp[k])) else None
|
|
387
|
+
mmax, nmax = grid_info(inp)[:2]
|
|
388
|
+
|
|
389
|
+
# mask / index
|
|
390
|
+
ind = read_index(model_dir, inp)
|
|
391
|
+
msk = read_active_bin(model_dir, inp, 'mskfile', np.int8)
|
|
392
|
+
if ind is not None:
|
|
393
|
+
data['ind'] = ind
|
|
394
|
+
print(f' active cells : {len(ind):,} of {mmax * nmax:,} ({100 * len(ind) / (mmax * nmax):.1f} %)')
|
|
395
|
+
if msk is not None and len(msk) == len(ind):
|
|
396
|
+
data['msk'] = msk
|
|
397
|
+
names = {1: 'active', 2: 'water-level bnd', 3: 'outflow bnd', 5: 'wave bnd'}
|
|
398
|
+
u, c = np.unique(msk, return_counts=True)
|
|
399
|
+
print(' mask values : ' + ', '.join(f'{names.get(int(a), a)} ({a}) = {b:,}' for a, b in zip(u, c)))
|
|
400
|
+
|
|
401
|
+
# subgrid
|
|
402
|
+
if p('sbgfile'):
|
|
403
|
+
try:
|
|
404
|
+
import netCDF4 as nc
|
|
405
|
+
with nc.Dataset(p('sbgfile')) as d:
|
|
406
|
+
zmin = d['z_zmin'][:].astype(float)
|
|
407
|
+
zmax = d['z_zmax'][:].astype(float)
|
|
408
|
+
nl = len(d.dimensions['levels']) if 'levels' in d.dimensions else '?'
|
|
409
|
+
print(f' subgrid : {len(zmin):,} cells, {nl} levels, '
|
|
410
|
+
f'zmin {np.nanmin(zmin):.2f} .. {np.nanmax(zmin):.2f} m, '
|
|
411
|
+
f'zmax {np.nanmin(zmax):.2f} .. {np.nanmax(zmax):.2f} m')
|
|
412
|
+
data['zb'] = zmin
|
|
413
|
+
except Exception as e:
|
|
414
|
+
print(f' subgrid : could not read ({e})')
|
|
415
|
+
elif p('depfile') and ind is not None:
|
|
416
|
+
dep = np.fromfile(p('depfile'), dtype=np.float32)
|
|
417
|
+
print(f' bed level : {np.nanmin(dep):.2f} .. {np.nanmax(dep):.2f} m')
|
|
418
|
+
data['zb'] = dep
|
|
419
|
+
|
|
420
|
+
# infiltration
|
|
421
|
+
if p('qinffile'):
|
|
422
|
+
q = np.fromfile(p('qinffile'), dtype=np.float32)
|
|
423
|
+
print(f' infiltration : {q.min():.3g} .. {q.max():.3g} mm/h '
|
|
424
|
+
f'(mean {q.mean():.3g}, {np.count_nonzero(q):,} cells > 0)')
|
|
425
|
+
elif 'qinf' in inp:
|
|
426
|
+
print(f' infiltration : uniform {inp["qinf"]} mm/h')
|
|
427
|
+
|
|
428
|
+
# roughness
|
|
429
|
+
if p('manningfile'):
|
|
430
|
+
n = np.fromfile(p('manningfile'), dtype=np.float32)
|
|
431
|
+
print(f' manning : {n.min():.3g} .. {n.max():.3g}')
|
|
432
|
+
|
|
433
|
+
# boundary
|
|
434
|
+
if p('bndfile'):
|
|
435
|
+
bnd = read_xy(p('bndfile'))
|
|
436
|
+
data['bnd'] = np.array([b[:2] for b in bnd])
|
|
437
|
+
print(f' boundary points : {len(bnd)}')
|
|
438
|
+
if p('bzsfile'):
|
|
439
|
+
t, v = read_timeseries(p('bzsfile'))
|
|
440
|
+
data['bzs'] = (t, v)
|
|
441
|
+
print(f' bzs time series : {len(t)} times, t = {t[0]:g} .. {t[-1]:g} s, '
|
|
442
|
+
f'{v.shape[1]} points, zs {v.min():.2f} .. {v.max():.2f} m')
|
|
443
|
+
if 'bnd' in data and v.shape[1] != len(data['bnd']):
|
|
444
|
+
print(f' !! bzs has {v.shape[1]} columns but bnd has {len(data["bnd"])} points')
|
|
445
|
+
|
|
446
|
+
# discharges
|
|
447
|
+
if p('srcfile'):
|
|
448
|
+
src = read_xy(p('srcfile'))
|
|
449
|
+
data['src'] = np.array([s[:2] for s in src])
|
|
450
|
+
print(f' discharge points : {len(src)}')
|
|
451
|
+
if p('disfile'):
|
|
452
|
+
t, v = read_timeseries(p('disfile'))
|
|
453
|
+
data['dis'] = (t, v)
|
|
454
|
+
print(f' dis time series : {len(t)} times, {v.shape[1]} points, '
|
|
455
|
+
f'Q {v.min():.2f} .. {v.max():.2f} m3/s')
|
|
456
|
+
|
|
457
|
+
# drainage structures
|
|
458
|
+
if p('drnfile'):
|
|
459
|
+
d = read_drn(p('drnfile'))
|
|
460
|
+
data['drn'] = d
|
|
461
|
+
kinds = {1: 'pump', 2: 'culvert', 3: 'check-valve culvert'}
|
|
462
|
+
print(f' drainage struct. : {len(d)}')
|
|
463
|
+
for typ in np.unique(d[:, 4]).astype(int):
|
|
464
|
+
sel = d[d[:, 4] == typ]
|
|
465
|
+
print(f' {kinds.get(typ, f"type {typ}"):<10}: {len(sel):3d}, '
|
|
466
|
+
f'capacity {sel[:, 5].min():g} .. {sel[:, 5].max():g} m3/s, total {sel[:, 5].sum():g} m3/s')
|
|
467
|
+
|
|
468
|
+
# weirs / thin dams
|
|
469
|
+
for key, label in (('weirfile', 'weirs'), ('thdfile', 'thin dams')):
|
|
470
|
+
if p(key):
|
|
471
|
+
try:
|
|
472
|
+
lines = read_polylines(p(key))
|
|
473
|
+
data[key] = lines
|
|
474
|
+
npts = sum(len(l[1]) for l in lines)
|
|
475
|
+
length = sum(np.hypot(*np.diff(l[1][:, :2], axis=0).T).sum() for l in lines)
|
|
476
|
+
msg = f' {label:<18}: {len(lines)} polylines, {npts} points, {length / 1000:.2f} km'
|
|
477
|
+
if key == 'weirfile' and lines and lines[0][1].shape[1] >= 3:
|
|
478
|
+
z = np.concatenate([l[1][:, 2] for l in lines])
|
|
479
|
+
msg += f', crest {z.min():.2f} .. {z.max():.2f} m'
|
|
480
|
+
print(msg)
|
|
481
|
+
except Exception as e:
|
|
482
|
+
print(f' {label:<18}: could not read ({e})')
|
|
483
|
+
|
|
484
|
+
# observation points
|
|
485
|
+
if p('obsfile'):
|
|
486
|
+
obs = read_xy(p('obsfile'))
|
|
487
|
+
data['obs'] = obs
|
|
488
|
+
names = ', '.join(o[2].strip('"\'') or f'#{i + 1}' for i, o in enumerate(obs[:8]))
|
|
489
|
+
print(f' observation pts : {len(obs)} ({names}{" ..." if len(obs) > 8 else ""})')
|
|
490
|
+
|
|
491
|
+
# uniform precipitation
|
|
492
|
+
if p('precfile'):
|
|
493
|
+
t, v = read_timeseries(p('precfile'))
|
|
494
|
+
data['prec'] = (t, v[:, 0])
|
|
495
|
+
total = _trapz(v[:, 0], t) / 3600
|
|
496
|
+
print(f' precipitation : {len(t)} times, max {v.max():.2f} mm/h, total ~{total:.1f} mm')
|
|
497
|
+
|
|
498
|
+
# NetCDF forcing
|
|
499
|
+
t0, t1 = ptime(inp.get('tstart', '')), ptime(inp.get('tstop', ''))
|
|
500
|
+
for key in [k for k in inp if k.startswith('net') and k.endswith('file')]:
|
|
501
|
+
if p(key):
|
|
502
|
+
print(f' {key} ({inp[key]}):')
|
|
503
|
+
try:
|
|
504
|
+
tinfo = nc_summary(p(key))
|
|
505
|
+
if tinfo and t0 and t1:
|
|
506
|
+
a, b = tinfo
|
|
507
|
+
a = datetime(a.year, a.month, a.day, a.hour, a.minute)
|
|
508
|
+
b = datetime(b.year, b.month, b.day, b.hour, b.minute)
|
|
509
|
+
if a > t0 or b < t1:
|
|
510
|
+
print(f' !! forcing does not cover tstart..tstop ({t0} .. {t1})')
|
|
511
|
+
if key == 'netamprfile':
|
|
512
|
+
data['netampr'] = p(key)
|
|
513
|
+
except Exception as e:
|
|
514
|
+
print(f' could not read ({e})')
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# --------------------------------------------------------------------------- #
|
|
518
|
+
def plot(model_dir, inp, data, save=None):
|
|
519
|
+
import matplotlib.pyplot as plt
|
|
520
|
+
|
|
521
|
+
series = [k for k in ('bzs', 'dis', 'prec', 'netampr') if k in data]
|
|
522
|
+
fig = plt.figure(figsize=(14, 8))
|
|
523
|
+
ax = fig.add_axes([0.05, 0.08, 0.55 if series else 0.9, 0.85])
|
|
524
|
+
|
|
525
|
+
if 'ind' in data:
|
|
526
|
+
ind = data['ind']
|
|
527
|
+
if 'zb' in data and len(data['zb']) == len(ind):
|
|
528
|
+
z = active_to_grid(inp, ind, data['zb'])
|
|
529
|
+
label, cmap = 'bed level zmin (m)', 'terrain'
|
|
530
|
+
else:
|
|
531
|
+
z = active_to_grid(inp, ind, data.get('msk', np.ones(len(ind))))
|
|
532
|
+
label, cmap = 'mask', 'viridis'
|
|
533
|
+
mmax, nmax, dx, dy, x0, y0, rot = grid_info(inp)
|
|
534
|
+
mm, nn = np.meshgrid(np.arange(mmax), np.arange(nmax))
|
|
535
|
+
X, Y = cell_xy(inp, mm, nn)
|
|
536
|
+
pc = ax.pcolormesh(X, Y, z, cmap=cmap, shading='nearest')
|
|
537
|
+
fig.colorbar(pc, ax=ax, shrink=0.7, label=label)
|
|
538
|
+
if 'msk' in data:
|
|
539
|
+
bndcells = data['msk'] > 1
|
|
540
|
+
if bndcells.any():
|
|
541
|
+
n, m = ind[bndcells] % nmax, ind[bndcells] // nmax
|
|
542
|
+
bx, by = cell_xy(inp, m, n)
|
|
543
|
+
ax.plot(bx, by, 's', ms=1.5, color='tab:blue', label='mask bnd cells')
|
|
544
|
+
|
|
545
|
+
xs, ys = grid_corners(inp)
|
|
546
|
+
ax.plot(xs, ys, 'k-', lw=0.8, label='grid')
|
|
547
|
+
if 'bnd' in data:
|
|
548
|
+
ax.plot(*data['bnd'].T, 'o', color='navy', ms=5, label='bnd points')
|
|
549
|
+
if 'src' in data:
|
|
550
|
+
ax.plot(*data['src'].T, 'v', color='cyan', mec='k', ms=6, label='discharge')
|
|
551
|
+
if 'drn' in data:
|
|
552
|
+
d = data['drn']
|
|
553
|
+
for r in d:
|
|
554
|
+
ax.annotate('', xy=r[2:4], xytext=r[0:2],
|
|
555
|
+
arrowprops=dict(arrowstyle='->', color='red', lw=1.2))
|
|
556
|
+
ax.plot(d[:, 0], d[:, 1], '.', color='red', ms=4, label='drainage (sink -> source)')
|
|
557
|
+
for key, col, lab in (('weirfile', 'magenta', 'weir'), ('thdfile', 'k', 'thin dam')):
|
|
558
|
+
for i, (name, a) in enumerate(data.get(key, [])):
|
|
559
|
+
ax.plot(a[:, 0], a[:, 1], '-', color=col, lw=1.5, label=lab if i == 0 else None)
|
|
560
|
+
for i, o in enumerate(data.get('obs', [])):
|
|
561
|
+
ax.plot(o[0], o[1], '^', color='orange', mec='k', ms=7, label='obs' if i == 0 else None)
|
|
562
|
+
ax.annotate(o[2].strip('"\''), (o[0], o[1]), fontsize=7, xytext=(3, 3), textcoords='offset points')
|
|
563
|
+
|
|
564
|
+
ax.set_aspect('equal')
|
|
565
|
+
ax.set_title(f'SFINCS model: {os.path.basename(os.path.abspath(model_dir))} (EPSG:{inp.get("epsg", "?")})')
|
|
566
|
+
ax.legend(loc='upper left', fontsize=8)
|
|
567
|
+
ax.ticklabel_format(useOffset=False, style='plain')
|
|
568
|
+
|
|
569
|
+
# forcing panels
|
|
570
|
+
if series:
|
|
571
|
+
h = 0.85 / len(series)
|
|
572
|
+
tr = ptime(inp.get('tref', ''))
|
|
573
|
+
t0, t1 = ptime(inp.get('tstart', '')), ptime(inp.get('tstop', ''))
|
|
574
|
+
for i, k in enumerate(series):
|
|
575
|
+
a = fig.add_axes([0.68, 0.08 + (len(series) - 1 - i) * h + 0.04, 0.3, h - 0.08])
|
|
576
|
+
if k in ('bzs', 'dis', 'prec'):
|
|
577
|
+
t, v = data[k]
|
|
578
|
+
tt = [tr + timedelta(seconds=float(s)) for s in t] if tr else t
|
|
579
|
+
a.plot(tt, v)
|
|
580
|
+
a.set_ylabel({'bzs': 'zs (m)', 'dis': 'Q (m3/s)', 'prec': 'P (mm/h)'}[k])
|
|
581
|
+
else:
|
|
582
|
+
import netCDF4 as nc
|
|
583
|
+
with nc.Dataset(data[k]) as d:
|
|
584
|
+
var = [v for v in d.variables if d[v].ndim == 3][0]
|
|
585
|
+
P = np.asarray(d[var][:], dtype=float)
|
|
586
|
+
tt = nc.num2date(d['time'][:], d['time'].units, only_use_cftime_datetimes=False)
|
|
587
|
+
a.fill_between(tt, np.nanmean(P, axis=(1, 2)), step='post', alpha=0.5, label='mean')
|
|
588
|
+
a.plot(tt, np.nanmax(P, axis=(1, 2)), drawstyle='steps-post', lw=0.8, label='max')
|
|
589
|
+
a.set_ylabel(f'{var} (mm/h)')
|
|
590
|
+
a.legend(fontsize=7)
|
|
591
|
+
if t0 and t1:
|
|
592
|
+
a.axvspan(t0, t1, color='grey', alpha=0.1)
|
|
593
|
+
a.set_title(k, fontsize=9)
|
|
594
|
+
a.tick_params(labelsize=7)
|
|
595
|
+
for lab in a.get_xticklabels():
|
|
596
|
+
lab.set_rotation(30)
|
|
597
|
+
|
|
598
|
+
if save:
|
|
599
|
+
fig.savefig(save, dpi=150)
|
|
600
|
+
print(f'\nfigure written to {save}')
|
|
601
|
+
else:
|
|
602
|
+
plt.show()
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# --------------------------------------------------------------------------- #
|
|
606
|
+
def main(argv=None):
|
|
607
|
+
ap = argparse.ArgumentParser(prog='sfcfg', description='Display the configuration of a SFINCS model.')
|
|
608
|
+
ap.add_argument('model_dir', nargs='?', default='.')
|
|
609
|
+
ap.add_argument('--plot', action='store_true', help='show a map and forcing time series')
|
|
610
|
+
ap.add_argument('--save', help='save the figure to this file (implies --plot)')
|
|
611
|
+
args = ap.parse_args(argv)
|
|
612
|
+
|
|
613
|
+
inp_path = os.path.join(args.model_dir, 'sfincs.inp')
|
|
614
|
+
if not os.path.exists(inp_path):
|
|
615
|
+
sys.exit(f'no sfincs.inp in {os.path.abspath(args.model_dir)}')
|
|
616
|
+
|
|
617
|
+
inp = read_inp(args.model_dir)
|
|
618
|
+
title = f'SFINCS model {os.path.abspath(args.model_dir)}'
|
|
619
|
+
print('=' * len(title))
|
|
620
|
+
print(title)
|
|
621
|
+
print('=' * len(title))
|
|
622
|
+
|
|
623
|
+
show_inp(inp)
|
|
624
|
+
show_files(args.model_dir, inp)
|
|
625
|
+
data = {}
|
|
626
|
+
show_details(args.model_dir, inp, data)
|
|
627
|
+
|
|
628
|
+
if args.plot or args.save:
|
|
629
|
+
plot(args.model_dir, inp, data, args.save)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
if __name__ == '__main__':
|
|
633
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Check the progress of a SFINCS run.
|
|
3
|
+
|
|
4
|
+
sfpgs [MODEL_DIR] [--watch SECONDS] [--stall MINUTES]
|
|
5
|
+
|
|
6
|
+
MODEL_DIR folder with sfincs.inp (default: current folder)
|
|
7
|
+
--watch refresh every SECONDS until the run ends (default: report once)
|
|
8
|
+
--stall warn if the log has not changed for this many minutes (default 10)
|
|
9
|
+
|
|
10
|
+
Reads
|
|
11
|
+
- sfincs.log (or sfincs_log.txt): "xx% complete, yy s remaining", errors, final summary
|
|
12
|
+
- sfincs_map.nc / sfincs_his.nc : last output time, so you also see the model time reached
|
|
13
|
+
Works while SFINCS is running (the output files are opened read-only; a locked file is simply skipped).
|
|
14
|
+
"""
|
|
15
|
+
import os, re, sys, time, argparse
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
|
|
18
|
+
os.environ.setdefault('HDF5_USE_FILE_LOCKING', 'FALSE') # allow reading NetCDF files that SFINCS has open
|
|
19
|
+
|
|
20
|
+
LOGS = ('sfincs.log', 'sfincs_log.txt')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_inp(model_dir):
|
|
24
|
+
inp = {}
|
|
25
|
+
with open(os.path.join(model_dir, 'sfincs.inp')) as f:
|
|
26
|
+
for line in f:
|
|
27
|
+
if '=' in line:
|
|
28
|
+
k, v = line.split('=', 1)
|
|
29
|
+
inp[k.strip().lower()] = v.strip()
|
|
30
|
+
return inp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_time(s):
|
|
34
|
+
return datetime.strptime(s.strip(), '%Y%m%d %H%M%S')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def last_output_time(path):
|
|
38
|
+
"""last time stamp in a SFINCS NetCDF output file (None if missing / locked)"""
|
|
39
|
+
if not os.path.exists(path):
|
|
40
|
+
return None, 0
|
|
41
|
+
try:
|
|
42
|
+
import netCDF4 as nc
|
|
43
|
+
with nc.Dataset(path) as d:
|
|
44
|
+
t = d['time']
|
|
45
|
+
if len(t) == 0:
|
|
46
|
+
return None, 0
|
|
47
|
+
last = nc.num2date(t[-1], t.units, only_use_cftime_datetimes=False)
|
|
48
|
+
return datetime(last.year, last.month, last.day, last.hour, last.minute, last.second), len(t)
|
|
49
|
+
except Exception:
|
|
50
|
+
return None, 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_log(model_dir):
|
|
54
|
+
"""newest log file -> dict with percent, remaining, status, messages"""
|
|
55
|
+
logs = [os.path.join(model_dir, f) for f in LOGS if os.path.exists(os.path.join(model_dir, f))]
|
|
56
|
+
if not logs:
|
|
57
|
+
return None
|
|
58
|
+
log = max(logs, key=os.path.getmtime)
|
|
59
|
+
with open(log, errors='ignore') as f:
|
|
60
|
+
txt = f.read()
|
|
61
|
+
r = dict(file=log, mtime=datetime.fromtimestamp(os.path.getmtime(log)), percent=None, remaining=None,
|
|
62
|
+
status='starting', errors=[], warnings=[], summary={})
|
|
63
|
+
prog = re.findall(r'(\d+)%\s*complete,\s*([\d.]+|-)\s*s remaining', txt)
|
|
64
|
+
if prog:
|
|
65
|
+
r['percent'] = int(prog[-1][0])
|
|
66
|
+
r['remaining'] = None if prog[-1][1] == '-' else float(prog[-1][1])
|
|
67
|
+
r['status'] = 'running'
|
|
68
|
+
r['errors'] = [l.strip() for l in txt.splitlines() if re.match(r'\s*error', l, re.I)]
|
|
69
|
+
r['warnings'] = sorted({l.strip() for l in txt.splitlines() if re.match(r'\s*warning', l, re.I)})
|
|
70
|
+
if 'Simulation finished' in txt or 'Closing off SFINCS' in txt:
|
|
71
|
+
r['status'] = 'finished'
|
|
72
|
+
for key, pat in (('total time', r'Total time\s*:\s*([\d.]+)'), ('average time step', r'Average time step \(s\)\s*:\s*([\d.]+)')):
|
|
73
|
+
m = re.search(pat, txt)
|
|
74
|
+
if m: r['summary'][key] = float(m.group(1))
|
|
75
|
+
if r['errors'] or re.search(r'has stopped|unstable|simulation stopped', txt, re.I):
|
|
76
|
+
r['status'] = 'error'
|
|
77
|
+
return r
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def bar(frac, width=40):
|
|
81
|
+
frac = min(max(frac, 0.0), 1.0)
|
|
82
|
+
n = int(round(frac * width))
|
|
83
|
+
return '[' + '#' * n + '-' * (width - n) + f'] {frac * 100:5.1f}%'
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def report(model_dir, stall_min):
|
|
87
|
+
inp = read_inp(model_dir)
|
|
88
|
+
tstart, tstop = parse_time(inp['tstart']), parse_time(inp['tstop'])
|
|
89
|
+
total = (tstop - tstart).total_seconds()
|
|
90
|
+
log = read_log(model_dir)
|
|
91
|
+
t_map, n_map = last_output_time(os.path.join(model_dir, 'sfincs_map.nc'))
|
|
92
|
+
t_his, n_his = last_output_time(os.path.join(model_dir, 'sfincs_his.nc'))
|
|
93
|
+
t_out = max([t for t in (t_map, t_his) if t is not None], default=None)
|
|
94
|
+
|
|
95
|
+
print(f'\n=== {os.path.abspath(model_dir)} ({datetime.now():%Y-%m-%d %H:%M:%S})')
|
|
96
|
+
print(f'model period : {tstart} -> {tstop} ({total / 3600:.1f} h)')
|
|
97
|
+
if log is None:
|
|
98
|
+
print('status : not started (no sfincs.log / sfincs_log.txt)')
|
|
99
|
+
return 'not started'
|
|
100
|
+
|
|
101
|
+
status = log['status']
|
|
102
|
+
idle = datetime.now() - log['mtime']
|
|
103
|
+
if status in ('running', 'starting') and idle > timedelta(minutes=stall_min):
|
|
104
|
+
status = f'stalled? (log unchanged for {idle.total_seconds() / 60:.0f} min)'
|
|
105
|
+
print(f'status : {status} [{os.path.basename(log["file"])}, updated {log["mtime"]:%H:%M:%S}]')
|
|
106
|
+
|
|
107
|
+
if log['status'] == 'finished':
|
|
108
|
+
print(f'progress : {bar(1.0)}')
|
|
109
|
+
for k, v in log['summary'].items():
|
|
110
|
+
print(f'{k:<13}: {v:g} s')
|
|
111
|
+
elif log['percent'] is not None:
|
|
112
|
+
rem = '' if log['remaining'] is None else f', ~{timedelta(seconds=round(log["remaining"]))} remaining (SFINCS estimate)'
|
|
113
|
+
print(f'progress : {bar(log["percent"] / 100)}{rem}')
|
|
114
|
+
if t_out is not None:
|
|
115
|
+
frac = (t_out - tstart).total_seconds() / total if total > 0 else 0
|
|
116
|
+
print(f'model time : {t_out} ({(t_out - tstart).total_seconds() / 3600:.1f} of {total / 3600:.1f} h, '
|
|
117
|
+
f'{frac * 100:.0f}%; map steps {n_map}, his steps {n_his})')
|
|
118
|
+
for e in log['errors'][:5]:
|
|
119
|
+
print(f'ERROR : {e}')
|
|
120
|
+
for w in log['warnings'][:5]:
|
|
121
|
+
print(f'warning : {w}')
|
|
122
|
+
if len(log['warnings']) > 5:
|
|
123
|
+
print(f' ... {len(log["warnings"]) - 5} more warnings in the log')
|
|
124
|
+
return log['status']
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv=None):
|
|
128
|
+
ap = argparse.ArgumentParser(prog='sfpgs', description='Check the progress of a SFINCS run')
|
|
129
|
+
ap.add_argument('model_dir', nargs='?', default='.')
|
|
130
|
+
ap.add_argument('--watch', type=float, default=0, help='refresh interval in seconds (0 = once)')
|
|
131
|
+
ap.add_argument('--stall', type=float, default=10, help='minutes without log updates before warning')
|
|
132
|
+
a = ap.parse_args(argv)
|
|
133
|
+
if not os.path.exists(os.path.join(a.model_dir, 'sfincs.inp')):
|
|
134
|
+
sys.exit(f'no sfincs.inp in {a.model_dir}')
|
|
135
|
+
while True:
|
|
136
|
+
status = report(a.model_dir, a.stall)
|
|
137
|
+
if not a.watch or status in ('finished', 'error'):
|
|
138
|
+
break
|
|
139
|
+
try:
|
|
140
|
+
time.sleep(a.watch)
|
|
141
|
+
except KeyboardInterrupt:
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == '__main__':
|
|
146
|
+
main()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sfincstools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line tools for inspecting and monitoring SFINCS models
|
|
5
|
+
Author-email: aaronchh <aaronhsu219@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AaronOET/sfincstools
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy>=1.20.0
|
|
14
|
+
Requires-Dist: netCDF4>=1.5.0
|
|
15
|
+
Requires-Dist: matplotlib>=3.4.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# sfincstools
|
|
19
|
+
|
|
20
|
+
Command-line tools for inspecting and monitoring [SFINCS](https://sfincs.readthedocs.io) models.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
### `sfpgs` — check the progress of a SFINCS run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
sfpgs [MODEL_DIR] [--watch SECONDS] [--stall MINUTES]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `MODEL_DIR` – folder with `sfincs.inp` (default: current folder)
|
|
37
|
+
- `--watch` – refresh every SECONDS until the run ends (default: report once)
|
|
38
|
+
- `--stall` – warn if the log has not changed for this many minutes (default 10)
|
|
39
|
+
|
|
40
|
+
Reads `sfincs.log` / `sfincs_log.txt` (percent complete, remaining time, errors, summary)
|
|
41
|
+
and the last time stamp in `sfincs_map.nc` / `sfincs_his.nc`. Safe to run while SFINCS is running.
|
|
42
|
+
|
|
43
|
+
### `sfcfg` — display the configuration of a SFINCS model
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sfcfg [MODEL_DIR] [--plot] [--save FILE.png]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `MODEL_DIR` – folder with `sfincs.inp` (default: current folder)
|
|
50
|
+
- `--plot` – also show a map (bed level, mask, boundary, drainage, weirs, obs) and the forcing time series
|
|
51
|
+
- `--save` – write the figure to a file instead of opening a window
|
|
52
|
+
|
|
53
|
+
Reports `sfincs.inp` grouped by topic with derived values, the presence/size of every
|
|
54
|
+
referenced input file, and a short summary of each file's contents.
|
|
55
|
+
|
|
56
|
+
## Python usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sfincstools import sfpgs, sfcfg
|
|
60
|
+
|
|
61
|
+
sfpgs.main(["path/to/model"])
|
|
62
|
+
sfcfg.main(["path/to/model", "--save", "model.png"])
|
|
63
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CHANGELOG.md
|
|
2
|
+
LICENSE
|
|
3
|
+
MANIFEST.in
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
requirements.txt
|
|
7
|
+
sfincstools/__init__.py
|
|
8
|
+
sfincstools/sfcfg.py
|
|
9
|
+
sfincstools/sfpgs.py
|
|
10
|
+
sfincstools.egg-info/PKG-INFO
|
|
11
|
+
sfincstools.egg-info/SOURCES.txt
|
|
12
|
+
sfincstools.egg-info/dependency_links.txt
|
|
13
|
+
sfincstools.egg-info/entry_points.txt
|
|
14
|
+
sfincstools.egg-info/requires.txt
|
|
15
|
+
sfincstools.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sfincstools
|