Coverage for brodata/gld.py: 73%

264 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-24 13:30 +0000

1import csv 

2import logging 

3import time 

4from functools import partial 

5from io import StringIO 

6 

7import numpy as np 

8import pandas as pd 

9 

10from . import bro, util 

11 

12logger = logging.getLogger(__name__) 

13 

14 

15def get_objects_as_csv( 

16 bro_id, 

17 rapportagetype="volledig", 

18 observatietype=None, 

19 to_file=None, 

20 return_contents=True, 

21 **kwargs, 

22): 

23 """ 

24 Fetch a complete Groundwater Level Dossier (GLD) as a CSV (RFC 4180) file 

25 based on the provided BRO-ID. The data can be filtered by report type and 

26 observation type. 

27 

28 Parameters 

29 ---------- 

30 bro_id : str 

31 The BRO-ID of the Groundwater Level Dossier to fetch. It can also be a full url, 

32 which is used by the gm-services. When using a full url, the parameter 

33 `rapportagetype` needs to reflect the choice in the url, and the parameter 

34 `observatietype` is ignored. 

35 rapportagetype : str, optional 

36 Type of report. The valid values are: 

37 - "volledig" : Full report 

38 - "compact" : Compact report with readable timestamps 

39 - "compact_met_timestamps" : Compact report with Unix epoch timestamps 

40 Default is "volledig". 

41 observatietype : str, optional 

42 Type of observations. The valid values are: 

43 - "regulier_beoordeeld" : Regular measurement with full evaluation 

44 (observatietype = reguliere meting en mate beoordeling = volledig beoordeeld) 

45 - "regulier_voorlopig" : Regular measurement with preliminary evaluation 

46 (observatietype = reguliere meting en mate beoordeling = voorlopig) 

47 - "controle" : Control measurement 

48 (observatietype = controle meting) 

49 - "onbekend" : Unknown evaluation 

50 (observatietype = reguliere meting en mate beoordeling = onbekend) 

51 If None, all observation types will be returned. Default is None. 

52 to_file : str, optional 

53 If provided, the CSV data will be written to the specified file. 

54 If None, the function returns the CSV data as a DataFrame. Default is None. 

55 return_contents : bool, optional 

56 If True, the function returns the parsed CSV data as a DataFrame. If False, 

57 the function returns None after saving the CSV to the specified file (if 

58 `to_file` is provided). Default is True. 

59 **kwargs : additional keyword arguments 

60 Additional arguments passed to `read_gld_csv`. 

61 

62 Returns 

63 ------- 

64 pd.DataFrame or None 

65 If successful, returns a DataFrame containing the parsed CSV data. 

66 If `to_file` is provided, returns None after saving the CSV to the specified file. 

67 If the request fails or returns empty data, returns None. 

68 

69 Notes 

70 ----- 

71 The function sends a GET request to the Groundwater Level Dossier API 

72 and fetches the data in CSV format. The `rapportagetype` and `observatietype` 

73 parameters can be used to filter the data. 

74 """ 

75 if bro_id.startswith("http"): 

76 req = util.get_with_rate_limit(bro_id) 

77 else: 

78 url = f"{GroundwaterLevelDossier._rest_url}/objectsAsCsv/{bro_id}" 

79 params = { 

80 "rapportagetype": rapportagetype, 

81 } 

82 if observatietype is not None: 

83 params["observatietype"] = observatietype 

84 req = util.get_with_rate_limit(url, params=params) 

85 req = _check_request_status(req) 

86 if to_file is not None: 

87 with open(to_file, "w") as f: 

88 f.write(req.text) 

89 if not return_contents: 

90 return 

91 if req.text == "": 

92 return None 

93 else: 

94 df = read_gld_csv( 

95 StringIO(req.text), 

96 bro_id, 

97 rapportagetype=rapportagetype, 

98 observatietype=observatietype, 

99 **kwargs, 

100 ) 

101 return df 

102 

103 

104def _check_request_status(req): 

105 if req.status_code == 429: 

106 msg = "Too many requests. The BRO API has rate limits in place." 

107 logger.warning(msg) 

108 # try 3 times with increasing wait time 

109 wait_times = [1, 2, 4] 

110 for wait_time in wait_times: 

111 logger.warning(f"Waiting for {wait_time} seconds before retrying...") 

112 time.sleep(wait_time) 

113 req = util.get_with_rate_limit(req.url) 

114 if req.status_code <= 200: 

115 break 

116 if req.status_code == 429: 

117 raise Exception(msg + " Please try again later.") 

118 if req.status_code > 200: 

119 json_data = req.json() 

120 if "errors" in json_data: 

121 msg = json_data["errors"][0]["message"] 

122 else: 

123 msg = "{}: {}".format(json_data["title"], json_data["description"]) 

124 raise Exception(msg) 

125 return req 

126 

127 

128def get_series_as_csv( 

129 bro_id, filter_on_status_quality_control=None, asISO8601=False, to_file=None 

130): 

131 """ 

132 Get groundwater level series as a CSV, with timestamps and corresponding measurements. 

133 

134 This function retrieves a table with measurements for different observation types 

135 (regulier_voorlopig, regulier_beoordeeld, controle en onbekend) as columns. It is 

136 intended for applications such as the graphical visualization of groundwater levels. 

137 

138 Parameters 

139 ---------- 

140 bro_id : str 

141 The BRO-ID of the Groundwater Level Dossier. 

142 filter_on_status_quality_control : str or list of str, optional 

143 One or more quality control statuses to filter the measurements by. 

144 Possible values are 'onbeslist', 'goedgekeurd', and 'afgekeurd'. 

145 The default is None. 

146 asISO8601 : bool, optional 

147 If True, timestamps are returned in ISO8601 format; otherwise, in Unix 

148 epoch format. The default is False. 

149 to_file : str, optional 

150 If provided, the CSV data will be written to this file path. The default 

151 is None. 

152 

153 Returns 

154 ------- 

155 pd.DataFrame or None 

156 A DataFrame containing the time series of measurements, with timestamps 

157 as the index. Returns None if no data is available. 

158 """ 

159 url = f"{GroundwaterLevelDossier._rest_url}/seriesAsCsv/{bro_id}" 

160 params = {} 

161 if filter_on_status_quality_control is not None: 

162 if not isinstance(filter_on_status_quality_control, str): 

163 filter_on_status_quality_control = ",".join( 

164 filter_on_status_quality_control 

165 ) 

166 params["filterOnStatusQualityControl"] = filter_on_status_quality_control 

167 if asISO8601: 

168 params["asISO8601"] = "" 

169 req = util.get_with_rate_limit(url, params=params) 

170 req = _check_request_status(req) 

171 if to_file is not None: 

172 with open(to_file, "w") as f: 

173 f.write(req.text) 

174 if req.text == "": 

175 return None 

176 else: 

177 df = pd.read_csv(StringIO(req.text)) 

178 if "Tijdstip" in df.columns: 

179 if asISO8601: 

180 df["Tijdstip"] = pd.to_datetime(df["Tijdstip"]) 

181 else: 

182 df["Tijdstip"] = pd.to_datetime(df["Tijdstip"], unit="ms") 

183 df = df.set_index("Tijdstip") 

184 return df 

185 

186 

187def read_gld_csv( 

188 fname, bro_id="gld", rapportagetype="volledig", observatietype=None, **kwargs 

189): 

190 """ 

191 Read and process a Groundwater Level Dossier (GLD) CSV file. 

192 

193 This function reads a CSV file containing groundwater level observations, 

194 processes the data according to the specified report type (`rapportagetype`), 

195 and returns a DataFrame of the observations. The file is assumed to contain 

196 at least three columns: time, value, and qualifier. The 'time' column is parsed 

197 as datetime, and additional processing is applied to the data. 

198 

199 Parameters 

200 ---------- 

201 fname : str 

202 The path to the CSV file containing the groundwater level observations. 

203 bro_id : str 

204 The BRO-ID of the Groundwater Level Dossier being processed. Only used for 

205 logging-purposes. The default is "gld". 

206 rapportagetype : str, optional 

207 The report type. Can be one of: 

208 - 'volledig': as complete as possible 

209 - 'compact': simple format with time and value. 

210 - 'compact_met_timestamps': format with timestamps for each observation. 

211 Default is "volledig". 

212 observatietype : str, optional 

213 Type of observations. The valid values are: 

214 - "regulier_beoordeeld" : Regular measurement with full evaluation 

215 (observatietype = reguliere meting en mate beoordeling = volledig beoordeeld) 

216 - "regulier_voorlopig" : Regular measurement with preliminary evaluation 

217 (observatietype = reguliere meting en mate beoordeling = voorlopig) 

218 - "controle" : Control measurement 

219 (observatietype = controle meting) 

220 - "onbekend" : Unknown evaluation 

221 (observatietype = reguliere meting en mate beoordeling = onbekend) 

222 If None, all observation types will be returned. Default is None. 

223 **kwargs : additional keyword arguments 

224 Additional arguments passed to the `process_observations` function. 

225 

226 Returns 

227 ------- 

228 pd.DataFrame 

229 A DataFrame containing the processed observations with the following columns: 

230 - time: The observation time. 

231 - value: The observed groundwater level. 

232 - qualifier: The quality code of the observation. 

233 - censored_reason: Reason for censoring, if applicable. 

234 - censoring_limitvalue: Limit value for censoring, if applicable. 

235 - interpolation_type: The interpolation method used, if applicable. 

236 

237 Notes 

238 ----- 

239 The time column is parsed as a datetime index. If the report type is 

240 'compact_met_timestamps', the time values are converted from Unix epoch time 

241 (milliseconds) to a datetime format. 

242 """ 

243 names = [ 

244 "time", 

245 "value", 

246 "qualifier", 

247 "censored_reason", 

248 "censoring_limitvalue", 

249 "interpolation_type", 

250 ] 

251 if rapportagetype == "compact": 

252 parse_dates = ["time"] 

253 else: 

254 parse_dates = None 

255 if observatietype is None or rapportagetype == "volledig": 

256 # the csv contains multiple observation types, seperated by a header with 

257 # observation-type and status. 

258 if isinstance(fname, StringIO): 

259 lines = fname.readlines() 

260 else: 

261 with open(fname, "r") as f: 

262 lines = f.readlines() 

263 

264 # look for header lines 

265 headers = [] 

266 if rapportagetype == "volledig": 

267 # the line with metdata is proceeded by a line starting with "observatie ID" 

268 for i, line in enumerate(lines): 

269 if line.startswith('"observatie ID",'): 

270 headers.append(i + 1) 

271 header_length = 3 

272 else: 

273 # the line with metdata is proceeded by an empty line 

274 # but directly after the header, there can also be empty lines, that we skip 

275 data_lines = False 

276 for i, line in enumerate(lines): 

277 only_commas = all(c == "," for c in line.rstrip("\r\n")) 

278 last_line_was_header = len(headers) > 0 and headers[-1] == i - 1 

279 

280 if only_commas: 

281 if last_line_was_header: 

282 data_lines = True 

283 else: 

284 data_lines = False 

285 else: 

286 if not data_lines: 

287 headers.append(i) 

288 header_length = 2 

289 

290 dfs = [] 

291 for i, header in enumerate(headers): 

292 line = lines[header] 

293 # split string by comma, but ignore commas between quotes 

294 reader = csv.reader(StringIO(line)) 

295 parts = next(reader) 

296 observation_type = parts[3] 

297 status = parts[4] 

298 

299 if i < len(headers) - 1: 

300 current_lines = lines[header + header_length : headers[i + 1] - 1] 

301 else: 

302 current_lines = lines[header + header_length :] 

303 df = pd.read_csv( 

304 StringIO("".join(current_lines)), 

305 names=names, 

306 index_col="time", 

307 parse_dates=parse_dates, 

308 usecols=[0, 1, 2], 

309 ) 

310 # remove empty indices 

311 mask = df.index.isna() & df.isna().all(axis=1) 

312 if mask.any(): 

313 df = df[~mask] 

314 df["status"] = status 

315 df["observation_type"] = observation_type 

316 dfs.append(df) 

317 if len(dfs) > 0: 

318 df = pd.concat(dfs) 

319 else: 

320 df = _get_empty_observation_df() 

321 else: 

322 df = pd.read_csv( 

323 fname, 

324 names=names, 

325 index_col="time", 

326 parse_dates=parse_dates, 

327 usecols=[0, 1, 2], 

328 ) 

329 if observatietype == "regulier_beoordeeld": 

330 df["status"] = "volledigBeoordeeld" 

331 df["observation_type"] = "reguliereMeting" 

332 elif observatietype == "regulier_voorlopig": 

333 df["status"] = "voorlopig" 

334 df["observation_type"] = "reguliereMeting" 

335 elif observatietype == "controle": 

336 df["status"] = np.nan 

337 df["observation_type"] = "controleMeting" 

338 elif observatietype == "onbekend": 

339 df["status"] = "onbekend" 

340 df["observation_type"] = "reguliereMeting" 

341 if rapportagetype == "compact_met_timestamps": 

342 df.index = pd.to_datetime(df.index, unit="ms") 

343 # remove empty indices 

344 mask = df.index.isna() & df.isna().all(axis=1) 

345 if mask.any(): 

346 df = df[~mask] 

347 df = process_observations(df, bro_id, **kwargs) 

348 return df 

349 

350 

351def get_observations_summary(bro_id): 

352 """ 

353 Fetch a summary of a Groundwater Level Dossier (GLD) in JSON format based on 

354 the provided BRO-ID. The summary includes details about the groundwater level 

355 observations, such as observation ID, start and end dates. 

356 

357 Parameters 

358 ---------- 

359 bro_id : str 

360 The BRO-ID of the Groundwater Level Dossier to fetch the summary for. 

361 

362 Raises 

363 ------ 

364 Exception 

365 If the request to the API fails or the status code is greater than 200, 

366 an exception will be raised with the error message returned by the API. 

367 

368 Returns 

369 ------- 

370 pd.DataFrame 

371 A DataFrame containing the summary of the groundwater level observations. 

372 The DataFrame will be indexed by the `observationId` and include 

373 `startDate` and `endDate` columns, converted to `datetime` format. 

374 

375 Notes 

376 ----- 

377 The function sends a GET request to the REST API and processes the returned 

378 JSON data into a DataFrame. If the response contains valid `startDate` or 

379 `endDate` fields, they will be converted to `datetime` format using the 

380 `pd.to_datetime` function. 

381 """ 

382 url = GroundwaterLevelDossier._rest_url 

383 url = "{}/objects/{}/observationsSummary".format(url, bro_id) 

384 req = util.get_with_rate_limit(url) 

385 req = _check_request_status(req) 

386 df = pd.DataFrame(req.json()) 

387 if "observationId" in df.columns: 

388 df = df.set_index("observationId") 

389 if "startDate" in df.columns: 

390 df["startDate"] = pd.to_datetime(df["startDate"], dayfirst=True) 

391 if "endDate" in df.columns: 

392 df["endDate"] = pd.to_datetime(df["endDate"], dayfirst=True) 

393 return df 

394 

395 

396class GroundwaterLevelDossier(bro.FileOrUrl): 

397 """ 

398 Class to represent a Groundwater Level Dossier (GLD) from the BRO. 

399 

400 Attributes 

401 ---------- 

402 observation : pd.DataFrame 

403 DataFrame containing groundwater level observations with time and value 

404 columns. The data is processed and filtered based on the provided arguments. 

405 

406 tubeNumber : int 

407 The tube number associated with the observation. 

408 

409 groundwaterMonitoringWell : str 

410 The BRO-ID of the groundwater monitoring well. 

411 """ 

412 

413 _rest_url = "https://publiek.broservices.nl/gm/gld/v1" 

414 

415 def _read_contents(self, tree, status=None, observation_type=None, **kwargs): 

416 """ 

417 Parse data to populate the Groundwater Level Dossier attributes. 

418 

419 This method reads and processes the XML contents, extracting relevant 

420 groundwater monitoring information such as the groundwater monitoring well, 

421 tube number, and observations. It also processes the observations into a 

422 DataFrame, which is filtered and transformed based on the provided arguments. 

423 

424 Parameters 

425 ---------- 

426 tree : xml.etree.ElementTree 

427 The XML tree to parse and extract data from. 

428 

429 **kwargs : keyword arguments 

430 Additional parameters passed to the `process_observations` function to 

431 filter and transform the observations. 

432 

433 Raises 

434 ------ 

435 Exception 

436 If more than one or no GLD element is found in the XML tree. 

437 

438 Notes 

439 ----- 

440 The method expects the XML structure to adhere to the specified namespaces 

441 and element tags. It processes observation values, timestamps, and qualifiers 

442 into a pandas DataFrame. 

443 

444 The observation data is stored in the `observation` attribute and can be 

445 accessed as a DataFrame. 

446 """ 

447 ns = { 

448 "xmlns": "http://www.broservices.nl/xsd/dsgld/1.0", 

449 "gldcommon": "http://www.broservices.nl/xsd/gldcommon/1.0", 

450 "waterml": "http://www.opengis.net/waterml/2.0", 

451 "swe": "http://www.opengis.net/swe/2.0", 

452 "om": "http://www.opengis.net/om/2.0", 

453 "xlink": "http://www.w3.org/1999/xlink", 

454 } 

455 gld = self._get_main_object(tree, "GLD_O", ns) 

456 for key in gld.attrib: 

457 setattr(self, key.split("}", 1)[1], gld.attrib[key]) 

458 for child in gld: 

459 key = self._get_tag(child) 

460 if len(child) == 0: 

461 setattr(self, key, child.text) 

462 elif key == "monitoringPoint": 

463 well = child.find("gldcommon:GroundwaterMonitoringTube", ns) 

464 gmw_id = well.find("gldcommon:broId", ns).text 

465 setattr(self, "groundwaterMonitoringWell", gmw_id) 

466 tube_nr = int(well.find("gldcommon:tubeNumber", ns).text) 

467 setattr(self, "tubeNumber", tube_nr) 

468 elif key in ["registrationHistory"]: 

469 self._read_children_of_children(child) 

470 elif key == "groundwaterMonitoringNet": 

471 for grandchild in child: 

472 key2 = grandchild.tag.split("}", 1)[1] 

473 if key2 == "GroundwaterMonitoringNet": 

474 setattr(self, key, grandchild[0].text) 

475 else: 

476 logger.warning(f"Unknown key: {key2}") 

477 elif key == "observation": 

478 # get observation_metadata 

479 om_observation = child.find("om:OM_Observation", ns) 

480 if om_observation is None: 

481 continue 

482 metadata = om_observation.find("om:metadata", ns) 

483 observation_metadata = metadata.find("waterml:ObservationMetadata", ns) 

484 

485 # get status 

486 water_ml_status = observation_metadata.find("waterml:status", ns) 

487 if water_ml_status is None: 

488 status_value = None 

489 else: 

490 status_value = water_ml_status.attrib[ 

491 f"{{{ns['xlink']}}}href" 

492 ].rsplit(":", 1)[-1] 

493 if status is not None and status != status_value: 

494 continue 

495 

496 # get observation_type 

497 parameter = observation_metadata.find("waterml:parameter", ns) 

498 named_value = parameter.find("om:NamedValue", ns) 

499 name = named_value.find("om:name", ns) 

500 assert ( 

501 name.attrib[f"{{{ns['xlink']}}}href"] 

502 == "urn:bro:gld:ObservationMetadata:observationType" 

503 ) 

504 value = named_value.find("om:value", ns) 

505 observation_type_value = value.text 

506 if ( 

507 observation_type is not None 

508 and observation_type != observation_type_value 

509 ): 

510 continue 

511 

512 times = [] 

513 values = [] 

514 qualifiers = [] 

515 for measurement in child.findall(".//waterml:MeasurementTVP", ns): 

516 times.append(measurement.find("waterml:time", ns).text) 

517 value = measurement.find("waterml:value", ns).text 

518 if value is None: 

519 values.append(np.nan) 

520 else: 

521 values.append(float(value)) 

522 metadata = measurement.find("waterml:metadata", ns) 

523 TVPMM = metadata.find("waterml:TVPMeasurementMetadata", ns) 

524 qualifier = TVPMM.find("waterml:qualifier", ns) 

525 value = qualifier.find("swe:Category", ns).find("swe:value", ns) 

526 qualifiers.append(value.text) 

527 observation = pd.DataFrame( 

528 { 

529 "time": times, 

530 "value": values, 

531 "qualifier": qualifiers, 

532 "status": status_value, 

533 "observation_type": observation_type_value, 

534 } 

535 ).set_index("time") 

536 

537 if not hasattr(self, key): 

538 self.observation = [] 

539 self.observation.append(observation) 

540 else: 

541 self._warn_unknown_tag(key) 

542 if hasattr(self, "observation"): 

543 self.observation = pd.concat(self.observation) 

544 self.observation = process_observations( 

545 self.observation, self.broId, **kwargs 

546 ) 

547 else: 

548 self.observation = _get_empty_observation_df() 

549 

550 

551def process_observations( 

552 df, 

553 bro_id="gld", 

554 to_wintertime=True, 

555 qualifier=None, 

556 tmin=None, 

557 tmax=None, 

558 sort=True, 

559 drop_duplicates=True, 

560): 

561 """ 

562 Process groundwater level observations. 

563 

564 This function processes a DataFrame containing groundwater level observations, 

565 applying the following operations based on the provided parameters: 

566 - Conversion to Dutch winter time (optional). 

567 - Filtering observations based on the qualifier. 

568 - Dropping duplicate observations (optional). 

569 - Sorting the observations by time (optional). 

570 

571 Parameters 

572 ---------- 

573 df : pd.DataFrame 

574 DataFrame containing the groundwater level observations, with a time 

575 index and columns such as "value", "qualifier", etc. 

576 bro_id : str 

577 The BRO-ID of the Groundwater Level Dossier being processed. Only used for 

578 logging-purposes. The default is "gld". 

579 to_wintertime : bool, optional 

580 If True, the observation times are converted to Dutch winter time by 

581 removing any time zone information and adding one hour. If to_wintertime is 

582 False, observation times are kept in CET/CEST. Default is True. 

583 qualifier : str or list of str, optional 

584 If provided, the observations are filtered based on their "qualifier" 

585 column. Only rows with the specified qualifier(s) will be kept. 

586 tmin : str or datetime, optional 

587 The minimum time for filtering observations. Defaults to None. 

588 tmax : str or datetime, optional 

589 The maximum time for filtering observations. Defaults to None. 

590 sort : bool, optional 

591 If True, the DataFrame will be sorted, see `sort_observations`. Default is 

592 True. 

593 drop_duplicates : bool, optional 

594 If True, any duplicate observation times will be dropped, keeping only 

595 the first occurrence. Default is True. 

596 

597 Returns 

598 ------- 

599 pd.DataFrame 

600 A DataFrame containing the processed observations, with duplicate rows 

601 (if any) removed, the time index sorted, and filtered by qualifier if 

602 applicable. 

603 

604 """ 

605 df.index = pd.to_datetime(df.index, utc=True) 

606 if to_wintertime: 

607 # remove time zone information by transforming to dutch winter time 

608 df.index = df.index.tz_localize(None) + pd.Timedelta(1, unit="h") 

609 else: 

610 df.index = df.index.tz_convert("CET") 

611 

612 if qualifier is not None: 

613 if isinstance(qualifier, str): 

614 df = df[df["qualifier"] == qualifier] 

615 else: 

616 df = df[df["qualifier"].isin(qualifier)] 

617 

618 if tmin is not None: 

619 df = df.loc[df.index >= pd.Timestamp(tmin)] 

620 

621 if tmax is not None: 

622 df = df.loc[df.index <= pd.Timestamp(tmax)] 

623 

624 if sort: 

625 df = sort_observations(df) 

626 

627 if drop_duplicates: 

628 df = drop_duplicate_observations(df, bro_id=bro_id, sort=sort) 

629 

630 return df 

631 

632 

633def sort_observations(df): 

634 """ 

635 Sort observations in a DataFrame by multiple criteria. Applies a multi-level sort 

636 to the input DataFrame, prioritizing the following criteria in order: 

637 1. By the DataFrame's DatetimeIndex in ascending order 

638 2. By status (if present): volledigBeoordeeld before voorlopig before onbekend 

639 3. By observation_type (if present): reguliereMeting before controleMeting 

640 

641 Parameters 

642 ---------- 

643 df : pandas.DataFrame 

644 DataFrame with optional 'observation_type' and 'status' columns, 

645 and a DatetimeIndex. 

646 

647 Returns 

648 ------- 

649 pandas.DataFrame 

650 Sorted DataFrame with the same structure as input. 

651 """ 

652 if "observation_type" in df.columns: 

653 # make sure measurements with observation_type set to reguliereMeting are first 

654 sort_dict = {"reguliereMeting": 0, "controleMeting": 1} 

655 df = df.sort_values("observation_type", key=lambda x: x.map(sort_dict)) 

656 

657 if "status" in df.columns: 

658 # make sure measurements with status set to volledigBeoordeeld are first 

659 sort_dict = {"volledigBeoordeeld": 0, "voorlopig": 1, "onbekend": 2} 

660 df = df.sort_values("status", key=lambda x: x.map(sort_dict)) 

661 

662 # sort based on DatetimeIndex 

663 df = df.sort_index() 

664 

665 return df 

666 

667 

668def drop_duplicate_observations(df, bro_id="gld", keep="first", sort=True): 

669 """ 

670 Remove duplicate observations from a DataFrame based on its index. 

671 

672 Parameters 

673 ---------- 

674 df : pd.DataFrame 

675 The DataFrame to process. 

676 bro_id : str, optional 

677 Identifier for the dataset, used in warning messages. Default is "gld". 

678 keep : {'first', 'last', False}, optional 

679 Which duplicates to mark: 

680 - 'first' : Mark duplicates as True except for the first occurrence. 

681 - 'last' : Mark duplicates as True except for the last occurrence. 

682 - False : Mark all duplicates as True. 

683 Default is 'first'. 

684 

685 Returns 

686 ------- 

687 pd.DataFrame 

688 DataFrame with duplicate index values removed, keeping only the rows 

689 specified by the `keep` parameter. 

690 

691 Warnings 

692 -------- 

693 Logs a warning message if duplicates are found, indicating the number and 

694 total count of duplicates before removal. 

695 """ 

696 if df.index.has_duplicates: 

697 duplicates = df.index.duplicated(keep=keep) 

698 message = "{} contains {} duplicates (of {}). Keeping only first values" 

699 message = message.format(bro_id, duplicates.sum(), len(df.index)) 

700 if sort: 

701 message = f"{message} (sorted for importance)" 

702 message = f"{message}." 

703 logger.warning(message) 

704 df = df[~duplicates] 

705 return df 

706 

707 

708def _get_empty_observation_df(): 

709 columns = ["time", "value", "qualifier", "status", "observation_type"] 

710 return pd.DataFrame(columns=columns).set_index("time") 

711 

712 

713cl = GroundwaterLevelDossier 

714 

715get_bro_ids_of_bronhouder = partial(bro._get_bro_ids_of_bronhouder, cl) 

716get_bro_ids_of_bronhouder.__doc__ = bro._get_bro_ids_of_bronhouder.__doc__ 

717 

718get_data_for_bro_ids = partial(bro._get_data_for_bro_ids, cl) 

719get_data_for_bro_ids.__doc__ = bro._get_data_for_bro_ids.__doc__