Coverage for C:\src\imod-python\imod\formats\ipf.py: 89%

257 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-08 14:15 +0200

1""" 

2Functions for reading and writing iMOD Point Files (IDFs) to ``pandas.DataFrame``. 

3 

4The primary functions to use are :func:`imod.ipf.read` and 

5:func:`imod.ipf.save`, though lower level functions are also available. 

6""" 

7 

8import collections 

9import csv 

10import glob 

11import io 

12import pathlib 

13import warnings 

14from typing import Tuple 

15 

16import numpy as np 

17import pandas as pd 

18 

19import imod 

20 

21 

22def _infer_delimwhitespace(line, ncol): 

23 n_elem = len(next(csv.reader([line]))) 

24 if n_elem == 1: 

25 return True 

26 elif n_elem == ncol: 

27 return False 

28 else: 

29 warnings.warn( 

30 f"Inconsistent IPF: header states {ncol} columns, first line contains {n_elem}" 

31 ) 

32 return False 

33 

34 

35def _read_ipf(path, kwargs=None) -> Tuple[pd.DataFrame, int, str]: 

36 path = pathlib.Path(path) 

37 if kwargs is None: 

38 kwargs = {} 

39 

40 with open(path) as f: 

41 nrow = int(f.readline().strip()) 

42 ncol = int(f.readline().strip()) 

43 colnames = [f.readline().strip().strip("'").strip('"') for _ in range(ncol)] 

44 line = f.readline() 

45 try: 

46 # csv.reader parse one line 

47 # this catches commas in quotes 

48 indexcol, ext = map(str.strip, next(csv.reader([line]))) 

49 except ValueError: # then try whitespace delimited 

50 indexcol, ext = map(str.strip, next(csv.reader([line], delimiter=" "))) 

51 

52 position = f.tell() 

53 line = f.readline() 

54 delim_whitespace = _infer_delimwhitespace(line, ncol) 

55 f.seek(position) 

56 

57 ipf_kwargs = { 

58 "delim_whitespace": delim_whitespace, 

59 "header": None, 

60 "names": colnames, 

61 "nrows": nrow, 

62 "skipinitialspace": True, 

63 } 

64 ipf_kwargs.update(kwargs) 

65 df = pd.read_csv(f, **ipf_kwargs) 

66 

67 return df, int(indexcol), ext 

68 

69 

70def _read(path, kwargs=None, assoc_kwargs=None): 

71 """ 

72 Read one IPF file to a single pandas.DataFrame, including associated (TXT) files. 

73 

74 Parameters 

75 ---------- 

76 path: pathlib.Path or str 

77 globpath for IPF files to read. 

78 kwargs : dict 

79 Dictionary containing the ``pandas.read_csv()`` keyword arguments for the 

80 IPF files (e.g. `{"delim_whitespace": True}`) 

81 assoc_kwargs: dict 

82 Dictionary containing the ``pandas.read_csv()`` keyword arguments for the 

83 associated (TXT) files (e.g. `{"delim_whitespace": True}`) 

84 

85 Returns 

86 ------- 

87 pandas.DataFrame 

88 """ 

89 df, indexcol, ext = _read_ipf(path, kwargs) 

90 if assoc_kwargs is None: 

91 assoc_kwargs = {} 

92 

93 # See if reading associated files is necessary 

94 if indexcol > 1: 

95 colnames = df.columns 

96 # df = pd.read_csv(f, header=None, names=colnames, nrows=nrow, **kwargs) 

97 dfs = [] 

98 for row in df.itertuples(): 

99 filename = row[indexcol] 

100 # associate paths are relative to the ipf 

101 path_assoc = path.parent.joinpath(f"{filename}.{ext}") 

102 # Note that these kwargs handle all associated files, which might differ 

103 # within an IPF. If this happens we could consider supporting a dict 

104 # or function that maps assoc filenames to different kwargs. 

105 try: # Capture the error and print the offending path 

106 df_assoc = read_associated(path_assoc, assoc_kwargs) 

107 except Exception as e: 

108 raise type(e)( 

109 f'{e}\nWhile reading associated file "{path_assoc}" of IPF file "{path}"' 

110 ) from e 

111 

112 # Include records of the "mother" ipf file. 

113 for name, value in zip(colnames, row[1:]): # ignores df.index in row 

114 df_assoc[name] = value 

115 # Append to list 

116 dfs.append(df_assoc) 

117 # Merge into a single whole 

118 df = pd.concat(dfs, ignore_index=True, sort=False) 

119 

120 return df 

121 

122 

123def read_associated(path, kwargs={}): 

124 """ 

125 Read an IPF associated file (TXT). 

126 

127 Parameters 

128 ---------- 

129 path : pathlib.Path or str 

130 Path to associated file. 

131 kwargs : dict 

132 Dictionary containing the ``pandas.read_csv()`` keyword arguments for the 

133 associated (TXT) file (e.g. `{"delim_whitespace": True}`). 

134 

135 Returns 

136 ------- 

137 pandas.DataFrame 

138 """ 

139 

140 # deal with e.g. incorrect capitalization 

141 path = pathlib.Path(path).resolve() 

142 

143 with open(path) as f: 

144 nrow = int(f.readline().strip()) 

145 line = f.readline() 

146 try: 

147 # csv.reader parse one line 

148 # this catches commas in quotes 

149 ncol, itype = map(int, map(str.strip, next(csv.reader([line])))) 

150 # itype can be implicit, in which case it's a timeseries 

151 except ValueError: 

152 try: 

153 ncol = int(line.strip()) 

154 itype = 1 

155 except ValueError: # then try whitespace delimited 

156 ncol, itype = map( 

157 int, map(str.strip, next(csv.reader([line], delimiter=" "))) 

158 ) 

159 

160 # use pandas for csv parsing: stuff like commas within quotes 

161 # this is a workaround for a pandas bug, probable related issue: 

162 # https://github.com/pandas-dev/pandas/issues/19827#issuecomment-398649163 

163 lines = [f.readline() for _ in range(ncol)] 

164 delim_whitespace = _infer_delimwhitespace(lines[0], 2) 

165 # Normally, this ought to work: 

166 # metadata = pd.read_csv(f, header=None, nrows=ncol).values 

167 # TODO: replace when bugfix is released 

168 # try both comma and whitespace delimited, everything can be be mixed 

169 # in a single file... 

170 lines = "".join(lines) 

171 

172 # TODO: find out whether this can be replace by csv.reader 

173 # the challenge lies in replacing the pd.notnull for nodata values. 

174 # is otherwise quite a bit faster for such a header block. 

175 metadata_kwargs = { 

176 "delim_whitespace": delim_whitespace, 

177 "header": None, 

178 "nrows": ncol, 

179 "skipinitialspace": True, 

180 } 

181 metadata_kwargs.update(kwargs) 

182 metadata = pd.read_csv(io.StringIO(lines), **metadata_kwargs) 

183 # header description possibly includes nodata 

184 usecols = np.arange(ncol)[pd.notnull(metadata[0])] 

185 metadata = metadata.iloc[usecols, :] 

186 

187 # Collect column names and nodata values 

188 colnames = [] 

189 na_values = collections.OrderedDict() 

190 for colname, nodata in metadata.values: 

191 na_values[colname] = [nodata, "-"] # "-" seems common enough to ignore 

192 if isinstance(colname, str): 

193 colnames.append(colname.strip()) 

194 else: 

195 colnames.append(colname) 

196 

197 # Sniff the first line of the data block 

198 position = f.tell() 

199 line = f.readline() 

200 f.seek(position) 

201 delim_whitespace = _infer_delimwhitespace(line, ncol) 

202 

203 itype_kwargs = { 

204 "delim_whitespace": delim_whitespace, 

205 "header": None, 

206 "names": colnames, 

207 "usecols": usecols, 

208 "nrows": nrow, 

209 "na_values": na_values, 

210 "skipinitialspace": True, 

211 } 

212 if itype == 1: # Timevariant information: timeseries 

213 # check if first column is time in [yyyymmdd] or [yyyymmddhhmmss] 

214 itype_kwargs["dtype"] = {colnames[0]: str} 

215 elif itype == 2: # 1D borehole 

216 # enforce first column is a float 

217 itype_kwargs["dtype"] = {colnames[0]: np.float64} 

218 elif itype == 3: # cpt 

219 # all columns must be numeric 

220 itype_kwargs["dtype"] = {colname: np.float64 for colname in colnames} 

221 elif itype == 4: # 3D borehole 

222 # enforce first 3 columns are float 

223 itype_kwargs["dtype"] = { 

224 colnames[0]: np.float64, 

225 colnames[1]: np.float64, 

226 colnames[2]: np.float64, 

227 } 

228 itype_kwargs.update(kwargs) 

229 df = pd.read_csv(f, **itype_kwargs) 

230 

231 if nrow > 0 and itype == 1: 

232 time_column = colnames[0] 

233 len_date = len(df[time_column].iloc[0]) 

234 if len_date == 14: 

235 df[time_column] = pd.to_datetime(df[time_column], format="%Y%m%d%H%M%S") 

236 elif len_date == 8: 

237 df[time_column] = pd.to_datetime(df[time_column], format="%Y%m%d") 

238 else: 

239 raise ValueError( 

240 f"{path.name}: datetime format must be yyyymmddhhmmss or yyyymmdd" 

241 ) 

242 return df 

243 

244 

245def read(path, kwargs={}, assoc_kwargs={}): 

246 """ 

247 Read one or more IPF files to a single pandas.DataFrame, including associated 

248 (TXT) files. 

249 

250 The different IPF files can be from different model layers, 

251 and column names may differ between them. 

252 

253 Note that this function always returns a ``pandas.DataFrame``. IPF files 

254 always contain spatial information, for which ``geopandas.GeoDataFrame`` 

255 is a better fit, in principle. However, GeoDataFrames are not the best fit 

256 for the associated data. 

257 

258 To perform spatial operations on the points, you're likely best served by 

259 (temporarily) creating a GeoDataFrame, doing the spatial operation, and 

260 then using the output to select values in the original DataFrame. Please 

261 refer to the examples. 

262 

263 Parameters 

264 ---------- 

265 path: str, Path or list 

266 This can be a single file, 'wells_l1.ipf', a glob pattern expansion, 

267 'wells_l*.ipf', or a list of files, ['wells_l1.ipf', 'wells_l2.ipf']. 

268 Note that each file needs to have the same columns, such that they can 

269 be combined in a single pd.DataFrame. 

270 kwargs : dict 

271 Dictionary containing the ``pandas.read_csv()`` keyword arguments for the 

272 IPF files (e.g. `{"delim_whitespace": True}`) 

273 assoc_kwargs: dict 

274 Dictionary containing the ``pandas.read_csv()`` keyword arguments for the 

275 associated (TXT) files (e.g. `{"delim_whitespace": True}`) 

276 

277 Returns 

278 ------- 

279 pandas.DataFrame 

280 

281 Examples 

282 -------- 

283 Read an IPF file into a dataframe: 

284 

285 >>> import imod 

286 >>> df = imod.ipf.read("example.ipf") 

287 

288 Convert the x and y data into a GeoDataFrame, do a spatial operation, and 

289 use it to select points within a polygon. 

290 Note: ``gpd.points_from_xy()`` requires a geopandas version >= 0.5. 

291 

292 >>> import geopandas as gpd 

293 >>> polygon = gpd.read_file("polygon.shp").geometry[0] 

294 >>> ipf_points = gpd.GeoDataFrame(geometry=gpd.points_from_xy(df["x"], df["y"])) 

295 >>> within_polygon = ipf_points.within(polygon) 

296 >>> selection = df[within_polygon] 

297 

298 The same exercise is a little more complicated when associated files (like 

299 timeseries) are involved, since many duplicate values of x and y will exist. 

300 The easiest way to isolate these is by applying a groupby, and then taking 

301 first of x and y of every group: 

302 

303 >>> df = imod.ipf.read("example_with_time.ipf") 

304 >>> first = df.groupby("id").first() # replace "id" by what your ID column is called 

305 >>> x = first["x"] 

306 >>> y = first["y"] 

307 >>> id_code = first.index # id is a reserved keyword in python 

308 >>> ipf_points = gpd.GeoDataFrame(geometry=gpd.points_from_xy(x, y)) 

309 >>> within_polygon = ipf_points.within(polygon) 

310 

311 Using the result is a little more complicated as well, since it has to be 

312 mapped back to many duplicate values of the original dataframe. 

313 There are two options. First, by using the index: 

314 

315 >>> within_polygon.index = id_code 

316 >>> df = df.set_index("id") 

317 >>> selection = df[within_polygon] 

318 

319 If you do not wish to change index on the original dataframe, use 

320 ``pandas.DataFrame.merge()`` instead. 

321 

322 >>> import pandas as pd 

323 >>> within_polygon = pd.DataFrame({"within": within_polygon}) 

324 >>> within_polygon["id"] = id_code 

325 >>> df = df.merge(within_polygon, on="id") 

326 >>> df = df[df["within"]] 

327 """ 

328 if isinstance(path, list): 

329 paths = path 

330 elif isinstance(path, (str, pathlib.Path)): 

331 # convert since for Path.glob non-relative patterns are unsupported 

332 path = str(path) 

333 paths = [pathlib.Path(p) for p in glob.glob(path)] 

334 else: 

335 raise ValueError("Path should be either a list, str or pathlib.Path") 

336 

337 n = len(paths) 

338 if n == 0: 

339 raise FileNotFoundError(f"Could not find any files matching {path}") 

340 elif n == 1: 

341 bigdf = _read(paths[0], kwargs, assoc_kwargs) 

342 else: 

343 dfs = [] 

344 for p in paths: 

345 layer = imod.util.path.decompose(p).get("layer") 

346 try: 

347 df = _read(p, kwargs, assoc_kwargs) 

348 except Exception as e: 

349 raise type(e)(f'{e}\nWhile reading IPF file "{p}"') from e 

350 if layer is not None: 

351 df["layer"] = layer 

352 dfs.append(df) 

353 bigdf = pd.concat( 

354 dfs, ignore_index=True, sort=False 

355 ) # this sorts in pandas < 0.23 

356 

357 return bigdf 

358 

359 

360def _coerce_itype(itype): 

361 """Changes string itype to int""" 

362 if itype in [None, 1, 2, 3, 4]: 

363 pass 

364 elif itype.lower() == "timeseries": 

365 itype = 1 

366 elif itype.lower() == "borehole1d": 

367 itype = 2 

368 elif itype.lower() == "cpt": 

369 itype = 3 

370 elif itype.lower() == "borehole3d": 

371 itype = 4 

372 else: 

373 raise ValueError("Invalid IPF itype") 

374 return itype 

375 

376 

377def _lower(colnames): 

378 """Lowers colnames, checking for uniqueness""" 

379 lowered_colnames = [s.lower() for s in colnames] 

380 if len(set(lowered_colnames)) != len(colnames): 

381 seen = set() 

382 for name in lowered_colnames: 

383 if name in seen: 

384 raise ValueError(f'Column name "{name}" is not unique, after lowering.') 

385 else: 

386 seen.add(name) 

387 return lowered_colnames 

388 

389 

390def write_assoc(path, df, itype=1, nodata=1.0e20, assoc_columns=None): 

391 """ 

392 Writes a single IPF associated (TXT) file. 

393 

394 Parameters 

395 ---------- 

396 path : pathlib.Path or str 

397 Path for the written associated file. 

398 df : pandas.DataFrame 

399 DataFrame containing the data to write. 

400 itype : int or str 

401 IPF type. 

402 Possible values, either integer or string: 

403 

404 1 : "timeseries" 

405 2 : "borehole1d" 

406 3 : "cpt" 

407 4 : "borehole3d" 

408 nodata : float 

409 The value given to nodata values. These are generally NaN (Not-a-Number) 

410 in pandas, but this leads to errors in iMOD(FLOW) for IDFs. 

411 Defaults to value of 1.0e20 instead. 

412 assoc_columns : optional, list or dict 

413 Columns to store in the associated file. In case of a dictionary, the 

414 columns will be renamed according to the mapping in the dictionary. 

415 Defaults to None. 

416 

417 Returns 

418 ------- 

419 None 

420 Writes a file. 

421 """ 

422 

423 itype = _coerce_itype(itype) 

424 required_columns = { 

425 1: ["time"], 

426 2: ["top"], 

427 3: ["top"], 

428 4: ["x_offset", "y_offset", "top"], 

429 } 

430 

431 # Ensure columns are in the right order for the itype 

432 colnames = _lower(list(df)) 

433 df.columns = colnames 

434 columnorder = [] 

435 for colname in required_columns[itype]: 

436 if colname not in colnames: 

437 raise ValueError(f'given itype requires column "{colname}"') 

438 colnames.remove(colname) 

439 columnorder.append(colname) 

440 columnorder += colnames 

441 

442 # Check if columns have to be renamed 

443 if isinstance(assoc_columns, dict): 

444 columnorder = [assoc_columns[col] for col in columnorder] 

445 df = df.rename(columns=assoc_columns) 

446 

447 nrecords, nfields = df.shape 

448 with open(path, "w") as f: 

449 f.write(f"{nrecords}\n{nfields},{itype}\n") 

450 for colname in columnorder: 

451 if "," in colname or " " in colname: 

452 colname = '"' + colname + '"' 

453 f.write(f"{colname},{nodata}\n") 

454 # workaround pandas issue by closing the file first, see 

455 # https://github.com/pandas-dev/pandas/issues/19827#issuecomment-398649163 

456 

457 df = df.fillna(nodata) 

458 df = df[columnorder] 

459 

460 # We cannot rely on the quoting=QUOTE_NONNUMERIC policy 

461 # The reason is that datetime columns are converted to string as well 

462 # and then quoted. This causes trouble with some iMOD(batch) functions. 

463 for column in df.columns: 

464 if df.loc[:, column].dtype == np.dtype("O"): 

465 df.loc[:, column] = df.loc[:, column].astype(str) 

466 df.loc[:, column] = '"' + df.loc[:, column] + '"' 

467 

468 df.to_csv( 

469 path, 

470 index=False, 

471 header=False, 

472 mode="a", 

473 date_format="%Y%m%d%H%M%S", 

474 quoting=csv.QUOTE_NONE, 

475 ) 

476 

477 

478def write(path, df, indexcolumn=0, assoc_ext="txt", nodata=1.0e20): 

479 """ 

480 Writes a single IPF file. 

481 

482 Parameters 

483 ---------- 

484 path : pathlib.Path or str 

485 path of the written IPF file. 

486 Any associated files are written relative to this path, based on the ID 

487 column. 

488 df : pandas.DataFrame 

489 DataFrame containing the data to write. 

490 indexcolumn : integer 

491 number of the column containg the paths to the associated (TXT) files. 

492 Defaults to a value of 0 (no associated files). 

493 assoc_ext : str 

494 Extension of the associated files. Defaults to "txt". 

495 

496 Returns 

497 ------- 

498 None 

499 Writes a file. 

500 """ 

501 df = df.fillna(nodata) 

502 nrecords, nfields = df.shape 

503 with open(path, "w") as f: 

504 f.write(f"{nrecords}\n{nfields}\n") 

505 for colname in df.columns: 

506 if "," in colname or " " in colname: 

507 colname = '"' + colname + '"' 

508 f.write(f"{colname}\n") 

509 f.write(f"{indexcolumn},{assoc_ext}\n") 

510 

511 # We cannot rely on the quoting=QUOTE_NONNUMERIC policy 

512 # The reason is that datetime columns are converted to string as well 

513 # and then quoted. This causes trouble with some iMOD(batch) functions. 

514 for column in df.columns: 

515 if df.loc[:, column].dtype == np.dtype("O"): 

516 df.loc[:, column] = df.loc[:, column].astype(str) 

517 df.loc[:, column] = '"' + df.loc[:, column] + '"' 

518 

519 # workaround pandas issue by closing the file first, see 

520 # https://github.com/pandas-dev/pandas/issues/19827#issuecomment-398649163 

521 df.to_csv(path, index=False, header=False, mode="a", quoting=csv.QUOTE_NONE) 

522 

523 

524def _is_single_value(group): 

525 return len(pd.unique(group)) == 1 

526 

527 

528def _compose_ipf(path, df, itype, assoc_ext, nodata=1.0e20, assoc_columns=None): 

529 """ 

530 When itype is not None, breaks down the pandas DataFrame into its IPF part 

531 and its associated TXT files, creating the IPF data structure. 

532 

533 Parameters 

534 ---------- 

535 path : pathlib.Path or str 

536 path of the written IPF file. 

537 Any associated files are written relative to this path, based on the ID 

538 column. 

539 df : pandas.DataFrame 

540 DataFrame containing the data to write. 

541 itype : int or str or None 

542 If ``None`` no associated files are written. 

543 Other possible values, either integer or string: 

544 

545 * ``1`` or ``"timeseries"`` 

546 * ``2`` or ``"borehole1d"`` 

547 * ``3`` or ``"cpt"`` 

548 * ``4`` or ``"borehole3d"`` 

549 assoc_ext : str 

550 Extension of the associated files. Normally ".txt". 

551 nodata : float 

552 The value given to nodata values. These are generally NaN (Not-a-Number) 

553 in pandas, but this leads to errors in iMOD(FLOW) for IDFs. 

554 Defaults to value of 1.0e20 instead. 

555 assoc_columns : optional, list or dict 

556 Columns to store in the associated file. In case of a dictionary, the 

557 columns will be renamed according to the mapping in the dictionary. 

558 Defaults to None. 

559 

560 Returns 

561 ------- 

562 None 

563 Writes files. 

564 """ 

565 if itype is None: 

566 write(path, df, nodata=nodata) 

567 else: 

568 itype = _coerce_itype(itype) 

569 colnames = _lower(list(df)) 

570 df.columns = colnames 

571 for refname in ["x", "y", "id"]: 

572 if refname not in colnames: 

573 raise ValueError(f'given itype requires column "{refname}"') 

574 colnames.remove(refname) 

575 

576 grouped = df.groupby("id") 

577 if not grouped["x"].apply(_is_single_value).all(): 

578 raise ValueError("column x contains more than one value per id") 

579 if not grouped["y"].apply(_is_single_value).all(): 

580 raise ValueError("column y contains more than one value per id") 

581 # get columns that have only one value within a group, to save them in ipf 

582 ipf_columns = [ 

583 (colname, "first") 

584 for colname in colnames 

585 if grouped[colname].apply(_is_single_value).all() 

586 ] 

587 

588 for idcode, group in grouped: 

589 assoc_path = path.parent.joinpath(str(idcode) + "." + str(assoc_ext)) 

590 assoc_path.parent.mkdir(parents=True, exist_ok=True) 

591 if isinstance(assoc_columns, list): 

592 selection = assoc_columns 

593 elif isinstance(assoc_columns, dict): 

594 selection = list(assoc_columns.keys()) 

595 else: 

596 selection = [ 

597 colname for colname in colnames if colname not in ipf_columns 

598 ] 

599 out_df = group[selection] 

600 write_assoc(assoc_path, out_df, itype, nodata, assoc_columns) 

601 

602 # ensures right order for x, y, id; so that also indexcolumn == 3 

603 agg_kwargs = collections.OrderedDict( 

604 [("x", "first"), ("y", "first"), ("id", "first")] 

605 ) 

606 agg_kwargs.update(ipf_columns) 

607 agg_df = grouped.agg(agg_kwargs) 

608 write(path, agg_df, 3, assoc_ext, nodata=nodata) 

609 

610 

611def save(path, df, itype=None, assoc_ext="txt", nodata=1.0e20, assoc_columns=None): 

612 """ 

613 Saves the contents of a pandas DataFrame to one or more IPF files, and 

614 associated (TXT) files. 

615 

616 Can write multiple IPF files if one of the columns is named "layer". In 

617 turn, multiple associated (TXT) files may written for each of these IPF 

618 files. Note that the ID must be unique for each layer. See the examples. 

619 

620 Parameters 

621 ---------- 

622 path : pathlib.Path or str 

623 path of the written IPF file. 

624 Any associated files are written relative to this path, based on the ID 

625 column. 

626 df : pandas.DataFrame 

627 DataFrame containing the data to write. 

628 itype : int or str or None 

629 IPF type. Defaults to ``None``, in which case no associated files are 

630 created. Possible other values, either integer or string: 

631 

632 * ``1`` or ``"timeseries"`` 

633 * ``2`` or ``"borehole1d"`` 

634 * ``3`` or ``"cpt"`` 

635 * ``4`` or ``"borehole3d"`` 

636 assoc_ext : str 

637 Extension of the associated files. Defaults to "txt". 

638 nodata : float 

639 The value given to nodata values. These are generally NaN (Not-a-Number) 

640 in pandas, but this leads to errors in iMOD(FLOW) for IDFs. 

641 Defaults to value of 1.0e20 instead. 

642 assoc_columns : optional, list or dict 

643 Columns to store in the associated file. In case of a dictionary, the 

644 columns will be renamed according to the mapping in the dictionary. 

645 Defaults to None. 

646 

647 Returns 

648 ------- 

649 None 

650 Writes files. 

651 

652 Examples 

653 -------- 

654 To write a single IPF without associated timeseries or boreholes: 

655 

656 >>> imod.ipf.save("static-data.ipf", df) 

657 

658 To write timeseries data: 

659 

660 >>> imod.ipf.save("transient-data.ipf", df, itype="timeseries") 

661 

662 If a ``"layer"`` column is present, make sure the ID is unique per layer: 

663 

664 >>> df["id"] = df["id"].str.cat(df["layer"], sep="_") 

665 >>> imod.ipf.save("layered.ipf", df, itype="timeseries") 

666 

667 An error will be raised otherwise. 

668 """ 

669 

670 path = pathlib.Path(path) 

671 

672 d = {"extension": ".ipf", "name": path.stem, "directory": path.parent} 

673 d["directory"].mkdir(exist_ok=True, parents=True) 

674 

675 colnames = _lower(list(df)) 

676 # Lower assoc_columns as well if available 

677 if isinstance(assoc_columns, list): 

678 assoc_columns = _lower(assoc_columns) 

679 elif isinstance(assoc_columns, dict): 

680 keys = _lower(assoc_columns.keys()) 

681 values = _lower(assoc_columns.values()) 

682 assoc_columns = dict(zip(keys, values)) 

683 

684 df.columns = colnames 

685 if "layer" in colnames: 

686 if "time" in colnames: 

687 groupcols = ["time", "id"] 

688 else: 

689 groupcols = "id" 

690 

691 n_layer_per_id = df.groupby(groupcols)["layer"].nunique() 

692 if (n_layer_per_id > 1).any(): 

693 raise ValueError( 

694 "Multiple layer values for a single ID detected. " 

695 "Unique IDs are required for each layer." 

696 ) 

697 

698 for layer, group in df.groupby("layer"): 

699 d["layer"] = layer 

700 fn = imod.util.path.compose(d) 

701 _compose_ipf(fn, group, itype, assoc_ext, nodata, assoc_columns) 

702 else: 

703 fn = imod.util.path.compose(d) 

704 _compose_ipf(fn, df, itype, assoc_ext, nodata, assoc_columns)