ncftools 0.6.2__tar.gz → 0.9.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.
- {ncftools-0.6.2 → ncftools-0.9.0}/PKG-INFO +52 -2
- {ncftools-0.6.2 → ncftools-0.9.0}/README.md +51 -1
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/__init__.py +5 -1
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/describe.py +40 -1
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/nc2shp.py +27 -11
- ncftools-0.9.0/ncftools/rnxml.py +151 -0
- ncftools-0.9.0/ncftools/setncrain.py +561 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools.egg-info/PKG-INFO +52 -2
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools.egg-info/SOURCES.txt +2 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools.egg-info/entry_points.txt +2 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/pyproject.toml +3 -1
- {ncftools-0.6.2 → ncftools-0.9.0}/MANIFEST.in +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/cli.py +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/meshinfo.py +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/tests/__init__.py +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/transzone1.py +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools/transzone2.py +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools.egg-info/dependency_links.txt +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools.egg-info/requires.txt +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/ncftools.egg-info/top_level.txt +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/requirements.txt +0 -0
- {ncftools-0.6.2 → ncftools-0.9.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ncftools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
|
@@ -56,10 +58,13 @@ meshinfo -h
|
|
|
56
58
|
|
|
57
59
|
### nc2shp
|
|
58
60
|
|
|
59
|
-
Convert a NetCDF mesh file to ESRI Shapefiles. Outputs `{stem}_faces.shp`
|
|
61
|
+
Convert a NetCDF mesh file to ESRI Shapefiles. Outputs `{stem}_faces.shp` in the output
|
|
62
|
+
directory. Pass `-d`/`--dissolve` to additionally write `{stem}_dissolved.shp`, a single
|
|
63
|
+
polygon dissolved from all mesh faces (slower on large meshes).
|
|
60
64
|
|
|
61
65
|
```bash
|
|
62
66
|
nc2shp -i FlowFM_net.nc
|
|
67
|
+
nc2shp -i mesh.nc -d
|
|
63
68
|
nc2shp -i mesh.nc -o output --crs EPSG:4326
|
|
64
69
|
nc2shp -i mesh.nc -q
|
|
65
70
|
```
|
|
@@ -89,6 +94,51 @@ transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -o
|
|
|
89
94
|
transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -q
|
|
90
95
|
```
|
|
91
96
|
|
|
97
|
+
### setncrain
|
|
98
|
+
|
|
99
|
+
Point a D-Flow FM model at a NetCDF rainfall forcing file.
|
|
100
|
+
|
|
101
|
+
Every `[Meteo]` block in the `.ext` file is rewritten to
|
|
102
|
+
`quantity=rainfall`, `forcingFile=<your .nc>`, `forcingFileType=netcdf`.
|
|
103
|
+
If the `.ext` file does not exist it is created from a built-in template and
|
|
104
|
+
registered in the `.mdu` as `ExtForceFileNew`.
|
|
105
|
+
|
|
106
|
+
The NetCDF time axis is read and the model times in the `.mdu` are set to match it
|
|
107
|
+
(expressed in the model's `Tunit`):
|
|
108
|
+
|
|
109
|
+
| Key | Value |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `RefDate` | midnight of the first time stamp |
|
|
112
|
+
| `TStart` | offset of the first time stamp from `RefDate` |
|
|
113
|
+
| `TStop` | offset of the last time stamp from `RefDate` |
|
|
114
|
+
|
|
115
|
+
The `forcingFile` path is written relative to the `.ext` file (D-Flow FM resolves it
|
|
116
|
+
that way); use `--as-given` to write it exactly as typed. `.bak` copies of the
|
|
117
|
+
modified files are written unless `--no-backup` is given.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
setncrain -i May28_Event.nc
|
|
121
|
+
setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
|
|
122
|
+
setncrain -i May28_Event.nc --mdu dflowfm/FM_model.mdu --no-time
|
|
123
|
+
setncrain -i May28_Event.nc --no-backup --as-given
|
|
124
|
+
setncrain -i May28_Event.nc -q
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### rnxml
|
|
128
|
+
|
|
129
|
+
Rename `dimr.xml` to `dimr_config.xml`, the name the DIMR runner expects. The file
|
|
130
|
+
stays in its folder and its contents are not touched.
|
|
131
|
+
|
|
132
|
+
If `dimr_config.xml` already exists the rename is refused; pass `--force` to
|
|
133
|
+
overwrite it (a `.bak` copy of the old target is kept unless `--no-backup`).
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rnxml
|
|
137
|
+
rnxml -i model/dimr.xml
|
|
138
|
+
rnxml -i dimr.xml -o dimr_config.xml --force
|
|
139
|
+
rnxml -i dimr.xml -q
|
|
140
|
+
```
|
|
141
|
+
|
|
92
142
|
## Python API
|
|
93
143
|
|
|
94
144
|
```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
|
|
|
@@ -40,10 +42,13 @@ meshinfo -h
|
|
|
40
42
|
|
|
41
43
|
### nc2shp
|
|
42
44
|
|
|
43
|
-
Convert a NetCDF mesh file to ESRI Shapefiles. Outputs `{stem}_faces.shp`
|
|
45
|
+
Convert a NetCDF mesh file to ESRI Shapefiles. Outputs `{stem}_faces.shp` in the output
|
|
46
|
+
directory. Pass `-d`/`--dissolve` to additionally write `{stem}_dissolved.shp`, a single
|
|
47
|
+
polygon dissolved from all mesh faces (slower on large meshes).
|
|
44
48
|
|
|
45
49
|
```bash
|
|
46
50
|
nc2shp -i FlowFM_net.nc
|
|
51
|
+
nc2shp -i mesh.nc -d
|
|
47
52
|
nc2shp -i mesh.nc -o output --crs EPSG:4326
|
|
48
53
|
nc2shp -i mesh.nc -q
|
|
49
54
|
```
|
|
@@ -73,6 +78,51 @@ transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -o
|
|
|
73
78
|
transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -q
|
|
74
79
|
```
|
|
75
80
|
|
|
81
|
+
### setncrain
|
|
82
|
+
|
|
83
|
+
Point a D-Flow FM model at a NetCDF rainfall forcing file.
|
|
84
|
+
|
|
85
|
+
Every `[Meteo]` block in the `.ext` file is rewritten to
|
|
86
|
+
`quantity=rainfall`, `forcingFile=<your .nc>`, `forcingFileType=netcdf`.
|
|
87
|
+
If the `.ext` file does not exist it is created from a built-in template and
|
|
88
|
+
registered in the `.mdu` as `ExtForceFileNew`.
|
|
89
|
+
|
|
90
|
+
The NetCDF time axis is read and the model times in the `.mdu` are set to match it
|
|
91
|
+
(expressed in the model's `Tunit`):
|
|
92
|
+
|
|
93
|
+
| Key | Value |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `RefDate` | midnight of the first time stamp |
|
|
96
|
+
| `TStart` | offset of the first time stamp from `RefDate` |
|
|
97
|
+
| `TStop` | offset of the last time stamp from `RefDate` |
|
|
98
|
+
|
|
99
|
+
The `forcingFile` path is written relative to the `.ext` file (D-Flow FM resolves it
|
|
100
|
+
that way); use `--as-given` to write it exactly as typed. `.bak` copies of the
|
|
101
|
+
modified files are written unless `--no-backup` is given.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
setncrain -i May28_Event.nc
|
|
105
|
+
setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
|
|
106
|
+
setncrain -i May28_Event.nc --mdu dflowfm/FM_model.mdu --no-time
|
|
107
|
+
setncrain -i May28_Event.nc --no-backup --as-given
|
|
108
|
+
setncrain -i May28_Event.nc -q
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### rnxml
|
|
112
|
+
|
|
113
|
+
Rename `dimr.xml` to `dimr_config.xml`, the name the DIMR runner expects. The file
|
|
114
|
+
stays in its folder and its contents are not touched.
|
|
115
|
+
|
|
116
|
+
If `dimr_config.xml` already exists the rename is refused; pass `--force` to
|
|
117
|
+
overwrite it (a `.bak` copy of the old target is kept unless `--no-backup`).
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
rnxml
|
|
121
|
+
rnxml -i model/dimr.xml
|
|
122
|
+
rnxml -i dimr.xml -o dimr_config.xml --force
|
|
123
|
+
rnxml -i dimr.xml -q
|
|
124
|
+
```
|
|
125
|
+
|
|
76
126
|
## Python API
|
|
77
127
|
|
|
78
128
|
```python
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
NCFTOOLS - A collection of tools for working with NetCDF files.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = '0.
|
|
5
|
+
__version__ = '0.9.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
|
|
@@ -21,12 +21,14 @@ TOOL_DESCRIPTIONS = {
|
|
|
21
21
|
'nc2shp': """
|
|
22
22
|
Convert a NetCDF mesh file to ESRI Shapefiles.
|
|
23
23
|
|
|
24
|
-
Reads a UGRID-compliant NetCDF mesh file and writes
|
|
24
|
+
Reads a UGRID-compliant NetCDF mesh file and writes:
|
|
25
25
|
{stem}_faces.shp one polygon per mesh face
|
|
26
26
|
{stem}_dissolved.shp single dissolved polygon of the entire mesh
|
|
27
|
+
(only written with -d/--dissolve)
|
|
27
28
|
|
|
28
29
|
Examples:
|
|
29
30
|
nc2shp -i FlowFM_net.nc
|
|
31
|
+
nc2shp -i mesh.nc -d # also write the dissolved polygon
|
|
30
32
|
nc2shp -i mesh.nc -o output --crs EPSG:4326
|
|
31
33
|
nc2shp -i mesh.nc -q
|
|
32
34
|
""",
|
|
@@ -58,6 +60,43 @@ TOOL_DESCRIPTIONS = {
|
|
|
58
60
|
transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_faces.shp -o SHP_TRANS
|
|
59
61
|
transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_faces.shp -q
|
|
60
62
|
""",
|
|
63
|
+
'setncrain': """
|
|
64
|
+
Point a D-Flow FM model at a NetCDF rainfall forcing file.
|
|
65
|
+
|
|
66
|
+
Rewrites every [Meteo] block of the .ext file to use the given NetCDF
|
|
67
|
+
file as rainfall forcing:
|
|
68
|
+
quantity=rainfall, forcingFile=<*.nc>, forcingFileType=netcdf
|
|
69
|
+
If the .ext file is missing it is created from a built-in template and
|
|
70
|
+
registered in the .mdu as ExtForceFileNew.
|
|
71
|
+
|
|
72
|
+
The NetCDF time axis is read and the model times in the .mdu are set
|
|
73
|
+
to match it (in the model's Tunit):
|
|
74
|
+
RefDate midnight of the first time stamp
|
|
75
|
+
TStart offset of the first time stamp from RefDate
|
|
76
|
+
TStop offset of the last time stamp from RefDate
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
setncrain -i May28_Event.nc
|
|
80
|
+
setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
|
|
81
|
+
setncrain -i May28_Event.nc --no-time
|
|
82
|
+
setncrain -i May28_Event.nc --no-backup --as-given
|
|
83
|
+
""",
|
|
84
|
+
'rnxml': """
|
|
85
|
+
Rename dimr.xml to dimr_config.xml.
|
|
86
|
+
|
|
87
|
+
D-HYDRO / Delft3D FM writes its DIMR control file as dimr.xml, while
|
|
88
|
+
the DIMR runner expects dimr_config.xml. This tool renames the file in
|
|
89
|
+
place, leaving its contents untouched.
|
|
90
|
+
|
|
91
|
+
If the target name already exists the rename is refused unless --force
|
|
92
|
+
is given, in which case a .bak copy of the old target is kept.
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
rnxml
|
|
96
|
+
rnxml -i model/dimr.xml
|
|
97
|
+
rnxml -i dimr.xml -o dimr_config.xml --force
|
|
98
|
+
rnxml -i dimr.xml -q
|
|
99
|
+
""",
|
|
61
100
|
}
|
|
62
101
|
|
|
63
102
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
"""
|
|
3
3
|
nc2shp - Convert NetCDF mesh faces to ESRI Shapefiles
|
|
4
4
|
|
|
5
|
-
Reads a UGRID-compliant NetCDF mesh file and writes
|
|
5
|
+
Reads a UGRID-compliant NetCDF mesh file and writes:
|
|
6
6
|
{stem}_faces.shp - one polygon per mesh face
|
|
7
7
|
{stem}_dissolved.shp - single dissolved polygon of the entire mesh
|
|
8
|
+
(only with --dissolve)
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import argparse
|
|
@@ -149,25 +150,30 @@ def dissolve_geodataframe(gdf, quiet=False):
|
|
|
149
150
|
return dissolved
|
|
150
151
|
|
|
151
152
|
|
|
152
|
-
def mesh_to_shp(input_file, output_dir="SHP_NC", crs="EPSG:3826", quiet=False
|
|
153
|
+
def mesh_to_shp(input_file, output_dir="SHP_NC", crs="EPSG:3826", quiet=False,
|
|
154
|
+
dissolve=False):
|
|
153
155
|
"""
|
|
154
|
-
Convert a NetCDF mesh file to face and dissolved shapefiles.
|
|
156
|
+
Convert a NetCDF mesh file to face and (optionally) dissolved shapefiles.
|
|
155
157
|
|
|
156
158
|
Args:
|
|
157
159
|
input_file (str): Path to the input NetCDF file.
|
|
158
160
|
output_dir (str): Directory for output shapefiles.
|
|
159
161
|
crs (str): CRS for output shapefiles.
|
|
160
162
|
quiet (bool): Suppress non-error output.
|
|
163
|
+
dissolve (bool): Also write a single dissolved polygon shapefile.
|
|
161
164
|
|
|
162
165
|
Returns:
|
|
163
|
-
tuple[str, str]: Paths to (faces_shp, dissolved_shp).
|
|
166
|
+
tuple[str, str | None]: Paths to (faces_shp, dissolved_shp). The second
|
|
167
|
+
element is None when dissolve is False.
|
|
164
168
|
"""
|
|
165
169
|
_print("=== NetCDF Mesh to Shapefile Converter ===", quiet)
|
|
166
170
|
|
|
167
171
|
os.makedirs(output_dir, exist_ok=True)
|
|
168
172
|
stem = os.path.splitext(os.path.basename(input_file))[0]
|
|
169
173
|
out_faces = os.path.join(output_dir, f"{stem}_faces.shp")
|
|
170
|
-
out_dissolved =
|
|
174
|
+
out_dissolved = (
|
|
175
|
+
os.path.join(output_dir, f"{stem}_dissolved.shp") if dissolve else None
|
|
176
|
+
)
|
|
171
177
|
|
|
172
178
|
face_x, face_y, node_x, node_y, face_nodes, var_names = read_mesh_netcdf(
|
|
173
179
|
input_file, quiet
|
|
@@ -181,9 +187,10 @@ def mesh_to_shp(input_file, output_dir="SHP_NC", crs="EPSG:3826", quiet=False):
|
|
|
181
187
|
_print(f"Saving faces shapefile: {out_faces}", quiet)
|
|
182
188
|
gdf.to_file(out_faces)
|
|
183
189
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
if dissolve:
|
|
191
|
+
dissolved = dissolve_geodataframe(gdf, quiet)
|
|
192
|
+
_print(f"Saving dissolved shapefile: {out_dissolved}", quiet)
|
|
193
|
+
dissolved.to_file(out_dissolved)
|
|
187
194
|
|
|
188
195
|
if not quiet:
|
|
189
196
|
bounds = gdf.total_bounds
|
|
@@ -191,7 +198,8 @@ def mesh_to_shp(input_file, output_dir="SHP_NC", crs="EPSG:3826", quiet=False):
|
|
|
191
198
|
print("\n=== SUMMARY ===")
|
|
192
199
|
print(f" Input: {input_file}")
|
|
193
200
|
print(f" Faces: {out_faces}")
|
|
194
|
-
|
|
201
|
+
if dissolve:
|
|
202
|
+
print(f" Dissolved: {out_dissolved}")
|
|
195
203
|
print(f" Faces processed: {len(face_x):,} Valid: {len(polygons):,}")
|
|
196
204
|
print(f" CRS: {gdf.crs}")
|
|
197
205
|
for t, n in counts.items():
|
|
@@ -218,6 +226,7 @@ def main():
|
|
|
218
226
|
epilog="""
|
|
219
227
|
Examples:
|
|
220
228
|
nc2shp -i FlowFM_net.nc
|
|
229
|
+
nc2shp -i mesh.nc -d
|
|
221
230
|
nc2shp -i mesh.nc -o output --crs EPSG:4326
|
|
222
231
|
nc2shp -i mesh.nc -q
|
|
223
232
|
""",
|
|
@@ -245,6 +254,12 @@ Examples:
|
|
|
245
254
|
default='EPSG:3826',
|
|
246
255
|
help='Coordinate reference system (default: EPSG:3826)',
|
|
247
256
|
)
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
'-d', '--dissolve',
|
|
259
|
+
action='store_true',
|
|
260
|
+
help='Also write {stem}_dissolved.shp, a single polygon dissolved '
|
|
261
|
+
'from all mesh faces (slower on large meshes)',
|
|
262
|
+
)
|
|
248
263
|
parser.add_argument(
|
|
249
264
|
'-q', '--quiet',
|
|
250
265
|
action='store_true',
|
|
@@ -259,11 +274,12 @@ Examples:
|
|
|
259
274
|
|
|
260
275
|
try:
|
|
261
276
|
faces, dissolved = mesh_to_shp(
|
|
262
|
-
args.input, args.output_dir, args.crs, args.quiet
|
|
277
|
+
args.input, args.output_dir, args.crs, args.quiet, args.dissolve
|
|
263
278
|
)
|
|
264
279
|
if args.quiet:
|
|
265
280
|
print(faces)
|
|
266
|
-
|
|
281
|
+
if dissolved:
|
|
282
|
+
print(dissolved)
|
|
267
283
|
except Exception as e:
|
|
268
284
|
print(f"Error: {e}", file=sys.stderr)
|
|
269
285
|
sys.exit(1)
|
|
@@ -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.
|
|
3
|
+
Version: 0.9.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
|
|
|
@@ -56,10 +58,13 @@ meshinfo -h
|
|
|
56
58
|
|
|
57
59
|
### nc2shp
|
|
58
60
|
|
|
59
|
-
Convert a NetCDF mesh file to ESRI Shapefiles. Outputs `{stem}_faces.shp`
|
|
61
|
+
Convert a NetCDF mesh file to ESRI Shapefiles. Outputs `{stem}_faces.shp` in the output
|
|
62
|
+
directory. Pass `-d`/`--dissolve` to additionally write `{stem}_dissolved.shp`, a single
|
|
63
|
+
polygon dissolved from all mesh faces (slower on large meshes).
|
|
60
64
|
|
|
61
65
|
```bash
|
|
62
66
|
nc2shp -i FlowFM_net.nc
|
|
67
|
+
nc2shp -i mesh.nc -d
|
|
63
68
|
nc2shp -i mesh.nc -o output --crs EPSG:4326
|
|
64
69
|
nc2shp -i mesh.nc -q
|
|
65
70
|
```
|
|
@@ -89,6 +94,51 @@ transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -o
|
|
|
89
94
|
transzone2 -i SHP_NC/FlowFM_net_faces.shp -z SHP_TRANS/trans_zone_extend.shp -q
|
|
90
95
|
```
|
|
91
96
|
|
|
97
|
+
### setncrain
|
|
98
|
+
|
|
99
|
+
Point a D-Flow FM model at a NetCDF rainfall forcing file.
|
|
100
|
+
|
|
101
|
+
Every `[Meteo]` block in the `.ext` file is rewritten to
|
|
102
|
+
`quantity=rainfall`, `forcingFile=<your .nc>`, `forcingFileType=netcdf`.
|
|
103
|
+
If the `.ext` file does not exist it is created from a built-in template and
|
|
104
|
+
registered in the `.mdu` as `ExtForceFileNew`.
|
|
105
|
+
|
|
106
|
+
The NetCDF time axis is read and the model times in the `.mdu` are set to match it
|
|
107
|
+
(expressed in the model's `Tunit`):
|
|
108
|
+
|
|
109
|
+
| Key | Value |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `RefDate` | midnight of the first time stamp |
|
|
112
|
+
| `TStart` | offset of the first time stamp from `RefDate` |
|
|
113
|
+
| `TStop` | offset of the last time stamp from `RefDate` |
|
|
114
|
+
|
|
115
|
+
The `forcingFile` path is written relative to the `.ext` file (D-Flow FM resolves it
|
|
116
|
+
that way); use `--as-given` to write it exactly as typed. `.bak` copies of the
|
|
117
|
+
modified files are written unless `--no-backup` is given.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
setncrain -i May28_Event.nc
|
|
121
|
+
setncrain -i data/May28_Event.nc --ext dflowfm/FM_model_bnd.ext
|
|
122
|
+
setncrain -i May28_Event.nc --mdu dflowfm/FM_model.mdu --no-time
|
|
123
|
+
setncrain -i May28_Event.nc --no-backup --as-given
|
|
124
|
+
setncrain -i May28_Event.nc -q
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### rnxml
|
|
128
|
+
|
|
129
|
+
Rename `dimr.xml` to `dimr_config.xml`, the name the DIMR runner expects. The file
|
|
130
|
+
stays in its folder and its contents are not touched.
|
|
131
|
+
|
|
132
|
+
If `dimr_config.xml` already exists the rename is refused; pass `--force` to
|
|
133
|
+
overwrite it (a `.bak` copy of the old target is kept unless `--no-backup`).
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rnxml
|
|
137
|
+
rnxml -i model/dimr.xml
|
|
138
|
+
rnxml -i dimr.xml -o dimr_config.xml --force
|
|
139
|
+
rnxml -i dimr.xml -q
|
|
140
|
+
```
|
|
141
|
+
|
|
92
142
|
## Python API
|
|
93
143
|
|
|
94
144
|
```python
|
|
@@ -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.
|
|
7
|
+
version = "0.9.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|