Coverage for brodata/gld.py: 73%
264 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-24 13:30 +0000
« 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
7import numpy as np
8import pandas as pd
10from . import bro, util
12logger = logging.getLogger(__name__)
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.
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`.
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.
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
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
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.
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.
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.
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
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.
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.
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.
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.
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()
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
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
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]
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
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.
357 Parameters
358 ----------
359 bro_id : str
360 The BRO-ID of the Groundwater Level Dossier to fetch the summary for.
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.
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.
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
396class GroundwaterLevelDossier(bro.FileOrUrl):
397 """
398 Class to represent a Groundwater Level Dossier (GLD) from the BRO.
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.
406 tubeNumber : int
407 The tube number associated with the observation.
409 groundwaterMonitoringWell : str
410 The BRO-ID of the groundwater monitoring well.
411 """
413 _rest_url = "https://publiek.broservices.nl/gm/gld/v1"
415 def _read_contents(self, tree, status=None, observation_type=None, **kwargs):
416 """
417 Parse data to populate the Groundwater Level Dossier attributes.
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.
424 Parameters
425 ----------
426 tree : xml.etree.ElementTree
427 The XML tree to parse and extract data from.
429 **kwargs : keyword arguments
430 Additional parameters passed to the `process_observations` function to
431 filter and transform the observations.
433 Raises
434 ------
435 Exception
436 If more than one or no GLD element is found in the XML tree.
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.
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)
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
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
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")
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()
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.
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).
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.
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.
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")
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)]
618 if tmin is not None:
619 df = df.loc[df.index >= pd.Timestamp(tmin)]
621 if tmax is not None:
622 df = df.loc[df.index <= pd.Timestamp(tmax)]
624 if sort:
625 df = sort_observations(df)
627 if drop_duplicates:
628 df = drop_duplicate_observations(df, bro_id=bro_id, sort=sort)
630 return df
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
641 Parameters
642 ----------
643 df : pandas.DataFrame
644 DataFrame with optional 'observation_type' and 'status' columns,
645 and a DatetimeIndex.
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))
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))
662 # sort based on DatetimeIndex
663 df = df.sort_index()
665 return df
668def drop_duplicate_observations(df, bro_id="gld", keep="first", sort=True):
669 """
670 Remove duplicate observations from a DataFrame based on its index.
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'.
685 Returns
686 -------
687 pd.DataFrame
688 DataFrame with duplicate index values removed, keeping only the rows
689 specified by the `keep` parameter.
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
708def _get_empty_observation_df():
709 columns = ["time", "value", "qualifier", "status", "observation_type"]
710 return pd.DataFrame(columns=columns).set_index("time")
713cl = GroundwaterLevelDossier
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__
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__