ncftools 0.6.2__tar.gz → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ncftools
3
- Version: 0.6.2
3
+ Version: 0.8.0
4
4
  Summary: A collection of tools for working with NetCDF files
5
5
  Author-email: aaronchh <aaronhsu219@gmail.com>
6
6
  License-Expression: MIT
@@ -30,6 +30,8 @@ pip install -e .
30
30
  - **nc2shp**: Convert a UGRID-compliant NetCDF mesh file to ESRI Shapefiles
31
31
  - **transzone1**: Build a transition zone from triangle mesh faces and select all intersecting faces
32
32
  - **transzone2**: Extract the core transition zone — faces fully within the shrunk zone
33
+ - **setncrain**: Point a D-Flow FM model (`.ext` / `.mdu`) at a NetCDF rainfall forcing file
34
+ - **rnxml**: Rename `dimr.xml` to `dimr_config.xml`
33
35
 
34
36
  ## Usage
35
37
 
@@ -89,6 +91,51 @@ transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -o
89
91
  transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -q
90
92
  ```
91
93
 
94
+ ### setncrain
95
+
96
+ Point a D-Flow FM model at a NetCDF rainfall forcing file.
97
+
98
+ Every `[Meteo]` block in the `.ext` file is rewritten to
99
+ `quantity=rainfall`, `forcingFile=<your .nc>`, `forcingFileType=netcdf`.
100
+ If the `.ext` file does not exist it is created from a built-in template and
101
+ registered in the `.mdu` as `ExtForceFileNew`.
102
+
103
+ The NetCDF time axis is read and the model times in the `.mdu` are set to match it
104
+ (expressed in the model's `Tunit`):
105
+
106
+ | Key | Value |
107
+ | --- | --- |
108
+ | `RefDate` | midnight of the first time stamp |
109
+ | `TStart` | offset of the first time stamp from `RefDate` |
110
+ | `TStop` | offset of the last time stamp from `RefDate` |
111
+
112
+ The `forcingFile` path is written relative to the `.ext` file (D-Flow FM resolves it
113
+ that way); use `--as-given` to write it exactly as typed. `.bak` copies of the
114
+ modified files are written unless `--no-backup` is given.
115
+
116
+ ```bash
117
+ setncrain -i May28_Event.nc
118
+ setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
119
+ setncrain -i May28_Event.nc --mdu dflowfm/FM_model.mdu --no-time
120
+ setncrain -i May28_Event.nc --no-backup --as-given
121
+ setncrain -i May28_Event.nc -q
122
+ ```
123
+
124
+ ### rnxml
125
+
126
+ Rename `dimr.xml` to `dimr_config.xml`, the name the DIMR runner expects. The file
127
+ stays in its folder and its contents are not touched.
128
+
129
+ If `dimr_config.xml` already exists the rename is refused; pass `--force` to
130
+ overwrite it (a `.bak` copy of the old target is kept unless `--no-backup`).
131
+
132
+ ```bash
133
+ rnxml
134
+ rnxml -i model/dimr.xml
135
+ rnxml -i dimr.xml -o dimr_config.xml --force
136
+ rnxml -i dimr.xml -q
137
+ ```
138
+
92
139
  ## Python API
93
140
 
94
141
  ```python
@@ -14,6 +14,8 @@ pip install -e .
14
14
  - **nc2shp**: Convert a UGRID-compliant NetCDF mesh file to ESRI Shapefiles
15
15
  - **transzone1**: Build a transition zone from triangle mesh faces and select all intersecting faces
16
16
  - **transzone2**: Extract the core transition zone — faces fully within the shrunk zone
17
+ - **setncrain**: Point a D-Flow FM model (`.ext` / `.mdu`) at a NetCDF rainfall forcing file
18
+ - **rnxml**: Rename `dimr.xml` to `dimr_config.xml`
17
19
 
18
20
  ## Usage
19
21
 
@@ -73,6 +75,51 @@ transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -o
73
75
  transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -q
74
76
  ```
75
77
 
78
+ ### setncrain
79
+
80
+ Point a D-Flow FM model at a NetCDF rainfall forcing file.
81
+
82
+ Every `[Meteo]` block in the `.ext` file is rewritten to
83
+ `quantity=rainfall`, `forcingFile=<your .nc>`, `forcingFileType=netcdf`.
84
+ If the `.ext` file does not exist it is created from a built-in template and
85
+ registered in the `.mdu` as `ExtForceFileNew`.
86
+
87
+ The NetCDF time axis is read and the model times in the `.mdu` are set to match it
88
+ (expressed in the model's `Tunit`):
89
+
90
+ | Key | Value |
91
+ | --- | --- |
92
+ | `RefDate` | midnight of the first time stamp |
93
+ | `TStart` | offset of the first time stamp from `RefDate` |
94
+ | `TStop` | offset of the last time stamp from `RefDate` |
95
+
96
+ The `forcingFile` path is written relative to the `.ext` file (D-Flow FM resolves it
97
+ that way); use `--as-given` to write it exactly as typed. `.bak` copies of the
98
+ modified files are written unless `--no-backup` is given.
99
+
100
+ ```bash
101
+ setncrain -i May28_Event.nc
102
+ setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
103
+ setncrain -i May28_Event.nc --mdu dflowfm/FM_model.mdu --no-time
104
+ setncrain -i May28_Event.nc --no-backup --as-given
105
+ setncrain -i May28_Event.nc -q
106
+ ```
107
+
108
+ ### rnxml
109
+
110
+ Rename `dimr.xml` to `dimr_config.xml`, the name the DIMR runner expects. The file
111
+ stays in its folder and its contents are not touched.
112
+
113
+ If `dimr_config.xml` already exists the rename is refused; pass `--force` to
114
+ overwrite it (a `.bak` copy of the old target is kept unless `--no-backup`).
115
+
116
+ ```bash
117
+ rnxml
118
+ rnxml -i model/dimr.xml
119
+ rnxml -i dimr.xml -o dimr_config.xml --force
120
+ rnxml -i dimr.xml -q
121
+ ```
122
+
76
123
  ## Python API
77
124
 
78
125
  ```python
@@ -2,13 +2,15 @@
2
2
  NCFTOOLS - A collection of tools for working with NetCDF files.
3
3
  """
4
4
 
5
- __version__ = '0.6.2'
5
+ __version__ = '0.8.0'
6
6
 
7
7
  __all__ = [
8
8
  'meshinfo',
9
9
  'nc2shp',
10
10
  'transzone1',
11
11
  'transzone2',
12
+ 'setncrain',
13
+ 'rnxml',
12
14
  'describe',
13
15
  ]
14
16
 
@@ -16,5 +18,7 @@ from . import meshinfo
16
18
  from . import nc2shp
17
19
  from . import transzone1
18
20
  from . import transzone2
21
+ from . import setncrain
22
+ from . import rnxml
19
23
  from . import describe
20
24
  from . import cli
@@ -58,6 +58,43 @@ TOOL_DESCRIPTIONS = {
58
58
  transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_faces.shp -o SHP_TRANS
59
59
  transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_faces.shp -q
60
60
  """,
61
+ 'setncrain': """
62
+ Point a D-Flow FM model at a NetCDF rainfall forcing file.
63
+
64
+ Rewrites every [Meteo] block of the .ext file to use the given NetCDF
65
+ file as rainfall forcing:
66
+ quantity=rainfall, forcingFile=<*.nc>, forcingFileType=netcdf
67
+ If the .ext file is missing it is created from a built-in template and
68
+ registered in the .mdu as ExtForceFileNew.
69
+
70
+ The NetCDF time axis is read and the model times in the .mdu are set
71
+ to match it (in the model's Tunit):
72
+ RefDate midnight of the first time stamp
73
+ TStart offset of the first time stamp from RefDate
74
+ TStop offset of the last time stamp from RefDate
75
+
76
+ Examples:
77
+ setncrain -i May28_Event.nc
78
+ setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
79
+ setncrain -i May28_Event.nc --no-time
80
+ setncrain -i May28_Event.nc --no-backup --as-given
81
+ """,
82
+ 'rnxml': """
83
+ Rename dimr.xml to dimr_config.xml.
84
+
85
+ D-HYDRO / Delft3D FM writes its DIMR control file as dimr.xml, while
86
+ the DIMR runner expects dimr_config.xml. This tool renames the file in
87
+ place, leaving its contents untouched.
88
+
89
+ If the target name already exists the rename is refused unless --force
90
+ is given, in which case a .bak copy of the old target is kept.
91
+
92
+ Examples:
93
+ rnxml
94
+ rnxml -i model/dimr.xml
95
+ rnxml -i dimr.xml -o dimr_config.xml --force
96
+ rnxml -i dimr.xml -q
97
+ """,
61
98
  }
62
99
 
63
100
 
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ rnxml - Rename dimr.xml to dimr_config.xml
4
+
5
+ D-HYDRO / Delft3D FM writes its DIMR control file as dimr.xml, while the DIMR
6
+ runner expects dimr_config.xml. This tool renames the file in place.
7
+
8
+ The file contents are not touched. If the target name already exists the
9
+ rename is refused unless --force is given, in which case a .bak copy of the
10
+ existing target is kept (unless --no-backup).
11
+ """
12
+
13
+ import argparse
14
+ import importlib.metadata
15
+ import os
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ DEFAULT_INPUT = 'dimr.xml'
21
+ DEFAULT_OUTPUT_NAME = 'dimr_config.xml'
22
+
23
+
24
+ def _print(msg, quiet=False):
25
+ if not quiet:
26
+ print(msg)
27
+
28
+
29
+ def rename_xml(
30
+ input_file=DEFAULT_INPUT,
31
+ output_name=DEFAULT_OUTPUT_NAME,
32
+ force=False,
33
+ backup=True,
34
+ quiet=False,
35
+ ):
36
+ """
37
+ Rename an XML file, keeping it in the same directory.
38
+
39
+ Args:
40
+ input_file (str): Path to the file to rename (default: dimr.xml).
41
+ output_name (str): New file name, without a directory part
42
+ (default: dimr_config.xml).
43
+ force (bool): Overwrite the target if it already exists.
44
+ backup (bool): When overwriting, keep a .bak copy of the old target.
45
+ quiet (bool): Suppress non-error output.
46
+
47
+ Returns:
48
+ Path: Path of the renamed file.
49
+
50
+ Raises:
51
+ FileNotFoundError: The input file does not exist.
52
+ ValueError: output_name contains a directory part.
53
+ FileExistsError: The target exists and force is False.
54
+ """
55
+ src = Path(input_file)
56
+ if not src.is_file():
57
+ raise FileNotFoundError(f"file not found: {src}")
58
+
59
+ if os.path.dirname(output_name):
60
+ raise ValueError(
61
+ f"--output-name must be a file name, not a path: {output_name}"
62
+ )
63
+
64
+ dst = src.parent / output_name
65
+
66
+ if src.resolve() == dst.resolve():
67
+ _print(f"{src} is already named {output_name} - nothing to do.", quiet)
68
+ return dst
69
+
70
+ if dst.exists():
71
+ if not force:
72
+ raise FileExistsError(
73
+ f"{dst} already exists - use --force to overwrite it"
74
+ )
75
+ if backup:
76
+ bak = dst.with_suffix(dst.suffix + '.bak')
77
+ shutil.copy2(dst, bak)
78
+ _print(f"Backup written: {bak}", quiet)
79
+
80
+ os.replace(src, dst)
81
+ _print(f"Renamed: {src} -> {dst}", quiet)
82
+ return dst
83
+
84
+
85
+ def main():
86
+ parser = argparse.ArgumentParser(
87
+ prog='rnxml',
88
+ description='Rename dimr.xml to dimr_config.xml',
89
+ formatter_class=argparse.RawDescriptionHelpFormatter,
90
+ epilog="""
91
+ Examples:
92
+ rnxml
93
+ rnxml -i dimr.xml
94
+ rnxml -i model/dimr.xml
95
+ rnxml -i dimr.xml -o dimr_config.xml --force
96
+ rnxml -i dimr.xml -q
97
+ """,
98
+ )
99
+
100
+ parser.add_argument(
101
+ '-v', '--version',
102
+ action='version',
103
+ version=f'%(prog)s {importlib.metadata.version("ncftools")}',
104
+ )
105
+ parser.add_argument(
106
+ '-i', '--input',
107
+ default=DEFAULT_INPUT,
108
+ metavar='FILE',
109
+ help=f'File to rename (default: {DEFAULT_INPUT})',
110
+ )
111
+ parser.add_argument(
112
+ '-o', '--output-name',
113
+ default=DEFAULT_OUTPUT_NAME,
114
+ metavar='NAME',
115
+ help=f'New file name, kept in the same folder (default: {DEFAULT_OUTPUT_NAME})',
116
+ )
117
+ parser.add_argument(
118
+ '--force',
119
+ action='store_true',
120
+ help='Overwrite the target file if it already exists',
121
+ )
122
+ parser.add_argument(
123
+ '--no-backup',
124
+ action='store_true',
125
+ help='Do not keep a .bak copy of an overwritten target',
126
+ )
127
+ parser.add_argument(
128
+ '-q', '--quiet',
129
+ action='store_true',
130
+ help='Suppress non-error output',
131
+ )
132
+
133
+ args = parser.parse_args()
134
+
135
+ try:
136
+ dst = rename_xml(
137
+ args.input,
138
+ output_name=args.output_name,
139
+ force=args.force,
140
+ backup=not args.no_backup,
141
+ quiet=args.quiet,
142
+ )
143
+ if args.quiet:
144
+ print(dst)
145
+ except Exception as e:
146
+ print(f"Error: {e}", file=sys.stderr)
147
+ sys.exit(1)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ setncrain - Point a D-Flow FM model at a NetCDF rainfall forcing file
4
+
5
+ In every [Meteo] block of the .ext file:
6
+ quantity -> rainfall
7
+ forcingFile -> <user specified *.nc>
8
+ forcingFileType -> netcdf
9
+
10
+ If the .ext file does not exist it is created from the hard-coded TEMPLATE
11
+ below, already pointing at the given NetCDF file, and ExtForceFileNew in the
12
+ model definition file (*.mdu) is set to it.
13
+
14
+ The NetCDF time axis is read and the model times in the .mdu are adjusted to it:
15
+ RefDate -> the date of the first time stamp (midnight)
16
+ TStart -> offset of the first time stamp from RefDate (in Tunit)
17
+ TStop -> offset of the last time stamp from RefDate (in Tunit)
18
+ """
19
+
20
+ import argparse
21
+ import importlib.metadata
22
+ import os
23
+ import re
24
+ import shutil
25
+ import sys
26
+ from datetime import datetime, timedelta
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ REPLACEMENTS = {
31
+ "quantity": "rainfall",
32
+ "forcingfiletype": "netcdf",
33
+ # forcingFile is filled in at runtime
34
+ }
35
+
36
+ # Hard-coded .ext contents, used when the .ext file is missing.
37
+ # Edit here to change the generated file; {forcing_file} is substituted.
38
+ TEMPLATE = """[General]
39
+ fileVersion=2.02
40
+ fileType=extForce
41
+
42
+ [Meteo]
43
+ quantity=rainfall
44
+ forcingFile={forcing_file}
45
+ forcingFileType=netcdf
46
+ interpolationMethod=linearSpaceTime
47
+ operand=O
48
+
49
+ """
50
+
51
+ CF_UNITS = {
52
+ "second": 1.0, "seconds": 1.0, "sec": 1.0, "secs": 1.0, "s": 1.0,
53
+ "minute": 60.0, "minutes": 60.0, "min": 60.0, "mins": 60.0,
54
+ "hour": 3600.0, "hours": 3600.0, "hr": 3600.0, "hrs": 3600.0, "h": 3600.0,
55
+ "day": 86400.0, "days": 86400.0, "d": 86400.0,
56
+ }
57
+ # TStart/TStop are expressed in the model's Tunit
58
+ TUNIT_SECONDS = {"S": 1.0, "M": 60.0, "H": 3600.0, "D": 86400.0}
59
+
60
+
61
+ def _print(msg, quiet=False):
62
+ if not quiet:
63
+ print(msg)
64
+
65
+
66
+ def create(ext_path, nc_file, quiet=False):
67
+ """
68
+ Create a new .ext file from the hard-coded TEMPLATE, pointing at nc_file.
69
+
70
+ Args:
71
+ ext_path (Path): Path of the .ext file to write.
72
+ nc_file (str): forcingFile value to write into the [Meteo] block.
73
+ quiet (bool): Suppress non-error output.
74
+ """
75
+ ext_path = Path(ext_path)
76
+ ext_path.parent.mkdir(parents=True, exist_ok=True)
77
+ # D-Flow FM input files conventionally use CRLF
78
+ with ext_path.open("w", newline="\r\n") as f:
79
+ f.write(TEMPLATE.format(forcing_file=nc_file))
80
+ _print(f"Created: {ext_path}", quiet)
81
+ _print(
82
+ f" quantity=rainfall, forcingFile={nc_file}, forcingFileType=netcdf",
83
+ quiet,
84
+ )
85
+
86
+
87
+ def find_mdu(ext_path) -> Optional[Path]:
88
+ """Locate the .mdu next to the .ext file (FM_model.mdu wins if several)."""
89
+ ext_path = Path(ext_path)
90
+ folder = ext_path.parent if str(ext_path.parent) else Path(".")
91
+ mdus = sorted(folder.glob("*.mdu"))
92
+ if not mdus:
93
+ return None
94
+ for m in mdus:
95
+ if m.stem.lower() == "fm_model":
96
+ return m
97
+ return mdus[0]
98
+
99
+
100
+ def read_mdu_value(mdu_path, key) -> Optional[str]:
101
+ """Return the value of a single .mdu key, or None if it is absent."""
102
+ with Path(mdu_path).open("r", newline="") as f:
103
+ for line in f:
104
+ name, sep, rest = line.partition("=")
105
+ if sep and name.strip().lower() == key.lower():
106
+ return rest.partition("#")[0].strip()
107
+ return None
108
+
109
+
110
+ def update_mdu(mdu_path, settings, backup=True, quiet=False):
111
+ """
112
+ Set the given key=value pairs in the .mdu, preserving layout and comments.
113
+
114
+ Args:
115
+ mdu_path (Path): Path to the model definition file.
116
+ settings (dict): Key/value pairs to apply.
117
+ backup (bool): Write a .bak copy before overwriting.
118
+ quiet (bool): Suppress non-error output.
119
+
120
+ Returns:
121
+ list[str]: Human-readable description of each change made.
122
+ """
123
+ mdu_path = Path(mdu_path)
124
+ wanted = {k.lower(): (k, str(v)) for k, v in settings.items()}
125
+
126
+ with mdu_path.open("r", newline="") as f:
127
+ lines = f.read().splitlines(keepends=True)
128
+
129
+ out, changed, seen = [], [], set()
130
+ for line in lines:
131
+ body = line.rstrip("\r\n")
132
+ eol = line[len(body):]
133
+ key, sep, rest = body.partition("=")
134
+ name = key.strip().lower()
135
+
136
+ if sep and name in wanted:
137
+ seen.add(name)
138
+ label, value = wanted[name]
139
+ val_part, hash_sep, comment = rest.partition("#")
140
+ old = val_part.strip()
141
+ if old != value:
142
+ # keep the original column of the trailing comment
143
+ lead = val_part[: len(val_part) - len(val_part.lstrip())] if old else " "
144
+ new_val = (lead + value).ljust(len(val_part))
145
+ if hash_sep and not new_val.endswith(" "):
146
+ new_val += " "
147
+ line = f"{key}={new_val}{hash_sep}{comment}" + eol
148
+ changed.append(f"{label} = {old or '<empty>'} -> {value}")
149
+
150
+ out.append(line)
151
+
152
+ for name, (label, value) in wanted.items():
153
+ if name not in seen:
154
+ print(
155
+ f"! {label} not found in {mdu_path} - set it to {value} manually.",
156
+ file=sys.stderr,
157
+ )
158
+
159
+ if not changed:
160
+ _print(f"{mdu_path}: already up to date.", quiet)
161
+ return changed
162
+
163
+ if backup:
164
+ bak = mdu_path.with_suffix(mdu_path.suffix + ".bak")
165
+ shutil.copy2(mdu_path, bak)
166
+ _print(f"Backup written: {bak}", quiet)
167
+
168
+ with mdu_path.open("w", newline="") as f:
169
+ f.write("".join(out))
170
+ _print(f"Updated: {mdu_path}", quiet)
171
+ for c in changed:
172
+ _print(f" {c}", quiet)
173
+ return changed
174
+
175
+
176
+ def rewrite(ext_path, nc_file, backup=True, quiet=False):
177
+ """
178
+ Rewrite the [Meteo] blocks of an existing .ext file for NetCDF rainfall.
179
+
180
+ Args:
181
+ ext_path (Path): Path to the existing .ext file.
182
+ nc_file (str): forcingFile value to write.
183
+ backup (bool): Write a .bak copy before overwriting.
184
+ quiet (bool): Suppress non-error output.
185
+
186
+ Returns:
187
+ list[str]: Human-readable description of each change made.
188
+ """
189
+ ext_path = Path(ext_path)
190
+ if not ext_path.is_file():
191
+ raise FileNotFoundError(f"ext file not found: {ext_path}")
192
+
193
+ # newline='' keeps original CRLF/LF line endings intact
194
+ with ext_path.open("r", newline="") as f:
195
+ raw = f.read()
196
+
197
+ lines = raw.splitlines(keepends=True)
198
+ out = []
199
+ in_meteo = False
200
+ changed = []
201
+
202
+ for line in lines:
203
+ stripped = line.strip()
204
+ body, eol = line.rstrip("\r\n"), line[len(line.rstrip("\r\n")):]
205
+
206
+ if stripped.startswith("["):
207
+ in_meteo = stripped.lower() == "[meteo]"
208
+ out.append(line)
209
+ continue
210
+
211
+ if in_meteo and "=" in body:
212
+ key, _, old = body.partition("=")
213
+ # split off any inline comment so it is preserved
214
+ old_val, sep, comment = old.partition("#")
215
+ name = key.strip().lower()
216
+
217
+ new_val = None
218
+ if name == "forcingfile":
219
+ new_val = nc_file
220
+ elif name in REPLACEMENTS:
221
+ new_val = REPLACEMENTS[name]
222
+
223
+ if new_val is not None:
224
+ # keep original spacing style around '=' and the column of any
225
+ # trailing comment
226
+ lead = old_val[: len(old_val) - len(old_val.lstrip())]
227
+ padded = lead + new_val
228
+ if sep: # an inline comment follows: keep its column
229
+ padded = padded.ljust(len(old_val))
230
+ if not padded.endswith(" "):
231
+ padded += " "
232
+ body = f"{key}={padded}{sep}{comment}"
233
+ changed.append(f"{key.strip()} = {old_val.strip()} -> {new_val}")
234
+ line = body + eol
235
+
236
+ out.append(line)
237
+
238
+ if not changed:
239
+ _print("No [Meteo] keys matched - file left unchanged.", quiet)
240
+ return changed
241
+
242
+ if backup:
243
+ bak = ext_path.with_suffix(ext_path.suffix + ".bak")
244
+ shutil.copy2(ext_path, bak)
245
+ _print(f"Backup written: {bak}", quiet)
246
+
247
+ with ext_path.open("w", newline="") as f:
248
+ f.write("".join(out))
249
+
250
+ _print(f"Updated: {ext_path}", quiet)
251
+ for c in changed:
252
+ _print(f" {c}", quiet)
253
+ return changed
254
+
255
+
256
+ def _open_time_var(nc_path):
257
+ """Return (values, units_string) of the time coordinate of a NetCDF file."""
258
+
259
+ def pick(names, get_attr):
260
+ for cand in ("time", "TIME", "Time", "t"):
261
+ if cand in names:
262
+ return cand
263
+ for n in names: # fall back to any CF time-like variable
264
+ units = (get_attr(n, "units") or "").lower()
265
+ if " since " in units or (get_attr(n, "axis") or "") == "T":
266
+ return n
267
+ return None
268
+
269
+ try:
270
+ import netCDF4 # the usual reader in a Delft3D FM python environment
271
+ except ImportError:
272
+ pass
273
+ else:
274
+ with netCDF4.Dataset(str(nc_path)) as ds:
275
+ def get(n, a):
276
+ return getattr(ds.variables[n], a, None)
277
+
278
+ name = pick(list(ds.variables), get)
279
+ if name is None:
280
+ raise ValueError(f"no time variable found in {nc_path}")
281
+ v = ds.variables[name]
282
+ return [float(x) for x in v[:].ravel()], str(getattr(v, "units", ""))
283
+
284
+ try:
285
+ import xarray as xr # second choice
286
+ except ImportError:
287
+ pass
288
+ else:
289
+ with xr.open_dataset(str(nc_path), decode_times=False) as ds:
290
+ def get(n, a):
291
+ return ds[n].attrs.get(a)
292
+
293
+ name = pick(list(ds.variables), get)
294
+ if name is None:
295
+ raise ValueError(f"no time variable found in {nc_path}")
296
+ v = ds[name]
297
+ return [float(x) for x in v.values.ravel()], str(v.attrs.get("units", ""))
298
+
299
+ try:
300
+ from scipy.io import netcdf_file # last resort, NetCDF3 only
301
+ except ImportError as exc:
302
+ raise ImportError(
303
+ "reading the NetCDF file needs one of netCDF4, xarray or scipy - "
304
+ "install with: pip install netCDF4"
305
+ ) from exc
306
+
307
+ with netcdf_file(str(nc_path), "r", mmap=False) as ds:
308
+ def get(n, a):
309
+ val = getattr(ds.variables[n], a, None)
310
+ return val.decode() if isinstance(val, bytes) else val
311
+
312
+ name = pick(list(ds.variables), get)
313
+ if name is None:
314
+ raise ValueError(f"no time variable found in {nc_path}")
315
+ v = ds.variables[name]
316
+ units = getattr(v, "units", b"")
317
+ return (
318
+ [float(x) for x in v[:].ravel()],
319
+ units.decode() if isinstance(units, bytes) else str(units),
320
+ )
321
+
322
+
323
+ def parse_cf_units(units):
324
+ """'minutes since 1970-01-01 00:00:00.0 +0000' -> (scale in s, epoch)."""
325
+ unit, _, epoch_txt = units.partition(" since ")
326
+ scale = CF_UNITS.get(unit.strip().lower())
327
+ if scale is None:
328
+ raise ValueError(f"unsupported time unit in '{units}'")
329
+
330
+ txt = epoch_txt.strip()
331
+ # drop the time zone, then normalise an ISO 'T' separator to a space
332
+ txt = re.sub(r"\s*(Z|UTC|GMT)\s*$", "", txt) # ... 00:00:00 UTC
333
+ txt = re.sub(r"\s+[+-]\d{2}:?\d{2}\s*$", "", txt) # ... 00:00:00 +0000
334
+ txt = re.sub(r"(?<=\d)T(?=\d)", " ", txt).strip()
335
+ for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
336
+ try:
337
+ return scale, datetime.strptime(txt, fmt)
338
+ except ValueError:
339
+ continue
340
+ raise ValueError(f"cannot parse the reference date in '{units}'")
341
+
342
+
343
+ def nc_time_range(nc_path):
344
+ """First and last time stamp in the NetCDF file, as datetimes."""
345
+ values, units = _open_time_var(nc_path)
346
+ if not values:
347
+ raise ValueError(f"the time variable in {nc_path} is empty")
348
+ scale, epoch = parse_cf_units(units)
349
+ lo, hi = min(values), max(values)
350
+ return epoch + timedelta(seconds=lo * scale), epoch + timedelta(seconds=hi * scale)
351
+
352
+
353
+ def _fmt(seconds, tunit):
354
+ v = seconds / TUNIT_SECONDS.get(tunit.upper(), 1.0)
355
+ return str(int(round(v))) if abs(v - round(v)) < 1e-6 else f"{v:.6g}"
356
+
357
+
358
+ def time_settings(nc_path, tunit="S", quiet=False):
359
+ """
360
+ RefDate / TStart / TStop derived from the NetCDF time axis.
361
+
362
+ RefDate is midnight of the first time stamp; TStart and TStop are offsets
363
+ from that midnight, expressed in the model's Tunit.
364
+
365
+ Args:
366
+ nc_path (Path): Path to the NetCDF forcing file.
367
+ tunit (str): Model time unit, one of S, M, H, D.
368
+ quiet (bool): Suppress non-error output.
369
+
370
+ Returns:
371
+ dict: {'RefDate': ..., 'TStart': ..., 'TStop': ...}
372
+ """
373
+ t0, t1 = nc_time_range(nc_path)
374
+ ref = t0.replace(hour=0, minute=0, second=0, microsecond=0)
375
+ _print(
376
+ f"NetCDF time span: {t0:%Y-%m-%d %H:%M:%S} -> {t1:%Y-%m-%d %H:%M:%S}",
377
+ quiet,
378
+ )
379
+ return {
380
+ "RefDate": ref.strftime("%Y%m%d"),
381
+ "TStart": _fmt((t0 - ref).total_seconds(), tunit),
382
+ "TStop": _fmt((t1 - ref).total_seconds(), tunit),
383
+ }
384
+
385
+
386
+ def resolve_forcing_path(nc_file, ext_path):
387
+ """D-Flow FM resolves forcingFile relative to the .ext file, so rewrite a
388
+ path given relative to the cwd (or an absolute one) into that form."""
389
+ nc = Path(nc_file)
390
+ if not nc.exists():
391
+ return nc_file # nothing to resolve against; write it through as given
392
+ try:
393
+ rel = os.path.relpath(nc.resolve(), Path(ext_path).resolve().parent)
394
+ except ValueError: # different drive on Windows -> keep the absolute path
395
+ return str(nc.resolve())
396
+ return rel.replace(os.sep, "/")
397
+
398
+
399
+ def set_nc_rainfall(
400
+ nc_file,
401
+ ext_file="dflowfm/FM_model_bnd.ext",
402
+ mdu_file=None,
403
+ update_time=True,
404
+ as_given=False,
405
+ backup=True,
406
+ quiet=False,
407
+ ):
408
+ """
409
+ Point a D-Flow FM model at a NetCDF rainfall forcing file.
410
+
411
+ Args:
412
+ nc_file (str): Path to the NetCDF forcing file.
413
+ ext_file (str): Path to the .ext file (created if missing).
414
+ mdu_file (str): Model definition file; defaults to the *.mdu next to
415
+ the .ext file.
416
+ update_time (bool): Also set RefDate/TStart/TStop from the NetCDF
417
+ time axis.
418
+ as_given (bool): Write the forcingFile path exactly as typed instead
419
+ of making it relative to the .ext file.
420
+ backup (bool): Write .bak copies before overwriting files.
421
+ quiet (bool): Suppress non-error output.
422
+
423
+ Returns:
424
+ tuple[Path, Path | None]: Paths to the (.ext, .mdu) touched; the .mdu
425
+ is None when no model definition file could be found.
426
+ """
427
+ nc_path = Path(nc_file)
428
+ ext_path = Path(ext_file)
429
+ forcing = nc_file if as_given else resolve_forcing_path(nc_file, ext_path)
430
+
431
+ created = not ext_path.is_file()
432
+ if created:
433
+ _print(f"{ext_path} not found - creating it.", quiet)
434
+ create(ext_path, forcing, quiet=quiet)
435
+ else:
436
+ rewrite(ext_path, forcing, backup=backup, quiet=quiet)
437
+
438
+ # ---- model definition file -------------------------------------------
439
+ settings = {}
440
+ if created:
441
+ # a freshly created .ext has to be registered in the .mdu
442
+ settings["ExtForceFileNew"] = ext_path.name
443
+
444
+ mdu_path = Path(mdu_file) if mdu_file else find_mdu(ext_path)
445
+ if mdu_path is None:
446
+ print(
447
+ f"! No *.mdu found in {ext_path.parent} - model times not updated.",
448
+ file=sys.stderr,
449
+ )
450
+ return ext_path, None
451
+ if not mdu_path.is_file():
452
+ print(
453
+ f"! mdu not found: {mdu_path} - model times not updated.",
454
+ file=sys.stderr,
455
+ )
456
+ return ext_path, None
457
+
458
+ if update_time:
459
+ if not nc_path.is_file():
460
+ print(
461
+ f"! {nc_path} not found - RefDate/TStart/TStop left unchanged.",
462
+ file=sys.stderr,
463
+ )
464
+ else:
465
+ tunit = (read_mdu_value(mdu_path, "Tunit") or "S").upper() or "S"
466
+ try:
467
+ settings.update(time_settings(nc_path, tunit, quiet=quiet))
468
+ except Exception as exc: # unreadable file, odd time axis, ...
469
+ print(f"! could not read times from {nc_path}: {exc}", file=sys.stderr)
470
+ print(" RefDate/TStart/TStop left unchanged.", file=sys.stderr)
471
+
472
+ if settings:
473
+ update_mdu(mdu_path, settings, backup=backup, quiet=quiet)
474
+
475
+ return ext_path, mdu_path
476
+
477
+
478
+ def main():
479
+ parser = argparse.ArgumentParser(
480
+ prog='setncrain',
481
+ description='Point a D-Flow FM .ext/.mdu pair at a NetCDF rainfall forcing file',
482
+ formatter_class=argparse.RawDescriptionHelpFormatter,
483
+ epilog="""
484
+ Examples:
485
+ setncrain -i May28_Event.nc
486
+ setncrain -i C:/data/rain/May28_Event.nc
487
+ setncrain -i May28_Event.nc --ext dflowfm/FM_model_bnd.ext
488
+ setncrain -i May28_Event.nc --no-backup --as-given
489
+ """,
490
+ )
491
+
492
+ parser.add_argument(
493
+ '-v', '--version',
494
+ action='version',
495
+ version=f'%(prog)s {importlib.metadata.version("ncftools")}',
496
+ )
497
+ parser.add_argument(
498
+ '-i', '--input',
499
+ required=True,
500
+ metavar='FILE',
501
+ help='Path to the NetCDF rainfall forcing file (*.nc)',
502
+ )
503
+ parser.add_argument(
504
+ '--ext',
505
+ default='dflowfm/FM_model_bnd.ext',
506
+ metavar='FILE',
507
+ help='Path to the .ext file (default: dflowfm/FM_model_bnd.ext)',
508
+ )
509
+ parser.add_argument(
510
+ '--mdu',
511
+ metavar='FILE',
512
+ help='Model definition file to update (default: the *.mdu next to the .ext)',
513
+ )
514
+ parser.add_argument(
515
+ '--no-time',
516
+ action='store_true',
517
+ help='Do not touch RefDate/TStart/TStop in the .mdu',
518
+ )
519
+ parser.add_argument(
520
+ '--as-given',
521
+ action='store_true',
522
+ help='Write the path exactly as typed instead of making it relative to the .ext file',
523
+ )
524
+ parser.add_argument(
525
+ '--no-backup',
526
+ action='store_true',
527
+ help='Do not write .bak copies',
528
+ )
529
+ parser.add_argument(
530
+ '-q', '--quiet',
531
+ action='store_true',
532
+ help='Suppress non-error output',
533
+ )
534
+
535
+ args = parser.parse_args()
536
+
537
+ if not args.input.lower().endswith('.nc'):
538
+ print("Error: forcing file must be a *.nc file", file=sys.stderr)
539
+ sys.exit(1)
540
+
541
+ try:
542
+ ext_path, mdu_path = set_nc_rainfall(
543
+ args.input,
544
+ ext_file=args.ext,
545
+ mdu_file=args.mdu,
546
+ update_time=not args.no_time,
547
+ as_given=args.as_given,
548
+ backup=not args.no_backup,
549
+ quiet=args.quiet,
550
+ )
551
+ if args.quiet:
552
+ print(ext_path)
553
+ if mdu_path is not None:
554
+ print(mdu_path)
555
+ except Exception as e:
556
+ print(f"Error: {e}", file=sys.stderr)
557
+ sys.exit(1)
558
+
559
+
560
+ if __name__ == "__main__":
561
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ncftools
3
- Version: 0.6.2
3
+ Version: 0.8.0
4
4
  Summary: A collection of tools for working with NetCDF files
5
5
  Author-email: aaronchh <aaronhsu219@gmail.com>
6
6
  License-Expression: MIT
@@ -30,6 +30,8 @@ pip install -e .
30
30
  - **nc2shp**: Convert a UGRID-compliant NetCDF mesh file to ESRI Shapefiles
31
31
  - **transzone1**: Build a transition zone from triangle mesh faces and select all intersecting faces
32
32
  - **transzone2**: Extract the core transition zone — faces fully within the shrunk zone
33
+ - **setncrain**: Point a D-Flow FM model (`.ext` / `.mdu`) at a NetCDF rainfall forcing file
34
+ - **rnxml**: Rename `dimr.xml` to `dimr_config.xml`
33
35
 
34
36
  ## Usage
35
37
 
@@ -89,6 +91,51 @@ transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -o
89
91
  transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -q
90
92
  ```
91
93
 
94
+ ### setncrain
95
+
96
+ Point a D-Flow FM model at a NetCDF rainfall forcing file.
97
+
98
+ Every `[Meteo]` block in the `.ext` file is rewritten to
99
+ `quantity=rainfall`, `forcingFile=<your .nc>`, `forcingFileType=netcdf`.
100
+ If the `.ext` file does not exist it is created from a built-in template and
101
+ registered in the `.mdu` as `ExtForceFileNew`.
102
+
103
+ The NetCDF time axis is read and the model times in the `.mdu` are set to match it
104
+ (expressed in the model's `Tunit`):
105
+
106
+ | Key | Value |
107
+ | --- | --- |
108
+ | `RefDate` | midnight of the first time stamp |
109
+ | `TStart` | offset of the first time stamp from `RefDate` |
110
+ | `TStop` | offset of the last time stamp from `RefDate` |
111
+
112
+ The `forcingFile` path is written relative to the `.ext` file (D-Flow FM resolves it
113
+ that way); use `--as-given` to write it exactly as typed. `.bak` copies of the
114
+ modified files are written unless `--no-backup` is given.
115
+
116
+ ```bash
117
+ setncrain -i May28_Event.nc
118
+ setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
119
+ setncrain -i May28_Event.nc --mdu dflowfm/FM_model.mdu --no-time
120
+ setncrain -i May28_Event.nc --no-backup --as-given
121
+ setncrain -i May28_Event.nc -q
122
+ ```
123
+
124
+ ### rnxml
125
+
126
+ Rename `dimr.xml` to `dimr_config.xml`, the name the DIMR runner expects. The file
127
+ stays in its folder and its contents are not touched.
128
+
129
+ If `dimr_config.xml` already exists the rename is refused; pass `--force` to
130
+ overwrite it (a `.bak` copy of the old target is kept unless `--no-backup`).
131
+
132
+ ```bash
133
+ rnxml
134
+ rnxml -i model/dimr.xml
135
+ rnxml -i dimr.xml -o dimr_config.xml --force
136
+ rnxml -i dimr.xml -q
137
+ ```
138
+
92
139
  ## Python API
93
140
 
94
141
  ```python
@@ -7,6 +7,8 @@ ncftools/cli.py
7
7
  ncftools/describe.py
8
8
  ncftools/meshinfo.py
9
9
  ncftools/nc2shp.py
10
+ ncftools/rnxml.py
11
+ ncftools/setncrain.py
10
12
  ncftools/transzone1.py
11
13
  ncftools/transzone2.py
12
14
  ncftools.egg-info/PKG-INFO
@@ -3,5 +3,7 @@ meshinfo = ncftools.meshinfo:main
3
3
  nc2shp = ncftools.nc2shp:main
4
4
  ncftools = ncftools.cli:main
5
5
  ncftools-info = ncftools.describe:main
6
+ rnxml = ncftools.rnxml:main
7
+ setncrain = ncftools.setncrain:main
6
8
  transzone1 = ncftools.transzone1:main
7
9
  transzone2 = ncftools.transzone2:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ncftools"
7
- version = "0.6.2"
7
+ version = "0.8.0"
8
8
  authors = [
9
9
  { name = "aaronchh", email = "aaronhsu219@gmail.com" },
10
10
  ]
@@ -36,3 +36,5 @@ meshinfo = "ncftools.meshinfo:main"
36
36
  nc2shp = "ncftools.nc2shp:main"
37
37
  transzone1 = "ncftools.transzone1:main"
38
38
  transzone2 = "ncftools.transzone2:main"
39
+ setncrain = "ncftools.setncrain:main"
40
+ rnxml = "ncftools.rnxml:main"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes