Coverage for brodata/bro.py: 80%

602 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 13:58 +0000

1import logging 

2import os 

3import types 

4from xml.etree import ElementTree 

5from abc import ABC, abstractmethod 

6from io import StringIO 

7from zipfile import ZipFile 

8 

9from shapely.geometry import MultiPolygon, Point, Polygon 

10import shapely 

11import numpy as np 

12import geopandas as gpd 

13import pandas as pd 

14import requests 

15from pyproj import Transformer 

16 

17from . import util, gml 

18 

19logger = logging.getLogger(__name__) 

20 

21 

22# %% 

23def _get_bro_ids_of_bronhouder(cl, bronhouder): 

24 """ 

25 Retrieve list of BRO (Basisregistratie Ondergrond) IDs for a given bronhouder. 

26 

27 This function sends a GET request to the REST API to fetch the BRO IDs associated 

28 with the specified bronhouder. If the request is unsuccessful, it logs an error 

29 message. 

30 

31 Parameters 

32 ---------- 

33 bronhouder : str 

34 The identifier for the bronhouder to retrieve the associated BRO IDs. 

35 

36 Returns 

37 ------- 

38 list or None 

39 A list of BRO IDs if the request is successful. Returns `None` if the request 

40 fails. 

41 """ 

42 url = f"{cl._rest_url}/bro-ids?" 

43 params = dict(bronhouder=bronhouder) 

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

45 if req.status_code > 200: 

46 logger.error(req.json()["errors"][0]["message"]) 

47 return 

48 bro_ids = req.json()["broIds"] 

49 return bro_ids 

50 

51 

52def _get_characteristics( 

53 cl, 

54 extent=None, 

55 tmin=None, 

56 tmax=None, 

57 x=None, 

58 y=None, 

59 radius=1000.0, 

60 epsg=28992, 

61 to_file=None, 

62 redownload=False, 

63 use_all_corners_of_extent=True, 

64 timeout=5, 

65 zipfile=None, 

66): 

67 """ 

68 Get characteristics of a set of registered objects for a given object class. 

69 

70 The maximum number of objects that can be retrieved is 2000 for a single request. 

71 

72 Parameters 

73 ---------- 

74 extent : list, tuple, shapely.geometry.Polygon or shapely.geometry.MultiPolygon, optional 

75 Download the characteristics within extent ([xmin, xmax, ymin, ymax]) or 

76 within the bounds of a polygon. The default is None. 

77 tmin : str or pd.Timestamp, optional 

78 The minimum registrationPeriod of the requested characteristics. The default is 

79 None. 

80 tmax : str or pd.Timestamp, optional 

81 The maximum registrationPeriod of the requested characteristics. The default is 

82 None. 

83 x : float, optional 

84 The x-coordinate of the center of the circle in which the characteristics are 

85 requested. The default is None. 

86 y : float, optional 

87 The y-coordinate of the center of the circle in which the characteristics are 

88 requested. The default is None. 

89 radius : float, optional 

90 The radius in meters of the center of the circle in which the characteristics 

91 are requested. The default is 1000.0. 

92 epsg : str, optional 

93 The coordinate reference system of the specified extent, x or y, and of the 

94 resulting GeoDataFrame. The default is 28992, which is the Dutch RD-system. 

95 to_file : str, optional 

96 When not None, save the characteristics to a file with a name as specified in 

97 to_file. The defaults None. 

98 redownload : bool, optional 

99 When the downloaded file exists in to_file, read from this file when redownload 

100 is False. If redownload is True, download the data again from the BRO-servers. 

101 The default is False. 

102 use_all_corners_of_extent : bool, optional 

103 Because the extent by default is given in epsg 28992, some locations along the 

104 border of a requested extent will not be returned in the result. To solve this 

105 issue, when use_all_corners_of_extent is True, all four corners of the extent 

106 are used to calculate the minimum and maximum lan and lon values. The default is 

107 True. 

108 timeout : int or float, optional 

109 A number indicating how many seconds to wait for the client to make a connection 

110 and/or send a response. The default is 5. 

111 zipfile : zipfile.ZipFile, optional 

112 A zipfile-object. When not None, zipfile is used to read previously downloaded 

113 data from. The default is None. 

114 

115 Returns 

116 ------- 

117 gpd.GeoDataFrame 

118 A GeoDataFrame contraining the characteristics. 

119 

120 Notes 

121 ----- 

122 Haalt de karakteristieken op van een set van registratie objecten, gegeven een 

123 kenmerkenverzameling (kenset). 

124 

125 De karakteristieken geven een samenvatting van een object zodat een verdere selectie 

126 gemaakt kan worden. Het past in een tweetrapsbenadering, waarbij de eerste stap 

127 bestaat uit het ophalen van de karakteristieken en de 2e stap uit het ophalen van de 

128 gewenste registratie objecten. Het resultaat van deze operatie is gemaximaliseerd op 

129 2000. 

130 """ 

131 if zipfile is None and ( 

132 redownload or to_file is None or not os.path.isfile(to_file) 

133 ): 

134 url = f"{cl._rest_url}/characteristics/searches?" 

135 

136 data = {} 

137 if tmin is not None or tmax is not None: 

138 data["registrationPeriod"] = {} 

139 if tmin is not None: 

140 beginDate = pd.to_datetime(tmin).strftime("%Y-%m-%d") 

141 data["registrationPeriod"]["beginDate"] = beginDate 

142 if tmax is not None: 

143 endDate = pd.to_datetime(tmax).strftime("%Y-%m-%d") 

144 data["registrationPeriod"]["endDate"] = endDate 

145 if (x is None or y is None) and extent is None: 

146 raise (Exception("Please specify either extent or x, y and radius")) 

147 

148 transformer = Transformer.from_crs(epsg, 4326) 

149 data["area"] = {} 

150 if x is not None and y is not None: 

151 lat, lon = transformer.transform(x, y) 

152 data["area"]["enclosingCircle"] = { 

153 "center": {"lat": lat, "lon": lon}, 

154 "radius": radius / 1000, 

155 } 

156 if extent is not None: 

157 if isinstance(extent, (Polygon, MultiPolygon)): 

158 xmin, ymin, xmax, ymax = extent.bounds 

159 else: 

160 xmin, xmax, ymin, ymax = extent 

161 

162 lat_ll, lon_ll = transformer.transform(xmin, ymin) 

163 lat_ur, lon_ur = transformer.transform(xmax, ymax) 

164 if use_all_corners_of_extent: 

165 lat_ul, lon_ul = transformer.transform(xmin, ymax) 

166 lat_lr, lon_lr = transformer.transform(xmax, ymin) 

167 lat_ll = min(lat_ll, lat_lr) 

168 lon_ll = min(lon_ll, lon_ul) 

169 lat_ur = max(lat_ul, lat_ur) 

170 lon_ur = max(lon_lr, lon_ur) 

171 

172 data["area"]["boundingBox"] = { 

173 "lowerCorner": {"lat": lat_ll, "lon": lon_ll}, 

174 "upperCorner": {"lat": lat_ur, "lon": lon_ur}, 

175 } 

176 req = util.post_with_rate_limit(url, json=data, timeout=timeout) 

177 if req.status_code > 200: 

178 root = ElementTree.fromstring(req.text) 

179 FileOrUrl._check_for_rejection(root) 

180 # if reading of the rejection message failed, raise a more general error 

181 raise (Exception((f"Retieving data from {url} failed"))) 

182 

183 if to_file is not None: 

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

185 f.write(req.text) 

186 

187 # read results 

188 tree = ElementTree.fromstring(req.text) 

189 else: 

190 if zipfile is not None: 

191 with zipfile.open(to_file) as f: 

192 tree = ElementTree.parse(f).getroot() 

193 else: 

194 tree = ElementTree.parse(to_file).getroot() 

195 

196 ns = {"xmlns": cl._xmlns} 

197 data = [] 

198 for gmw in tree.findall(f".//xmlns:{cl._char}", ns): 

199 d = {} 

200 for key in gmw.attrib: 

201 d[key.split("}", 1)[1]] = gmw.attrib[key] 

202 for child in gmw: 

203 key = util._get_tag(child) 

204 if len(child) == 0: 

205 d[key] = child.text 

206 elif key == "standardizedLocation": 

207 d[key] = FileOrUrl._read_geometry(child) 

208 elif key == "deliveredLocation": 

209 d[key] = FileOrUrl._read_geometry(child) 

210 elif ( 

211 key.endswith("Date") 

212 or key.endswith("Overview") 

213 or key in ["startTime", "endTime"] 

214 ): 

215 d[key] = child[0].text 

216 elif key in ["diameterRange", "screenPositionRange"]: 

217 for grandchild in child: 

218 key = util._get_tag(grandchild) 

219 d[key] = grandchild.text 

220 elif key == "licence": 

221 for grandchild in child: 

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

223 for greatgrandchild in grandchild: 

224 key3 = greatgrandchild.tag.split("}", 1)[1] 

225 if key3 == "identificationLicence": 

226 d[key] = greatgrandchild.text 

227 else: 

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

229 elif key == "realisedInstallation": 

230 for grandchild in child: 

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

232 for greatgrandchild in grandchild: 

233 key3 = greatgrandchild.tag.split("}", 1)[1] 

234 if key3 == "installationFunction": 

235 d[key] = greatgrandchild.text 

236 else: 

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

238 

239 else: 

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

241 data.append(d) 

242 

243 gdf = objects_to_gdf(data) 

244 if zipfile is not None and extent is not None and isinstance(gdf, gpd.GeoDataFrame): 

245 if isinstance(extent, (Polygon, MultiPolygon)): 

246 gdf = gdf[gdf.intersects(extent)] 

247 else: 

248 gdf = gdf.cx[extent[0] : extent[1], extent[2] : extent[3]] 

249 return gdf 

250 

251 

252def _get_data_in_extent( 

253 bro_cl, 

254 extent=None, 

255 timeout=5, 

256 silent=False, 

257 to_path=None, 

258 to_zip=None, 

259 redownload=False, 

260 geometry=None, 

261 to_gdf=True, 

262 index="broId", 

263 continue_on_error=False, 

264 progress_callback=None, 

265): 

266 """ 

267 Retrieve data within a specified extent for a certain bro-class. 

268 

269 Parameters 

270 ---------- 

271 bro_cl : class 

272 brodata class. 

273 extent : str or object, optional 

274 Spatial extent to query. If a string, interpreted as a zip file path. 

275 timeout : int, default=5 

276 Timeout in seconds for data retrieval requests. 

277 silent : bool, default=False 

278 If True, disables progress bars and reduces logging output. 

279 to_path : str, optional 

280 Directory path to save downloaded files. 

281 to_zip : str, optional 

282 Path to a zip file to read from or save data to. 

283 redownload : bool, default=False 

284 If True, forces redownload of data even if files exist. 

285 geometry : str or object, optional 

286 Geometry specification for the output GeoDataFrame. 

287 to_gdf : bool, default=True 

288 If True, converts the output to a GeoDataFrame. 

289 index : str, default="broId" 

290 Column name to use as index in the output GeoDataFrame. 

291 continue_on_error : bool, default=False 

292 If True, continues processing other items if an error occurs. 

293 progress_callback : function, optional 

294 A callback function that takes two arguments (current, total) to report 

295 progress. If None, no progress reporting is done. Defaults to None. 

296 

297 Returns 

298 ------- 

299 gdf : GeoDataFrame 

300 GeoDataFrame containing the retrieved data objects, indexed by the specified 

301 column. 

302 

303 Raises 

304 ------ 

305 Exception 

306 If invalid arguments are provided or data retrieval fails (unless 

307 continue_on_error is True). 

308 

309 Notes 

310 ----- 

311 - If `extent` is a string, it is treated as a zip file path and `to_zip` must not 

312 be provided. 

313 - Data can be read from or saved to zip archives or directories, depending on the 

314 provided arguments. 

315 - Progress is displayed unless `silent` is True. 

316 """ 

317 if isinstance(extent, str): 

318 if to_zip is not None: 

319 raise (Exception("When extent is a string, do not supply to_zip")) 

320 to_zip = extent 

321 extent = None 

322 redownload = False 

323 zipfile = None 

324 _files = None 

325 if to_zip is not None: 

326 if not redownload and os.path.isfile(to_zip): 

327 logger.info(f"Reading data from {to_zip}") 

328 zipfile = ZipFile(to_zip) 

329 else: 

330 if to_path is None: 

331 to_path = os.path.splitext(to_zip)[0] 

332 remove_path_again = not os.path.isdir(to_path) 

333 _files = [] 

334 

335 # get gwm characteristics 

336 logger.info(f"Getting characteristics in extent: {extent}") 

337 to_file = None 

338 if zipfile is not None or to_path is not None: 

339 to_file = "characteristics.xml" 

340 if zipfile is None: 

341 to_file = os.path.join(to_path, to_file) 

342 if _files is not None: 

343 _files.append(to_file) 

344 if to_path is not None and not os.path.isdir(to_path): 

345 os.makedirs(to_path) 

346 

347 char = _get_characteristics( 

348 bro_cl, extent=extent, to_file=to_file, redownload=redownload, zipfile=zipfile 

349 ) 

350 

351 data = _get_data_for_bro_ids( 

352 bro_cl, 

353 char.index, 

354 timeout=timeout, 

355 silent=silent, 

356 to_path=to_path, 

357 zipfile=zipfile, 

358 redownload=redownload, 

359 continue_on_error=continue_on_error, 

360 progress_callback=progress_callback, 

361 _files=_files, 

362 ) 

363 if zipfile is not None: 

364 zipfile.close() 

365 if zipfile is None and to_zip is not None: 

366 util._save_data_to_zip(to_zip, _files, remove_path_again, to_path) 

367 

368 gdf = objects_to_gdf(data, geometry, to_gdf, index) 

369 

370 return gdf 

371 

372 

373def _get_data_for_bro_ids( 

374 bro_cl, 

375 bro_ids, 

376 timeout=5, 

377 silent=False, 

378 to_path=None, 

379 zipfile=None, 

380 redownload=False, 

381 continue_on_error=False, 

382 desc=None, 

383 progress_callback=None, 

384 _files=None, 

385): 

386 """ 

387 Retrieve data for a list of specified bro_ids for a certain bro-class. 

388 

389 Parameters 

390 ---------- 

391 bro_cl : class 

392 brodata class. 

393 bro_ids : list of strings 

394 A list of bro-ids to donnload data for. 

395 timeout : int, default=5 

396 Timeout in seconds for data retrieval requests. 

397 silent : bool, default=False 

398 If True, disables progress bars and reduces logging output. 

399 to_path : str, optional 

400 Directory path to save downloaded files. 

401 zipfile : zipfile.ZipFile, optional 

402 A zipfile-object. When not None, zipfile is used to read previously downloaded 

403 data from. The default is None. 

404 redownload : bool, default=False 

405 If True, forces redownload of data even if files exist. 

406 continue_on_error : bool, default=False 

407 If True, continues processing other items if an error occurs. 

408 desc : str, optional 

409 Description for the progress bar. The default is None. 

410 progress_callback : function, optional 

411 A callback function that takes two arguments (current, total) to report 

412 progress. If None, no progress reporting is done. Defaults to None. 

413 

414 Returns 

415 ------- 

416 data : dictionary 

417 A dictionary with the bro-ids as keys, and the data as values. 

418 

419 Raises 

420 ------ 

421 Exception 

422 If invalid arguments are provided or data retrieval fails (unless 

423 continue_on_error is True). 

424 

425 Notes 

426 ----- 

427 - Data can be read from or saved to zip archives or directories, depending on the 

428 provided arguments. 

429 - Progress is displayed unless `silent` is True. 

430 """ 

431 data = {} 

432 if isinstance(bro_ids, str): 

433 bro_ids = [bro_ids] 

434 total = len(bro_ids) 

435 for i, bro_id in util.tqdm( 

436 enumerate(bro_ids), total=total, disable=silent, desc=desc 

437 ): 

438 if progress_callback is not None: 

439 progress_callback(i, total) 

440 if zipfile is not None: 

441 fname = f"{bro_id}.xml" 

442 data[bro_id] = bro_cl(fname, zipfile=zipfile) 

443 continue 

444 to_file = None 

445 if to_path is not None: 

446 to_file = os.path.join(to_path, f"{bro_id}.xml") 

447 if _files is not None: 

448 _files.append(to_file) 

449 if not redownload and os.path.isfile(to_file): 

450 data[bro_id] = bro_cl(to_file) 

451 continue 

452 kwargs = {"to_file": to_file, "timeout": timeout} 

453 if continue_on_error: 

454 try: 

455 data[bro_id] = bro_cl.from_bro_id(bro_id, **kwargs) 

456 except Exception as e: 

457 logger.error("Error retrieving %s: %s", bro_id, e) 

458 else: 

459 data[bro_id] = bro_cl.from_bro_id(bro_id, **kwargs) 

460 

461 return data 

462 

463 

464def objects_to_gdf( 

465 data, 

466 geometry=None, 

467 to_gdf=True, 

468 index="broId", 

469 from_crs=None, 

470 to_crs=28992, 

471): 

472 if not to_gdf: 

473 return data 

474 if isinstance(data, list): 

475 df = pd.DataFrame(data) 

476 else: 

477 df = pd.DataFrame([data[key].to_dict() for key in data]) 

478 

479 if index is not None and not df.empty: 

480 if isinstance(index, str): 

481 if index in df.columns: 

482 df = df.set_index(index) 

483 elif np.all([x in df.columns for x in index]): 

484 # we assume index is an iterable (list), to form a MultiIndex 

485 df = df.set_index(index) 

486 if geometry is None: 

487 if "deliveredLocation" in df: 

488 geometry = "deliveredLocation" 

489 if from_crs is None: 

490 from_crs = 28992 

491 elif "standardizedLocation" in df: 

492 geometry = "standardizedLocation" 

493 if from_crs is None: 

494 from_crs = 4258 

495 else: 

496 return df 

497 gdf = gpd.GeoDataFrame(df, geometry=geometry, crs=from_crs) 

498 if to_crs is not None and from_crs is not None and to_crs != from_crs: 

499 gdf = gdf.to_crs(to_crs) 

500 return gdf 

501 

502 

503class FileOrUrl(ABC): 

504 """ 

505 A class for parsing and handling XML data from files, URLs, or zipped files. 

506 

507 Supports fetching XML data from local files or remote URLs. It also handles 

508 rejection checks and extracts data into object attributes. Data is parsed 

509 recursively and can be converted to a dictionary. 

510 

511 Attributes: 

512 Instance variables are dynamically set based on the XML content. 

513 

514 Methods: 

515 __init__(url_or_file, zipfile=None, timeout=5, to_file=None, **kwargs): 

516 Parses XML from a URL, file, or zipped file, and initializes the object. 

517 

518 from_bro_id(bro_id, **kwargs): 

519 Fetches XML data from a REST service based on a given 'bro_id'. 

520 

521 to_dict(): 

522 Converts instance attributes to a dictionary, excluding methods and 

523 private attributes. 

524 """ 

525 

526 def __init__( 

527 self, 

528 url_or_file, 

529 zipfile=None, 

530 timeout=5, 

531 to_file=None, 

532 redownload=True, 

533 max_retries=2, 

534 **kwargs, 

535 ): 

536 # CSV 

537 if url_or_file.endswith(".csv"): 

538 if zipfile is not None: 

539 self._read_csv(StringIO(zipfile.read(url_or_file)), **kwargs) 

540 else: 

541 self._read_csv(url_or_file, **kwargs) 

542 # XML or URL 

543 else: 

544 if zipfile is not None: 

545 root = ElementTree.fromstring(zipfile.read(url_or_file)) 

546 elif url_or_file.startswith("http"): 

547 if redownload or to_file is None or not os.path.isfile(to_file): 

548 params = {} 

549 if "tmin" in kwargs and kwargs["tmin"] is not None: 

550 tmin = kwargs.pop("tmin") 

551 tmin = pd.to_datetime(tmin).strftime("%Y-%m-%d") 

552 params["observationPeriodBeginDate"] = tmin 

553 if "tmax" in kwargs and kwargs["tmax"] is not None: 

554 tmax = kwargs.pop("tmax") 

555 tmax = pd.to_datetime(tmax).strftime("%Y-%m-%d") 

556 params["observationPeriodEndDate"] = tmax 

557 if max_retries > 1: 

558 adapter = requests.adapters.HTTPAdapter(max_retries=max_retries) 

559 session = requests.Session() 

560 session.mount("https://", adapter) 

561 util.wait_for_rate_limit(url_or_file) 

562 req = session.get(url_or_file, params=params, timeout=timeout) 

563 else: 

564 req = util.get_with_rate_limit( 

565 url_or_file, params=params, timeout=timeout 

566 ) 

567 if not req.ok: 

568 if req.reason == "Bad Request": 

569 root = ElementTree.fromstring(req.text) 

570 FileOrUrl._check_for_rejection(root) 

571 raise Exception(f"Retrieving data from {url_or_file} failed") 

572 if to_file is not None: 

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

574 f.write(req.text) 

575 root = ElementTree.fromstring(req.text) 

576 FileOrUrl._check_for_rejection(root) 

577 else: 

578 tree = ElementTree.parse(to_file) 

579 root = tree.getroot() 

580 else: 

581 tree = ElementTree.parse(url_or_file) 

582 root = tree.getroot() 

583 

584 self._read_contents(root, **kwargs) 

585 

586 def __repr__(self): 

587 # retrieve properties if they exist 

588 propdict = {"broId": "broId"} 

589 props = {} 

590 for key in propdict: 

591 if hasattr(self, key): 

592 props[propdict[key]] = getattr(self, key) 

593 if hasattr(self, "deliveredLocation"): 

594 if isinstance(self.deliveredLocation, Point): 

595 props["x"] = self.deliveredLocation.x 

596 props["y"] = self.deliveredLocation.y 

597 name = util._format_repr(self, props) 

598 return name 

599 

600 @abstractmethod 

601 def _read_contents(self, tree, **kwargs): 

602 """Each subclass must overload _read_contents to parse XML result.""" 

603 

604 def _read_csv(self, *args, **kwargs): 

605 raise NotImplementedError( 

606 f"Class {self.__class__.__name__} does not support reading from CSV files." 

607 ) 

608 

609 @classmethod 

610 def from_bro_id(cls, bro_id, **kwargs): 

611 if not hasattr(cls, "_rest_url"): 

612 raise (NotImplementedError(f"No rest-service defined for {cls.__name__}")) 

613 

614 return cls(f"{cls._rest_url}/objects/{bro_id}", **kwargs) 

615 

616 def to_dict(self): 

617 d = {} 

618 for attrib in dir(self): 

619 if attrib.startswith("_"): 

620 continue 

621 value = getattr(self, attrib) 

622 if type(value) is types.MethodType: 

623 continue 

624 d[attrib] = value 

625 return d 

626 

627 @staticmethod 

628 def _check_for_rejection(tree): 

629 ns = {"brocom": "http://www.broservices.nl/xsd/brocommon/3"} 

630 response_type = tree.find("brocom:responseType", ns) 

631 if response_type is not None and response_type.text == "rejection": 

632 criterionError = tree.find("brocom:criterionError", ns) 

633 if criterionError is None: 

634 rejection_reason = tree.find("brocom:rejectionReason", ns) 

635 if rejection_reason is None: 

636 msg = "Unknown rejection reason" 

637 else: 

638 msg = rejection_reason.text 

639 else: 

640 specification = criterionError.find("brocom:specification", ns) 

641 if specification is None: 

642 msg = "Unknown specification" 

643 else: 

644 msg = specification.text 

645 raise (ValueError(msg)) 

646 

647 @staticmethod 

648 def _get_tag(node): 

649 return util._get_tag(node) 

650 

651 def _get_main_object(self, tree, object_name=None, ns=None): 

652 if object_name is None: 

653 object_name = self._object_name 

654 if ns is None: 

655 ns = {"xmlns": self._xmlns} 

656 if isinstance(object_name, list): 

657 for name in object_name: 

658 objects = tree.findall(f".//xmlns:{name}", ns) 

659 if objects: 

660 break 

661 else: 

662 objects = tree.findall(f".//xmlns:{object_name}", ns) 

663 if len(objects) > 1: 

664 raise (Exception(f"Only one {object_name} supported")) 

665 elif len(objects) == 0: 

666 raise (Exception(f"No {object_name} found")) 

667 return objects[0] 

668 

669 def _warn_unknown_tag(self, tag, parent=None): 

670 class_name = self.__class__.__name__ 

671 bro_id = getattr(self, "broId", "") 

672 util._warn_unknown_tag(tag, parent, class_name, bro_id) 

673 

674 def _raise_assumed_single(self, key): 

675 raise ValueError( 

676 f"Assumed there is only one {key} in {self.__class__.__name__} {getattr(self, 'broId', '')}" 

677 ) 

678 

679 def _check_single_child_with_tag(self, node, tag): 

680 return len(node) == 1 and self._get_tag(node[0]) == tag 

681 

682 def _read_children_of_children(self, node, d=None, to_float=None, to_int=None): 

683 if to_float is not None and isinstance(to_float, str): 

684 to_float = [to_float] 

685 if to_int is not None and isinstance(to_int, str): 

686 to_int = [to_int] 

687 if len(node) == 0: 

688 key = node.tag.split("}", 1)[1] 

689 if d is None: 

690 setattr(self, key, FileOrUrl._parse_text(node, key, to_float, to_int)) 

691 else: 

692 d[key] = FileOrUrl._parse_text(node, key, to_float, to_int) 

693 else: 

694 for child in node: 

695 self._read_children_of_children( 

696 child, d=d, to_float=to_float, to_int=to_int 

697 ) 

698 

699 @staticmethod 

700 def _parse_text(node, key, to_float=None, to_int=None): 

701 if to_float is not None and key in to_float: 

702 return FileOrUrl._parse_float(node) 

703 if to_int is not None and key in to_int: 

704 return int(node.text) 

705 return node.text 

706 

707 @staticmethod 

708 def _parse_float(node): 

709 if node.text is None: 

710 return np.nan 

711 return float(node.text) 

712 

713 def _read_delivered_location(self, node): 

714 for child in node: 

715 key = self._get_tag(child) 

716 if key == "location": 

717 setattr(self, "deliveredLocation", self._read_geometry(child)) 

718 elif key == "horizontalPositioningDate": 

719 setattr(self, key, self._read_date(child)) 

720 elif key == "horizontalPositioningMethod": 

721 setattr(self, key, child.text) 

722 elif key == "horizontalPositioningOperator": 

723 setattr(self, key, self._read_operator(child)) 

724 else: 

725 self._warn_unknown_tag(key) 

726 

727 def _read_operator(self, node): 

728 d = {} 

729 for child in node: 

730 key = self._get_tag(child) 

731 if key in [ 

732 "chamberOfCommerceNumber", 

733 "europeanCompanyRegistrationNumber", 

734 ]: 

735 d[key] = child.text 

736 else: 

737 self._warn_unknown_tag(key) 

738 return d 

739 

740 def _read_standardized_location(self, node): 

741 for child in node: 

742 key = self._get_tag(child) 

743 if key == "location": 

744 setattr(self, "standardizedLocation", self._read_geometry(child)) 

745 elif key == "coordinateTransformation": 

746 setattr(self, key, child.text) 

747 else: 

748 self._warn_unknown_tag(key) 

749 

750 def _read_delivered_vertical_position(self, node, d=None): 

751 for child in node: 

752 key = self._get_tag(child) 

753 if key == "verticalPositioningDate": 

754 value = self._read_date(child) 

755 elif key == "offset": 

756 if child.text is None: 

757 value = np.nan 

758 else: 

759 value = float(child.text) 

760 elif key == "verticalPositioningOperator": 

761 value = self._read_operator(child) 

762 else: 

763 value = child.text 

764 

765 if d is None: 

766 setattr(self, key, value) 

767 else: 

768 d[key] = value 

769 

770 def _read_lifespan(self, node, d=None): 

771 for child in node: 

772 key = self._get_tag(child) 

773 if key in ["startDate", "startTime", "endTime"]: 

774 if d is None: 

775 setattr(self, key, self._read_date(child)) 

776 else: 

777 d[key] = self._read_date(child) 

778 else: 

779 self._warn_unknown_tag(key) 

780 

781 def _read_validity_period(self, node, d=None): 

782 for child in node: 

783 key = self._get_tag(child) 

784 if key == "startValidity": 

785 if d is None: 

786 setattr(self, key, self._read_date(child)) 

787 else: 

788 d[key] = self._read_date(child) 

789 elif key == "endValidity": 

790 if d is None: 

791 setattr(self, key, self._read_date(child)) 

792 else: 

793 d[key] = self._read_date(child) 

794 else: 

795 self._warn_unknown_tag(key) 

796 

797 @staticmethod 

798 def _read_geometry(node): 

799 assert len(node) == 1 

800 tag = node[0].tag.split("}")[-1] 

801 if tag == "pos": 

802 x, y = tuple(map(float, node[0].text.strip().split())) 

803 if FileOrUrl._is_epsg_4258(node): 

804 x, y = y, x 

805 return Point(x, y) 

806 geometry = gml.parse_geometry(node[0]) 

807 if FileOrUrl._is_epsg_4258(node[0]): 

808 geometry = shapely.ops.transform(lambda x, y: (y, x), geometry) 

809 return geometry 

810 

811 @staticmethod 

812 def _is_epsg_4258(node): 

813 srsName = "urn:ogc:def:crs:EPSG::4258" 

814 return "srsName" in node.attrib and node.attrib["srsName"] == srsName 

815 

816 @staticmethod 

817 def _read_date(node): 

818 ns = {"brocom": "http://www.broservices.nl/xsd/brocommon/3.0"} 

819 date = node.find("brocom:date", ns) 

820 if date is None: 

821 date = node.find("brocom:yearMonth", ns) 

822 if date is None: 

823 date = node.find("brocom:year", ns) 

824 if date is None: 

825 return pd.NaT 

826 return pd.to_datetime(date.text) 

827 

828 @staticmethod 

829 def _read_time_instant(node): 

830 ns = {"gml": "http://www.opengis.net/gml/3.2"} 

831 time_instant = node.find("gml:TimeInstant", ns) 

832 time_position = time_instant.find("gml:timePosition", ns) 

833 return pd.to_datetime(time_position.text) 

834 

835 def _read_data_array(self, node): 

836 values = None 

837 for child in node: 

838 key = self._get_tag(child) 

839 if key == "encoding": 

840 ns = {"swe": "http://www.opengis.net/swe/2.0"} 

841 text_encoding = child.find("swe:TextEncoding", ns) 

842 encoding = text_encoding.attrib.copy() 

843 elif key == "elementCount": 

844 pass 

845 elif key == "elementType": 

846 pass 

847 elif key == "values": 

848 values = pd.read_csv( 

849 StringIO(child.text), 

850 header=None, 

851 decimal=encoding["decimalSeparator"], 

852 sep=encoding["tokenSeparator"], 

853 lineterminator=encoding["blockSeparator"], 

854 na_values=-999999, 

855 ) 

856 else: 

857 self._warn_unknown_tag(key) 

858 return values 

859 

860 def _read_descriptive_borehole_log(self, node): 

861 d = {} 

862 for child in node: 

863 key = self._get_tag(child) 

864 if key in [ 

865 "descriptionQuality", 

866 "describedSamplesQuality", 

867 "continuouslySampled", 

868 "descriptionLocation", 

869 "describedMaterial", 

870 "sampleMoistness", 

871 "boreholeLogChecked", 

872 ]: 

873 d[key] = child.text 

874 elif key == "layer": 

875 if key not in d: 

876 d[key] = [] 

877 if self._check_single_child_with_tag(child, "Layer"): 

878 child = child[0] 

879 layer = {} 

880 for grandchild in child: 

881 key2 = self._get_tag(grandchild) 

882 if key2 in ["upperBoundary", "lowerBoundary"]: 

883 layer[key2] = self._parse_float(grandchild) 

884 elif key2 in [ 

885 "upperBoundaryDetermination", 

886 "lowerBoundaryDetermination", 

887 "anthropogenic", 

888 "activityType", 

889 "specialMaterial", 

890 "slant", 

891 "internalStructureIntact", 

892 "bedded", 

893 "compositeLayer", 

894 "bedding", 

895 "rooted", 

896 "identification", 

897 ]: 

898 layer[key2] = grandchild.text 

899 elif key2 == "soil": 

900 self._read_soil(grandchild, layer) 

901 elif key2 == "rock": 

902 self._read_rock(grandchild, layer) 

903 elif key2 == "soilType": 

904 for greatgrandchild in grandchild: 

905 key2 = self._get_tag(greatgrandchild) 

906 if key2 in ["soilName", "sandMedianClass"]: 

907 layer[key2] = greatgrandchild.text 

908 elif key2 == "particularConstituent": 

909 for greatgrandchild in grandchild: 

910 key2 = self._get_tag(greatgrandchild) 

911 if key2 in ["ConstituentType"]: 

912 layer[key2] = greatgrandchild.text 

913 else: 

914 self._warn_unknown_tag(key2) 

915 d[key].append(layer) 

916 else: 

917 self._warn_unknown_tag(key) 

918 if "layer" in d: 

919 d["layer"] = pd.DataFrame(d["layer"]) 

920 return d 

921 

922 def _read_soil(self, node, d): 

923 for child in node: 

924 key = self._get_tag(child) 

925 if key in [ 

926 "geotechnicalSoilName", 

927 "soilNameNEN5104", 

928 "gravelContentClassNEN5104", 

929 "organicMatterContentClassNEN5104", 

930 "colour", 

931 "mottled", 

932 "interbedding", 

933 "carbonateContentClass", 

934 "organicMatterContentClass", 

935 "crossBedding", 

936 "gradedBedding", 

937 "mixed", 

938 "mixingType", 

939 "gravelMedianClass", 

940 "fineGravelContentClass", 

941 "mediumCoarseGravelContentClass", 

942 "veryCoarseGravelContentClass", 

943 "sandMedianClass", 

944 "sandSortingNEN5104", 

945 "peatType", 

946 "organicSoilTexture", 

947 "fineSoilConsistency", 

948 "organicSoilConsistency", 

949 "peatTensileStrength", 

950 "geotechnicalDepositionalCharacteristic", 

951 "depositionalAge", 

952 "classificationLoamBased", 

953 "pedologicalSoilName", 

954 "structureType", 

955 "estimatedDensity", 

956 "ripeningClass", 

957 "vertic", 

958 "containsShellMatter", 

959 "containsGravel", 

960 "gravelContentClass", 

961 "chunk", 

962 "moistness", 

963 ]: 

964 d[key] = child.text 

965 elif key in ["estimatedOrganicMatterContent", "estimatedClayContent"]: 

966 d[key] = float(child.text) 

967 elif key in ["tertiaryConstituent", "dispersedInhomogeneity"]: 

968 if key not in d: 

969 d[key] = [] 

970 d[key].append(child.text) 

971 elif key == "grainshape": 

972 for grandchild in child: 

973 key = self._get_tag(grandchild) 

974 if key in ["sizeFraction", "angularity", "sphericity"]: 

975 d[key] = grandchild.text 

976 else: 

977 self._warn_unknown_tag(key) 

978 elif key == "incompleteFractionSpecification": 

979 for grandchild in child: 

980 key = self._get_tag(grandchild) 

981 if key in ["estimatedOrganicMatterContent", "estimatedClayContent"]: 

982 d[key] = float(grandchild.text) 

983 else: 

984 self._warn_unknown_tag(key) 

985 elif key == "stain": 

986 for grandchild in child: 

987 key = self._get_tag(grandchild) 

988 if key in ["stainColour", "mottlingDensity", "evenlyMottled"]: 

989 d[key] = grandchild.text 

990 else: 

991 self._warn_unknown_tag(key) 

992 elif key == "soilAggregate": 

993 for grandchild in child: 

994 key = self._get_tag(grandchild) 

995 if key in [ 

996 "aggregateShape", 

997 "angularity", 

998 "roughness", 

999 "aggregateLengthClass", 

1000 "poreAbundanceClass", 

1001 "horizontallyAligned", 

1002 "disintegrating", 

1003 ]: 

1004 d[key] = grandchild.text 

1005 else: 

1006 self._warn_unknown_tag(key) 

1007 elif key == "fractionDistribution": 

1008 for grandchild in child: 

1009 key = self._get_tag(grandchild) 

1010 if key in [ 

1011 "estimatedGravelContent", 

1012 "estimatedShellMatterContent", 

1013 "estimatedOrganicMatterContent", 

1014 "estimatedFineFractionContent", 

1015 ]: 

1016 d[key] = float(grandchild.text) 

1017 elif key == "fineFractionDistribution": 

1018 for greatgrandchild in grandchild: 

1019 key = self._get_tag(greatgrandchild) 

1020 if key in [ 

1021 "estimatedClayContent", 

1022 "estimatedSiltContent", 

1023 "estimatedSandContent", 

1024 ]: 

1025 d[key] = float(greatgrandchild.text) 

1026 else: 

1027 self._warn_unknown_tag(key) 

1028 else: 

1029 self._warn_unknown_tag(key) 

1030 elif key == "munsellColour": 

1031 for grandchild in child: 

1032 key = self._get_tag(grandchild) 

1033 if key in ["munsellHue", "munsellValue", "munsellChroma"]: 

1034 d[key] = grandchild.text 

1035 else: 

1036 self._warn_unknown_tag(key) 

1037 elif key == "sandFraction": 

1038 for grandchild in child: 

1039 key = self._get_tag(grandchild) 

1040 if key in ["sandMedianClass", "sandSorting"]: 

1041 d[key] = grandchild.text 

1042 else: 

1043 self._warn_unknown_tag(key) 

1044 else: 

1045 self._warn_unknown_tag(key) 

1046 

1047 def _read_rock(self, node, d): 

1048 for child in node: 

1049 key = self._get_tag(child) 

1050 if key in [ 

1051 "rockType", 

1052 "cementType", 

1053 "colour", 

1054 "carbonateContentClass", 

1055 "crossBedding", 

1056 "gradedBedding", 

1057 "voidsPresent", 

1058 "voidDistribution", 

1059 "stability", 

1060 "strengthClass", 

1061 "weathered", 

1062 ]: 

1063 d[key] = child.text 

1064 elif key in ["tertiaryRockConstituent", "dispersedInhomogeneity"]: 

1065 if key not in d: 

1066 d[key] = [] 

1067 d[key].append(child.text) 

1068 elif key == "weatheringDegree": 

1069 for grandchild in child: 

1070 key = self._get_tag(grandchild) 

1071 if key in ["discolouration", "disintegration", "decomposition"]: 

1072 d[key] = grandchild.text 

1073 else: 

1074 self._warn_unknown_tag(key) 

1075 else: 

1076 self._warn_unknown_tag(key) 

1077 

1078 

1079def get_bronhouders(index="kvk", **kwargs): 

1080 """ 

1081 Get the name, kvk-number and the identifier of bronhouders (data owners). 

1082 

1083 Parameters 

1084 ---------- 

1085 index : string, optional 

1086 The column to set as the index of the resulting DataFrame. The default is "kvk". 

1087 **kwargs : dict 

1088 Kwargs are passed onto pandas.read_json(). 

1089 

1090 Returns 

1091 ------- 

1092 df : pd.DataFrame 

1093 A Pandas DataFrame, with one row per bronhouder. 

1094 

1095 """ 

1096 url = "https://bromonitor.nl/api/rapporten/bronhouders" 

1097 df = pd.read_json(url, **kwargs) 

1098 if index is not None: 

1099 df = df.set_index(index) 

1100 return df 

1101 

1102 

1103def get_brondocumenten_per_bronhouder(index=("kvk", "type"), timeout=5, **kwargs): 

1104 """ 

1105 Get the number of documents per bronhouder (data owner). 

1106 

1107 Parameters 

1108 ---------- 

1109 index : str, tuple or list, optional 

1110 The column(s) to set as the index of the resulting DataFrame. The default is 

1111 "kvk" and "type". 

1112 timeout : int or float, optional 

1113 A number indicating how many seconds to wait for the client to make a connection 

1114 and/or send a response. The default is 5. 

1115 **kwargs : dict 

1116 Kwargs are passed onto pandas.DataFrame(). 

1117 

1118 Returns 

1119 ------- 

1120 df : pd.DataFrame 

1121 A Pandas DataFrame, with one row per combination of bronhouder and data-type. 

1122 

1123 """ 

1124 url = "https://bromonitor.nl/api/rapporten/brondocumenten-per-bronhouder" 

1125 r = util.get_with_rate_limit(url, timeout=timeout) 

1126 if not r.ok: 

1127 raise (Exception("Download of brondocumenten per bronhouder failed")) 

1128 df = pd.DataFrame(r.json()["data"], **kwargs) 

1129 if "key" in df.columns: 

1130 df = pd.concat((pd.DataFrame(list(df["key"])), df.drop(columns="key")), axis=1) 

1131 if index is not None: 

1132 if isinstance(index, tuple): 

1133 index = list(index) 

1134 df = df.set_index(index) 

1135 return df 

1136 

1137 

1138def get_kvk_df(fn_bronhouder_kvk=None): 

1139 """ 

1140 Read manually saved table of KVK and Organisatienaam to DataFrame. 

1141 

1142 from https://basisregistratieondergrond.nl/service-contact/formulieren/aangemeld-bro/ 

1143 :param fn_bronhouder_kvk: str, filename of the file with bronhouder and kvk numbers 

1144 :return: pandas DataFrame with kvk as index and column 'Organisatienaam' and 'Bronhouder' 

1145 """ 

1146 if fn_bronhouder_kvk is None: 

1147 fn_bronhouder_kvk = os.path.join( 

1148 os.path.dirname(__file__), "data", "bronhouder_kvk.txt" 

1149 ) 

1150 

1151 df_bron_kvk = pd.read_csv( 

1152 fn_bronhouder_kvk, 

1153 sep=";", # is a dummy value, data will be split later on the last | sign 

1154 dtype=str, 

1155 header=None, 

1156 names=["all_data"], 

1157 skipinitialspace=True, 

1158 comment="#", 

1159 ) 

1160 

1161 # split column all_data into bronhouder and kvk, using last | sign; both as string type 

1162 # mind that index has string type, as that is format provided in brodata downloads 

1163 df_bron_kvk[["Organisatienaam", "KVK-nummer"]] = ( 

1164 df_bron_kvk["all_data"].str.rsplit("|", n=1, expand=True).astype(str) 

1165 ) 

1166 df_bron_kvk = df_bron_kvk.drop(columns=["all_data"]) 

1167 

1168 # add column Bronhouder, value is True when (​B) in kvk 

1169 df_bron_kvk["Bronhouder"] = False 

1170 

1171 bronhouder_pattern = r"[(​B)|(B)]" 

1172 df_bron_kvk.loc[ 

1173 df_bron_kvk["KVK-nummer"].str.contains(bronhouder_pattern, regex=True), 

1174 "Bronhouder", 

1175 ] = True 

1176 # clean up kvk 

1177 df_bron_kvk["KVK-nummer"] = ( 

1178 df_bron_kvk["KVK-nummer"] 

1179 .str.replace(bronhouder_pattern, "", regex=True) 

1180 .str.strip() 

1181 ) 

1182 

1183 # remove leading and trailing whitespace from all columns 

1184 df_bron_kvk = df_bron_kvk.map(lambda x: x.strip() if isinstance(x, str) else x) 

1185 

1186 # make kvk index 

1187 df_bron_kvk.set_index("KVK-nummer", inplace=True) 

1188 

1189 return df_bron_kvk