cht_utils 2.0.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,326 @@
1
+ """Reader and writer for Deltares-style INI configuration files.
2
+
3
+ These files consist of ``[Section]`` blocks containing keyword-value pairs
4
+ and optional data tables.
5
+ """
6
+
7
+ import datetime
8
+ import re
9
+ from typing import Any, List, Optional
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+
15
+ class Section:
16
+ """A single section within an INI file.
17
+
18
+ Parameters
19
+ ----------
20
+ name : str or None
21
+ Section name (text between square brackets).
22
+ """
23
+
24
+ def __init__(self, name: Optional[str] = None) -> None:
25
+ self.name: Optional[str] = name
26
+ self.keyword: List["Keyword"] = []
27
+ self.data: Optional[pd.DataFrame] = None
28
+
29
+ def get_value(self, keyword: str) -> Optional[Any]:
30
+ """Return the value of a keyword in this section.
31
+
32
+ Parameters
33
+ ----------
34
+ keyword : str
35
+ Keyword name (case-insensitive).
36
+
37
+ Returns
38
+ -------
39
+ Any or None
40
+ The keyword value, or ``None`` if not found.
41
+ """
42
+ for kw in self.keyword:
43
+ if kw.name.lower() == keyword.lower():
44
+ return kw.value
45
+ return None
46
+
47
+
48
+ class Keyword:
49
+ """A key-value pair within an INI section.
50
+
51
+ Parameters
52
+ ----------
53
+ name : str or None
54
+ Keyword name.
55
+ value : Any
56
+ Keyword value.
57
+ comment : str or None
58
+ Inline comment.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ name: Optional[str] = None,
64
+ value: Any = None,
65
+ comment: Optional[str] = None,
66
+ ) -> None:
67
+ self.name = name
68
+ self.value = value
69
+ self.comment = comment
70
+
71
+
72
+ class IniStruct:
73
+ """Deltares INI file parser and writer.
74
+
75
+ Parameters
76
+ ----------
77
+ filename : str or None
78
+ If provided, the file is read immediately on construction.
79
+ """
80
+
81
+ def __init__(self, filename: Optional[str] = None) -> None:
82
+ self.section: List[Section] = []
83
+ if filename:
84
+ self.read(filename)
85
+
86
+ def read(self, filename: str) -> None:
87
+ """Parse an INI file into sections, keywords, and data tables.
88
+
89
+ Parameters
90
+ ----------
91
+ filename : str
92
+ Path to the INI file.
93
+ """
94
+ self.section = []
95
+ istart: List[int] = []
96
+
97
+ with open(filename, "r") as fid:
98
+ lines = fid.readlines()
99
+
100
+ # Find section starts
101
+ for i, line in enumerate(lines):
102
+ ll = line.strip()
103
+ if len(ll) == 0:
104
+ continue
105
+ if ll[0] == "[" and ll[-1] == "]":
106
+ sec = Section(name=ll[1:-1])
107
+ istart.append(i)
108
+ self.section.append(sec)
109
+
110
+ # Parse each section
111
+ for isec in range(len(self.section)):
112
+ i1 = istart[isec] + 1
113
+ i2 = istart[isec + 1] - 1 if isec < len(self.section) - 1 else len(lines)
114
+
115
+ df = pd.DataFrame()
116
+
117
+ for iline in range(i1, i2):
118
+ ll = lines[iline].strip()
119
+ if len(ll) == 0 or ll[0] == "#":
120
+ continue
121
+
122
+ if "=" in ll:
123
+ key = Keyword()
124
+
125
+ # Handle embedded # in values (e.g. hex colours)
126
+ if "#" in ll:
127
+ ipos = [m.start() for m in re.finditer("#", ll)]
128
+ if len(ipos) > 1:
129
+ ll = (
130
+ ll[: ipos[0]]
131
+ + ll[ipos[0] + 1 : ipos[1]]
132
+ + ll[ipos[1] + 1 :]
133
+ )
134
+
135
+ if "#" in ll:
136
+ j = ll.index("#")
137
+ key.comment = ll[j + 1 :].strip()
138
+ ll = ll[:j].strip()
139
+
140
+ tx = ll.split("=")
141
+ key.name = tx[0].strip()
142
+ key.value = tx[1].strip()
143
+ self.section[isec].keyword.append(key)
144
+ else:
145
+ a_list = ll.split()
146
+ values = []
147
+ for item in a_list:
148
+ try:
149
+ values.append(float(item))
150
+ except ValueError:
151
+ values.append(item)
152
+ df = pd.concat([df, pd.Series(values)], axis=1)
153
+
154
+ if not df.empty:
155
+ df = df.transpose().set_index([0])
156
+ self.section[isec].data = df
157
+
158
+ def write(self, file_name: str) -> None:
159
+ """Write the INI structure back to a file.
160
+
161
+ Parameters
162
+ ----------
163
+ file_name : str
164
+ Output file path.
165
+ """
166
+ try:
167
+ fid = open(file_name, "w")
168
+ except OSError:
169
+ print(f"Warning! Could not create file: {file_name}")
170
+ return
171
+
172
+ try:
173
+ for section in self.section:
174
+ fid.write(f"[{section.name}]\n")
175
+
176
+ for kw in section.keyword:
177
+ valstr = ""
178
+ if kw.value is not None:
179
+ if isinstance(kw.value, (float, int)):
180
+ valstr = str(kw.value)
181
+ elif isinstance(kw.value, datetime.date):
182
+ valstr = kw.value.strftime("%Y%m%d")
183
+ else:
184
+ valstr = kw.value
185
+
186
+ kwstr = f" {kw.name:<20s} = "
187
+ valstr = f"{valstr:<30s}"
188
+ comstr = f" # {kw.comment}" if kw.comment else ""
189
+ fid.write(f"{kwstr}{valstr}{comstr}\n")
190
+
191
+ if section.data is not None:
192
+ tt = section.data.index.to_numpy()
193
+ vv = section.data.array.to_numpy()
194
+ for it, v in enumerate(vv):
195
+ if np.isnan(v):
196
+ v = -999.0
197
+ fid.write(f"{tt[it]:12.1f}{v:12.3f}\n")
198
+
199
+ fid.write("\n")
200
+ finally:
201
+ fid.close()
202
+
203
+ def get_value(self, section_name: str, keyword_name: str) -> Optional[Any]:
204
+ """Get a keyword value from a named section.
205
+
206
+ Parameters
207
+ ----------
208
+ section_name : str
209
+ Section name.
210
+ keyword_name : str
211
+ Keyword name.
212
+
213
+ Returns
214
+ -------
215
+ Any or None
216
+ The keyword value, or ``None`` if not found.
217
+ """
218
+ for section in self.section:
219
+ if section.name == section_name:
220
+ for kw in section.keyword:
221
+ if kw.name == keyword_name:
222
+ return kw.value
223
+ return None
224
+
225
+ def set_value(
226
+ self,
227
+ section_name: str,
228
+ keyword_name: str,
229
+ keyword_value: Any,
230
+ keyword_comment: Optional[str] = None,
231
+ ) -> None:
232
+ """Set a keyword value, creating the section or keyword if needed.
233
+
234
+ Parameters
235
+ ----------
236
+ section_name : str
237
+ Section name.
238
+ keyword_name : str
239
+ Keyword name.
240
+ keyword_value : Any
241
+ New value.
242
+ keyword_comment : str or None
243
+ Optional inline comment.
244
+ """
245
+ for section in self.section:
246
+ if section.name == section_name:
247
+ for kw in section.keyword:
248
+ if kw.name == keyword_name:
249
+ kw.value = keyword_value
250
+ return
251
+ kw = Keyword(keyword_name, keyword_value, keyword_comment)
252
+ section.keyword.append(kw)
253
+ return
254
+
255
+ kw = Keyword(keyword_name, keyword_value, keyword_comment)
256
+ section = Section(section_name)
257
+ section.keyword.append(kw)
258
+ self.section.append(section)
259
+
260
+ def get_data(
261
+ self,
262
+ section_name: str,
263
+ keyword_list: Optional[List[str]] = None,
264
+ value_list: Optional[List[str]] = None,
265
+ ) -> Optional[pd.DataFrame]:
266
+ """Return the data table from a matching section.
267
+
268
+ Parameters
269
+ ----------
270
+ section_name : str
271
+ Section name.
272
+ keyword_list : List[str] or None
273
+ Keywords to match for disambiguation.
274
+ value_list : List[str] or None
275
+ Values that the keywords must have.
276
+
277
+ Returns
278
+ -------
279
+ pd.DataFrame or None
280
+ """
281
+ for section in self.section:
282
+ if section.name == section_name:
283
+ if keyword_list and value_list:
284
+ ok = True
285
+ for key, val in zip(keyword_list, value_list):
286
+ for kw in section.keyword:
287
+ if kw.name == key and kw.value != val:
288
+ ok = False
289
+ if not ok:
290
+ continue
291
+ return section.data
292
+ return None
293
+
294
+ def get_section(
295
+ self,
296
+ section_name: str,
297
+ keyword_list: Optional[List[str]] = None,
298
+ value_list: Optional[List[str]] = None,
299
+ ) -> Optional[Section]:
300
+ """Return the first section matching the given name and filters.
301
+
302
+ Parameters
303
+ ----------
304
+ section_name : str
305
+ Section name.
306
+ keyword_list : List[str] or None
307
+ Keywords to match for disambiguation.
308
+ value_list : List[str] or None
309
+ Values that the keywords must have.
310
+
311
+ Returns
312
+ -------
313
+ Section or None
314
+ """
315
+ for section in self.section:
316
+ if section.name == section_name:
317
+ if keyword_list and value_list:
318
+ ok = True
319
+ for key, val in zip(keyword_list, value_list):
320
+ for kw in section.keyword:
321
+ if kw.name == key and kw.value != val:
322
+ ok = False
323
+ if not ok:
324
+ continue
325
+ return section
326
+ return None
@@ -0,0 +1,72 @@
1
+ """I/O for JavaScript-wrapped JSON and CSV files.
2
+
3
+ These formats prepend a JavaScript variable declaration line before the actual
4
+ JSON or CSV payload.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from typing import Any, Union
10
+
11
+
12
+ def read_json_js(file_name: str) -> Any:
13
+ """Read a JavaScript-wrapped JSON file (skips the first line).
14
+
15
+ Parameters
16
+ ----------
17
+ file_name : str
18
+ Input file path.
19
+
20
+ Returns
21
+ -------
22
+ Any
23
+ Parsed JSON object.
24
+ """
25
+ with open(file_name, "r") as f:
26
+ lines = f.readlines()
27
+ jsn_string = "".join(line.strip() for line in lines[1:])
28
+ return json.loads(jsn_string)
29
+
30
+
31
+ def write_json_js(file_name: str, jsn: Union[list, dict], first_line: str) -> None:
32
+ """Write a JSON object to a JavaScript-wrapped file.
33
+
34
+ Parameters
35
+ ----------
36
+ file_name : str
37
+ Output file path.
38
+ jsn : list or dict
39
+ JSON-serializable data.
40
+ first_line : str
41
+ JavaScript variable declaration line (e.g. ``"var data ="``)
42
+ """
43
+ with open(file_name, "w") as f:
44
+ f.write(first_line + "\n")
45
+ if isinstance(jsn, list):
46
+ f.write("[\n")
47
+ for ix, item in enumerate(jsn):
48
+ sep = "," if ix < len(jsn) - 1 else ""
49
+ f.write(json.dumps(item) + sep + "\n")
50
+ f.write("]\n")
51
+ else:
52
+ f.write(json.dumps(jsn) + "\n")
53
+
54
+
55
+ def write_csv_js(file_name: str, csv_string: str, first_line: str) -> None:
56
+ """Write a CSV string to a JavaScript-wrapped file.
57
+
58
+ Parameters
59
+ ----------
60
+ file_name : str
61
+ Output file path.
62
+ csv_string : str
63
+ CSV content.
64
+ first_line : str
65
+ JavaScript variable declaration line.
66
+ """
67
+ os.makedirs(os.path.dirname(file_name), exist_ok=True)
68
+ csv_string = csv_string.replace(chr(13), "")
69
+ with open(file_name, "w") as f:
70
+ f.write(first_line + "\n")
71
+ f.write(csv_string)
72
+ f.write("`;")
@@ -0,0 +1,233 @@
1
+ """Read and write Deltares PLI/POL files (polyline and polygon data).
2
+
3
+ PLI files store polylines; POL files store polygons. Both use the Tekal
4
+ block format internally.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ import geopandas as gpd
10
+ import pandas as pd
11
+ import shapely.geometry
12
+
13
+ import cht_utils.fileio.tekal as tek
14
+
15
+
16
+ class Polyline:
17
+ """Simple polyline wrapper with x/y coordinate arrays.
18
+
19
+ Parameters
20
+ ----------
21
+ x : array-like
22
+ X-coordinates.
23
+ y : array-like
24
+ Y-coordinates.
25
+ """
26
+
27
+ def __init__(self, x, y) -> None:
28
+ self.x = x
29
+ self.y = y
30
+
31
+
32
+ def read_pli_file(file_name: str) -> list:
33
+ """Read all polylines from a PLI file.
34
+
35
+ Parameters
36
+ ----------
37
+ file_name : str
38
+ Path to the PLI file.
39
+
40
+ Returns
41
+ -------
42
+ list of Polyline
43
+ List of polyline objects.
44
+ """
45
+ polylines = []
46
+ D = tek.tekal(file_name)
47
+ D.info()
48
+ for j in range(len(D.blocks)):
49
+ m = D.read(j)
50
+ polylines.append(Polyline(m[0, :, 0], m[1, :, 0]))
51
+ return polylines
52
+
53
+
54
+ def pli2gdf(
55
+ file_name: str,
56
+ crs: Optional[int] = None,
57
+ name_string: str = "name",
58
+ ) -> gpd.GeoDataFrame:
59
+ """Convert a PLI file to a GeoDataFrame of LineStrings.
60
+
61
+ Parameters
62
+ ----------
63
+ file_name : str
64
+ Path to the PLI file.
65
+ crs : int or None
66
+ Coordinate reference system (EPSG code).
67
+ name_string : str
68
+ Column name for the polyline name attribute.
69
+
70
+ Returns
71
+ -------
72
+ gpd.GeoDataFrame
73
+ """
74
+ D = tek.tekal(file_name)
75
+ D.info()
76
+ gdf_list = []
77
+ for j in range(len(D.blocks)):
78
+ name = D.blocks[j].name
79
+ if isinstance(name, bytes):
80
+ name = name.decode("utf-8")
81
+ m = D.read(j)
82
+ x, y = m[0, :, 0], m[1, :, 0]
83
+ line = shapely.geometry.LineString(list(zip(x, y)))
84
+ gdf_list.append({name_string: name, "geometry": line})
85
+ return gpd.GeoDataFrame(gdf_list, crs=crs)
86
+
87
+
88
+ def pli2geojson(
89
+ file_name_in: str,
90
+ file_name_out: str,
91
+ crs: Optional[int] = None,
92
+ ) -> None:
93
+ """Convert a PLI file to GeoJSON.
94
+
95
+ Parameters
96
+ ----------
97
+ file_name_in : str
98
+ Input PLI file path.
99
+ file_name_out : str
100
+ Output GeoJSON file path.
101
+ crs : int or None
102
+ Coordinate reference system (EPSG code).
103
+ """
104
+ gdf = pli2gdf(file_name_in, crs=crs)
105
+ gdf.to_file(file_name_out, driver="GeoJSON")
106
+
107
+
108
+ def pol2gdf(
109
+ file_name: str,
110
+ crs: Optional[int] = None,
111
+ header: bool = True,
112
+ ) -> gpd.GeoDataFrame:
113
+ """Convert a POL file to a GeoDataFrame of Polygons.
114
+
115
+ Parameters
116
+ ----------
117
+ file_name : str
118
+ Path to the POL file.
119
+ crs : int or None
120
+ Coordinate reference system (EPSG code).
121
+ header : bool
122
+ If ``False``, read as plain whitespace-separated x/y columns.
123
+
124
+ Returns
125
+ -------
126
+ gpd.GeoDataFrame
127
+ """
128
+ gdf_list = []
129
+ if not header:
130
+ df = pd.read_csv(
131
+ file_name,
132
+ index_col=False,
133
+ header=None,
134
+ delim_whitespace=True,
135
+ names=["x", "y"],
136
+ )
137
+ poly = shapely.geometry.Polygon(list(zip(df.x.values, df.y.values)))
138
+ gdf_list.append({"geometry": poly})
139
+ else:
140
+ D = tek.tekal(file_name)
141
+ D.info()
142
+ for j in range(len(D.blocks)):
143
+ m = D.read(j)
144
+ x, y = m[0, :, 0], m[1, :, 0]
145
+ poly = shapely.geometry.Polygon(list(zip(x, y)))
146
+ gdf_list.append({"geometry": poly})
147
+ return gpd.GeoDataFrame(gdf_list, crs=crs)
148
+
149
+
150
+ def pol2geojson(
151
+ file_name_in: str,
152
+ file_name_out: str,
153
+ crs: Optional[int] = None,
154
+ ) -> None:
155
+ """Convert a POL file to GeoJSON.
156
+
157
+ Parameters
158
+ ----------
159
+ file_name_in : str
160
+ Input POL file path.
161
+ file_name_out : str
162
+ Output GeoJSON file path.
163
+ crs : int or None
164
+ Coordinate reference system (EPSG code).
165
+ """
166
+ gdf = pol2gdf(file_name_in, crs=crs)
167
+ gdf.to_file(file_name_out, driver="GeoJSON")
168
+
169
+
170
+ def gdf2pli(
171
+ gdf: gpd.GeoDataFrame,
172
+ file_name: str,
173
+ header: bool = True,
174
+ name_string: str = "name",
175
+ add_point_name: bool = False,
176
+ ) -> None:
177
+ """Write a GeoDataFrame of LineStrings to a PLI file.
178
+
179
+ Parameters
180
+ ----------
181
+ gdf : gpd.GeoDataFrame
182
+ GeoDataFrame with LineString geometries.
183
+ file_name : str
184
+ Output PLI file path.
185
+ header : bool
186
+ Write Tekal block headers.
187
+ name_string : str
188
+ Column containing the polyline name.
189
+ add_point_name : bool
190
+ Append vertex names (``NAME_0001``, etc.) to each coordinate line.
191
+ """
192
+ fmt = "{:12.6f}" if gdf.crs.is_geographic else "{:12.1f}"
193
+
194
+ with open(file_name, "w") as fid:
195
+ for index, row in gdf.iterrows():
196
+ coords = list(row["geometry"].coords)
197
+ nrp = len(coords)
198
+ if header:
199
+ hdr = row.get(name_string, f"BL{index + 1:04d}")
200
+ fid.write(f"{hdr}\n{nrp} 2\n")
201
+ for ip, (x, y) in enumerate(coords):
202
+ line = fmt.format(x) + fmt.format(y)
203
+ if add_point_name:
204
+ line += f" {hdr}_{ip + 1:04d}"
205
+ fid.write(line + "\n")
206
+
207
+
208
+ def gdf2pol(
209
+ gdf: gpd.GeoDataFrame,
210
+ file_name: str,
211
+ header: bool = True,
212
+ ) -> None:
213
+ """Write a GeoDataFrame of Polygons to a POL file.
214
+
215
+ Parameters
216
+ ----------
217
+ gdf : gpd.GeoDataFrame
218
+ GeoDataFrame with Polygon geometries.
219
+ file_name : str
220
+ Output POL file path.
221
+ header : bool
222
+ Write Tekal block headers.
223
+ """
224
+ fmt = "{:12.6f}" if gdf.crs.is_geographic else "{:12.1f}"
225
+
226
+ with open(file_name, "w") as fid:
227
+ for index, row in gdf.iterrows():
228
+ coords = list(row["geometry"].exterior.coords)
229
+ nrp = len(coords)
230
+ if header:
231
+ fid.write(f"BL{index + 1:04d}\n{nrp} 2\n")
232
+ for x, y in coords:
233
+ fid.write(fmt.format(x) + fmt.format(y) + "\n")