sfincstools 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.
@@ -0,0 +1,7 @@
1
+ """
2
+ sfincstools - command-line tools for SFINCS models.
3
+
4
+ sfpgs check the progress of a SFINCS run
5
+ sfcfg display the configuration of a SFINCS model
6
+ """
7
+ __version__ = "0.1.0"
sfincstools/sfcfg.py ADDED
@@ -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()
sfincstools/sfpgs.py ADDED
@@ -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,9 @@
1
+ sfincstools/__init__.py,sha256=uO-b-PIFUv3umJFrxVaFCPJXW55UDuJeXD90PcXzLnk,182
2
+ sfincstools/sfcfg.py,sha256=SkX5ji2MJC_dsiUfXeKTHjoqbID-zJQR_BmOdjrBRII,25469
3
+ sfincstools/sfpgs.py,sha256=48qIeuiWVyjAOegNP-1VGMQt0NcINHl9IfdGzuVoS6A,6174
4
+ sfincstools-0.1.0.dist-info/licenses/LICENSE,sha256=uf78T1vy78ITLAE52aEPTzMv9GIRRg01U_b9azr7dMs,1070
5
+ sfincstools-0.1.0.dist-info/METADATA,sha256=vXbtSoI4OCL9SI1IZV5ItN8LMDI34WRUC0c6D-kXcso,1943
6
+ sfincstools-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ sfincstools-0.1.0.dist-info/entry_points.txt,sha256=idT3vmwn105lp9gOPXVY8USw06OUD3m_vMZe4N6tJyk,80
8
+ sfincstools-0.1.0.dist-info/top_level.txt,sha256=w0TsnUr3T4ASSSg9Sfyw5g5QmV2I2K1CZt9oPcSl3Ts,12
9
+ sfincstools-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sfcfg = sfincstools.sfcfg:main
3
+ sfpgs = sfincstools.sfpgs:main
@@ -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 @@
1
+ sfincstools