Coverage for src/geodense/lib.py: 94%

345 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-11 14:11 +0000

1import json 

2import logging 

3import math 

4import os 

5import sys 

6import tempfile 

7from collections.abc import Callable, Iterable, Sequence 

8from enum import Enum 

9from functools import partial 

10from typing import Literal, TextIO, cast 

11 

12from geojson_pydantic import ( 

13 Feature, 

14 GeometryCollection, 

15 LineString, 

16 MultiLineString, 

17 MultiPoint, 

18 MultiPolygon, 

19 Point, 

20 Polygon, 

21) 

22from geojson_pydantic.geometries import Geometry 

23from geojson_pydantic.types import LineStringCoords, Position, Position2D, Position3D 

24from pydantic import BaseModel 

25from pyproj import CRS 

26from shapely import LineString as ShpLineString 

27from shapely import Point as ShpPoint 

28 

29from geodense.geojson import CrsFeatureCollection 

30from geodense.models import DEFAULT_PRECISION_METERS, DenseConfig, GeodenseError 

31from geodense.types import ( 

32 GeojsonCoordinates, 

33 GeojsonGeomNoGeomCollection, 

34 GeojsonObject, 

35 Nested, 

36 ReportLineString, 

37 T, 

38) 

39 

40TWO_DIMENSIONAL = 2 

41THREE_DIMENSIONAL = 3 

42DEFAULT_CRS_2D = "OGC:CRS84" 

43DEFAULT_CRS_3D = "OGC:CRS84h" 

44SUPPORTED_FILE_FORMATS = { 

45 "GeoJSON": [".geojson", ".json"], 

46} 

47 

48logger = logging.getLogger("geodense") 

49 

50 

51class InfValCoordinateError(Exception): 

52 pass 

53 

54 

55def densify_geojson_object(densify_config: DenseConfig, geojson_obj: GeojsonObject) -> GeojsonObject: 

56 validate_geom_type(geojson_obj, "densify") 

57 _densify_geometry = partial(densify_geometry, densify_config) 

58 return traverse_geojson_geometries(geojson_obj, _densify_geometry) 

59 

60 

61def densify_geometry(densify_config: DenseConfig, geometry: GeojsonGeomNoGeomCollection) -> None: 

62 _densify_line_segment = partial(densify_line_segment, densify_config) 

63 result: GeojsonCoordinates = traverse_linestrings_in_coordinates(geometry.coordinates, _densify_line_segment) 

64 geometry.coordinates = result 

65 

66 

67def densify_line_segment( 

68 densify_config: DenseConfig, 

69 coords: GeojsonCoordinates, 

70) -> None: 

71 linestring = cast(LineStringCoords, coords) 

72 added_nodes = 0 

73 stop = len(linestring) - 1 

74 for i, _ in enumerate(linestring[:stop]): 

75 added_nodes += _add_vertices_to_line_segment(linestring, i + added_nodes, densify_config) 

76 

77 

78def check_density_geojson_object(densify_config: DenseConfig, geojson_obj: GeojsonObject) -> CrsFeatureCollection: 

79 validate_geom_type(geojson_obj, "density-check") 

80 _check_density_geometry = partial(check_density_geometry, densify_config) 

81 result: Nested[ReportLineString] | None = transform_geojson_geometries(geojson_obj, _check_density_geometry) 

82 if result is None: 

83 raise ValueError("_check_density_geometry returned None") 

84 

85 flat_result: list[ReportLineString] = ( 

86 list( # filter out None values, these occur when point geometries are part of input 

87 filter(lambda x: x is not None, _flatten(result)) 

88 ) 

89 ) 

90 report_fc = _report_line_string_to_geojson(flat_result, ":".join(densify_config.src_crs.to_authority())) 

91 return report_fc 

92 

93 

94def check_density_geometry( 

95 densify_config: DenseConfig, 

96 geometry: GeojsonGeomNoGeomCollection, 

97) -> Nested[ReportLineString] | None: 

98 _check_density_linestring = partial(check_density_linestring, densify_config) 

99 return transform_linestrings_in_coordinates(geometry.coordinates, _check_density_linestring) 

100 

101 

102def check_density_linestring( 

103 densify_config: DenseConfig, 

104 linestring: LineStringCoords, 

105) -> list[ReportLineString]: 

106 result = [] 

107 

108 for k in range(0, len(linestring) - 1): 

109 a: Position = linestring[k] 

110 b: Position = linestring[k + 1] 

111 

112 a_2d = cast(Position2D, a[0:2]) 

113 b_2d = cast(Position2D, b[0:2]) 

114 

115 if densify_config.in_projection: 

116 linesegment_dist = _cartesian_distance(a_2d, b_2d) 

117 else: 

118 if densify_config.src_crs.is_projected: # only convert to basegeographic crs if src_proj is projected 

119 transformer = densify_config.transformer 

120 if transformer is None: 

121 raise GeodenseError("transformer cannot be None when src_crs.is_projected=True") 

122 a_t = transformer.transform(*a_2d) 

123 b_t = transformer.transform(*b_2d) 

124 else: # src_crs is geographic do not transform 

125 a_t, b_t = (a_2d, b_2d) 

126 g = densify_config.geod 

127 

128 _, _, geod_dist = g.inv(*a_t, *b_t, return_back_azimuth=True) 

129 if math.isnan(geod_dist): 

130 raise GeodenseError( 

131 f"unable to calculate geodesic distance, output calculation geodesic distance: {geod_dist}, expected: floating-point number" 

132 ) 

133 linesegment_dist = geod_dist 

134 if linesegment_dist > (densify_config.max_segment_length + 0.001): 

135 result.append((linesegment_dist, (a, b))) 

136 return result 

137 

138 

139def _validate_dependent_file_args( 

140 input_file_path: str, 

141 output_file_path: str | None = None, 

142 overwrite: bool = False, 

143) -> None: 

144 if output_file_path is not None and (input_file_path == output_file_path and input_file_path != "-"): 

145 raise GeodenseError( 

146 f"input_file and output_file arguments must be different, input_file: {input_file_path}, output_file: {output_file_path}" 

147 ) 

148 if output_file_path is not None and output_file_path != "-": 

149 if os.path.exists(output_file_path) and not overwrite: 

150 raise GeodenseError(f"output_file {output_file_path} already exists") 

151 elif os.path.exists(output_file_path) and overwrite: 

152 os.remove(output_file_path) 

153 

154 

155def check_density_file( # noqa: PLR0913 

156 input_file_path: str, 

157 max_segment_length: float, 

158 density_check_report_path: str | None = None, 

159 src_crs: str | None = None, 

160 in_projection: bool = False, 

161 overwrite: bool = False, 

162) -> tuple[bool, str, int]: 

163 if density_check_report_path is None: 

164 density_check_report_path = os.path.join(tempfile.mkdtemp(), "check-density-report.json") 

165 

166 _validate_dependent_file_args(input_file_path, density_check_report_path, overwrite) 

167 

168 with open(input_file_path) if input_file_path != "-" else sys.stdin as src: 

169 geojson_obj = textio_to_geojson(src) 

170 validate_geom_type(geojson_obj, "check-density") 

171 has_3d_coords: Has3D = _has_3d_coordinates(geojson_obj) 

172 geojson_src_crs = _get_crs_geojson(geojson_obj, input_file_path, src_crs, has_3d_coords) 

173 config = DenseConfig( 

174 CRS.from_authority(*geojson_src_crs.split(":")), 

175 max_segment_length, 

176 in_projection=in_projection, 

177 ) 

178 report_fc = check_density_geojson_object(config, geojson_obj) 

179 

180 failed_segment_count = len(report_fc.features) 

181 check_status = failed_segment_count == 0 

182 

183 if not check_status: 

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

185 f.write(report_fc.model_dump_json(indent=4, exclude_none=True)) 

186 return (check_status, density_check_report_path, len(report_fc.features)) 

187 

188 

189def _report_line_string_to_geojson( 

190 report: list[ReportLineString], src_crs_auth_code: str | None 

191) -> CrsFeatureCollection: 

192 features: list[Feature] = [ 

193 Feature( 

194 type="Feature", 

195 properties={"segment_length": x[0]}, 

196 geometry=LineString(type="LineString", coordinates=list(x[1])), 

197 ) 

198 for x in report 

199 ] 

200 result = CrsFeatureCollection(features=features, type="FeatureCollection", name="density-check-report") 

201 if src_crs_auth_code is not None: 

202 result.set_crs_auth_code(src_crs_auth_code) 

203 return result 

204 

205 

206def densify_file( # noqa: PLR0913 

207 input_file_path: str, 

208 output_file_path: str, 

209 overwrite: bool = False, 

210 max_segment_length: float | None = None, 

211 densify_in_projection: bool = False, 

212 src_crs: str | None = None, 

213) -> None: 

214 """_summary_ 

215 

216 Arguments: 

217 input_file_path: assumes file exists otherwise raises FileNotFoundError 

218 output_file_path: assumes directory of output_file_path exists 

219 

220 Keyword Arguments: 

221 layer -- layer name, when no specified and multilayer file, first layer will be used (default: {None}) 

222 max_segment_length -- max segment length to use for densification (default: {None}) 

223 densify_in_projection -- user src projection for densification (default: {False}) 

224 src_crs -- override src crs of input file (default: {None}) 

225 

226 Raises: 

227 ValueError: application errors 

228 pyproj.exceptions.CRSError: when crs cannot be found by pyproj 

229 

230 """ 

231 _validate_dependent_file_args(input_file_path, output_file_path, overwrite) 

232 src: TextIO 

233 with open(input_file_path) if input_file_path != "-" else sys.stdin as src: 

234 geojson_obj = textio_to_geojson(src) 

235 has_3d_coords: Has3D = _has_3d_coordinates(geojson_obj) 

236 geojson_src_crs = _get_crs_geojson(geojson_obj, input_file_path, src_crs, has_3d_coords) 

237 config = DenseConfig( 

238 CRS.from_authority(*geojson_src_crs.split(":")), 

239 max_segment_length, 

240 densify_in_projection, 

241 ) 

242 densify_geojson_object(config, geojson_obj) 

243 if src_crs is not None and isinstance(geojson_obj, CrsFeatureCollection): 

244 geojson_obj.set_crs_auth_code(src_crs) 

245 with open(output_file_path, "w") if output_file_path != "-" else sys.stdout as out_f: 

246 geojson_obj_model: BaseModel = cast(BaseModel, geojson_obj) 

247 

248 out_f.write(geojson_obj_model.model_dump_json(indent=1, exclude_none=True)) 

249 

250 

251def transform_linestrings_in_coordinates( 

252 coordinates: GeojsonCoordinates, 

253 callback: Callable[[GeojsonCoordinates], T], 

254) -> Nested | T | None: 

255 # when point geometry return None, since we only want to operate on linestring-like coordinates sequences 

256 if isinstance(coordinates, tuple): 

257 return None 

258 elif not _is_linestring_geom(coordinates): 

259 _self = partial(transform_linestrings_in_coordinates, callback=callback) 

260 return list( 

261 map( 

262 _self, 

263 coordinates, 

264 ) 

265 ) 

266 else: 

267 return callback(coordinates) 

268 

269 

270def traverse_linestrings_in_coordinates( 

271 coordinates: GeojsonCoordinates, 

272 callback: Callable[[GeojsonCoordinates], None], 

273) -> GeojsonCoordinates: 

274 """Differs from transform_linestrings_in_coordinates in that it does not transform the type of coordinates, so coordinates in > coordinates out. transform_linestrings_in_coordinates expects a callback that transforms the linestring coordinates to generic type T.""" 

275 # maybe do perform mutations on copy 

276 if isinstance(coordinates, tuple): 

277 return coordinates 

278 elif not _is_linestring_geom(coordinates): 

279 _self = partial(traverse_linestrings_in_coordinates, callback=callback) 

280 list( 

281 map( 

282 _self, 

283 coordinates, 

284 ) 

285 ) 

286 else: 

287 callback(coordinates) 

288 return coordinates 

289 

290 

291def traverse_geojson_geometries( 

292 geojson: Feature | CrsFeatureCollection | Geometry, 

293 geometry_callback: Callable[[Geometry], None] | None = None, 

294 node_callback: Callable | None = None, 

295) -> Feature | CrsFeatureCollection | Geometry: 

296 _self: Callable[ 

297 [Feature | CrsFeatureCollection | Geometry], 

298 Feature | CrsFeatureCollection | Geometry, 

299 ] = partial( 

300 traverse_geojson_geometries, 

301 geometry_callback=geometry_callback, 

302 node_callback=node_callback, 

303 ) 

304 

305 _geojson = geojson.model_copy(deep=True) 

306 

307 if isinstance(geojson, Feature): 

308 feature = cast(Feature, geojson) 

309 _feature = cast(Feature, _geojson) 

310 geom = cast(GeojsonGeomNoGeomCollection, feature.geometry) 

311 try: 

312 _feature.geometry = _self(geom) 

313 except InfValCoordinateError as _: 

314 _feature.geometry = None 

315 

316 elif isinstance(geojson, CrsFeatureCollection): 

317 _feature_collection: CrsFeatureCollection = cast(CrsFeatureCollection, _geojson) 

318 _feature_collection.features = list(map(_self, _feature_collection.features)) 

319 elif isinstance(geojson, GeometryCollection): 

320 _geometry_collection = cast(GeometryCollection, _geojson) 

321 __self = cast( 

322 Callable[ 

323 [Geometry], 

324 Geometry, 

325 ], 

326 _self, 

327 ) # cast to more specific type, to fix mypy error 

328 _geometry_collection.geometries = list(map(__self, _geometry_collection.geometries)) 

329 elif isinstance( 

330 geojson, 

331 Point | MultiPoint | LineString | MultiLineString | Polygon | MultiPolygon, 

332 ): 

333 if geometry_callback is not None: 

334 _geom = cast(GeojsonGeomNoGeomCollection, _geojson) 

335 geometry_callback(_geom) 

336 

337 if node_callback: 

338 node_callback(_geojson) 

339 

340 return _geojson 

341 

342 

343def transform_geojson_geometries( 

344 geojson: (Feature | CrsFeatureCollection | GeojsonGeomNoGeomCollection | GeometryCollection), 

345 geometry_callback: Callable[[GeojsonGeomNoGeomCollection], T], 

346) -> Nested | T: 

347 _self = partial(transform_geojson_geometries, geometry_callback=geometry_callback) 

348 

349 if isinstance(geojson, Feature): 

350 feature = cast(Feature, geojson) 

351 geom = cast(GeojsonGeomNoGeomCollection, feature.geometry) 

352 return _self(geom) 

353 

354 elif isinstance(geojson, CrsFeatureCollection): 

355 feature_collection: CrsFeatureCollection = cast(CrsFeatureCollection, geojson) 

356 return list(map(_self, feature_collection.features)) 

357 elif isinstance(geojson, GeometryCollection): 

358 geometry_collection = cast(GeometryCollection, geojson) 

359 return list(map(_self, geometry_collection.geometries)) 

360 else: 

361 # isinstance( 

362 # geojson, 

363 # Point | MultiPoint | LineString | MultiLineString | Polygon | MultiPolygon, 

364 # ): 

365 geom = cast(GeojsonGeomNoGeomCollection, geojson) 

366 return geometry_callback(geom) 

367 

368 

369def interpolate_geodesic(a: Position, b: Position, densify_config: DenseConfig) -> LineStringCoords: 

370 """geodesic interpolate intermediate points between points a and b, with segment_length < max_segment_length. Only returns intermediate points.""" 

371 

372 three_dimensional_points = len(a) == THREE_DIMENSIONAL and len(b) == THREE_DIMENSIONAL 

373 a_2d = Position2D(longitude=a.longitude, latitude=a.latitude) 

374 b_2d = Position2D(longitude=b.longitude, latitude=b.latitude) 

375 

376 transformer = densify_config.transformer 

377 

378 if densify_config.src_crs.is_projected: # only convert to basegeographic crs if src_proj is projected 

379 if transformer is None: 

380 raise GeodenseError("transformer cannot be None when src_crs.is_projected=True") 

381 # technically the following transform call is a converion and not a transformation, since crs->base-crs will be a conversion in most cases 

382 a_t: tuple[float, float] = transformer.transform(*a_2d) 

383 b_t: tuple[float, float] = transformer.transform(*b_2d) 

384 else: # src_crs is geographic do not transform 

385 a_t, b_t = (a_2d, b_2d) 

386 

387 g = densify_config.geod 

388 

389 az12, _, geod_dist = g.inv(*a_t, *b_t, return_back_azimuth=True) 

390 if math.isnan(geod_dist): 

391 raise GeodenseError( 

392 f"unable to calculate geodesic distance, output calculation geodesic distance: {geod_dist}, expected: floating-point number" 

393 ) 

394 

395 if geod_dist <= densify_config.max_segment_length: 

396 return [] 

397 else: 

398 ( 

399 nr_points, 

400 new_max_segment_length, 

401 ) = _get_intermediate_nr_points_and_segment_length(geod_dist, densify_config.max_segment_length) 

402 r = g.fwd_intermediate( 

403 *a_t, 

404 az12, 

405 npts=nr_points, 

406 del_s=new_max_segment_length, 

407 return_back_azimuth=True, 

408 ) 

409 

410 def optional_back_transform(lon: float, lat: float) -> Position2D: 

411 """technically should be named optional_back_convert, since crs->base crs is (mostly) a conversion and not a transformation""" 

412 if densify_config.src_crs.is_projected: 

413 if densify_config.back_transformer is None: 

414 raise GeodenseError("back_transformer cannot be None when src_crs.is_projected=True") 

415 _result = densify_config.back_transformer.transform(lon, lat) 

416 return Position2D(longitude=_result[0], latitude=_result[1]) 

417 return Position2D(longitude=lon, latitude=lat) 

418 

419 if three_dimensional_points: 

420 # interpolate height for three_dimensional_points 

421 a_3d = cast(Position3D, a) 

422 b_3d = cast(Position3D, b) 

423 height_a = a_3d[2:][0] 

424 height_b = b_3d[2:][0] 

425 delta_height_b_a = height_b - height_a 

426 delta_height_per_point = delta_height_b_a * (new_max_segment_length / geod_dist) 

427 return [ 

428 Position3D( 

429 *optional_back_transform(lon, lat), 

430 altitude=round( 

431 (height_a + ((i + 1) * delta_height_per_point)), 

432 DEFAULT_PRECISION_METERS, 

433 ), 

434 ) 

435 for i, (lon, lat) in enumerate(zip(r.lons, r.lats, strict=True)) 

436 ] 

437 else: 

438 return [optional_back_transform(lon, lat) for lon, lat in zip(r.lons, r.lats, strict=True)] 

439 

440 

441def _cartesian_distance(a: Position, b: Position) -> float: 

442 return math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) # pythagoras 

443 

444 

445def interpolate_src_proj(a: Position, b: Position, densify_config: DenseConfig) -> LineStringCoords: 

446 """Interpolate intermediate points between points a and b, with segment_length < max_segment_length. Only returns intermediate points.""" 

447 

448 all_three_dimensional = len(a) == THREE_DIMENSIONAL and len(b) == THREE_DIMENSIONAL 

449 # when mixed 2D/3D reduce to 2D, shapely cannot do interpolation on points with mixed dimensionality 

450 if not all_three_dimensional: 

451 a = Position2D(longitude=a.longitude, latitude=a.latitude) 

452 b = Position2D(longitude=b.longitude, latitude=b.latitude) 

453 

454 dist = _cartesian_distance(a, b) 

455 if dist <= densify_config.max_segment_length: 

456 return [] 

457 else: 

458 new_points: list[Position] = [] 

459 

460 ( 

461 nr_points, 

462 new_max_segment_length, 

463 ) = _get_intermediate_nr_points_and_segment_length(dist, densify_config.max_segment_length) 

464 

465 for i in range(0, nr_points): 

466 p_point: ShpPoint = ShpLineString([a, b]).interpolate(new_max_segment_length * (i + 1)) 

467 p_2d = Position2D(longitude=p_point.coords[0][0], latitude=p_point.coords[0][1]) 

468 if len(p_point.coords[0]) == THREE_DIMENSIONAL: 

469 p: Position = Position3D(*p_2d, altitude=p_point.coords[0][2]) # type: ignore 

470 else: 

471 p = p_2d 

472 new_points.append(p) 

473 return [ 

474 *new_points, 

475 ] 

476 

477 

478def textio_to_geojson(src: TextIO) -> GeojsonObject: 

479 src_json = json.loads(src.read()) 

480 type_map = { 

481 "Feature": Feature, 

482 "GeometryCollection": GeometryCollection, 

483 "FeatureCollection": CrsFeatureCollection, 

484 "Point": Point, 

485 "MultiPoint": MultiPoint, 

486 "Polygon": Polygon, 

487 "MultiPolygon": MultiPolygon, 

488 "LineString": LineString, 

489 "MultiLineString": MultiLineString, 

490 } 

491 try: 

492 geojson_type = src_json["type"] 

493 constructor = type_map[geojson_type] 

494 except KeyError as e: 

495 message = f'received invalid GeoJSON file, loc: `.type`, value: `{src_json["type"]}`, expected one of: {", ".join(list(type_map.keys()))}' 

496 raise GeodenseError(message) from e 

497 geojson_obj: GeojsonObject = constructor(**src_json) 

498 return geojson_obj 

499 

500 

501class Has3D(Enum): 

502 all: Literal["all"] = "all" 

503 some: Literal["some"] = "some" 

504 none: Literal["none"] = "none" 

505 

506 

507def _get_crs_geojson( 

508 geojson_object: GeojsonObject, 

509 input_file_path: str, 

510 src_crs: str | None, 

511 has_3d_coords: Has3D, 

512) -> str: 

513 result_crs: str | None = None 

514 

515 is_fc = False 

516 if isinstance(geojson_object, CrsFeatureCollection): 

517 result_crs = geojson_object.get_crs_auth_code() 

518 is_fc = True 

519 

520 # case to set default CRS if src_crs not specified and not available in GeoJSON 

521 if ( 

522 result_crs is None and src_crs is None 

523 ): # set default crs if not in geojson object and not overridden with src_crs 

524 default_crs = DEFAULT_CRS_2D 

525 if has_3d_coords in [Has3D.some, Has3D.all]: 

526 default_crs = DEFAULT_CRS_3D 

527 message = f"unable to determine source CRS for file {input_file_path}, assumed CRS is {default_crs}" 

528 logger.warning(message) 

529 result_crs = default_crs 

530 if is_fc: 

531 fc: CrsFeatureCollection = cast(CrsFeatureCollection, geojson_object) 

532 fc.set_crs_auth_code(result_crs) 

533 

534 # is src_crs is set use src_crs 

535 elif src_crs is not None: 

536 src_crs_crs: CRS = CRS.from_authority(*src_crs.split(":")) 

537 if has_3d_coords == Has3D.all and not src_crs_crs.is_vertical: 

538 logger.warning("src_crs is 2D while input data contains geometries with 3D coordinates") 

539 result_crs = src_crs if src_crs is not None else result_crs # override json_crs with src_crs if defined 

540 

541 elif result_crs is None: 

542 raise GeodenseError("could not determine CRS from GeoJSON object") 

543 

544 return result_crs 

545 

546 

547def _flatten(container: Nested) -> Iterable: 

548 if isinstance(container, tuple | str): 

549 raise ValueError("var container should not be of type tuple or str") 

550 

551 for item in container: 

552 if isinstance(item, Sequence) and not isinstance(item, tuple) and not isinstance(item, str): 

553 yield from _flatten(item) 

554 else: 

555 yield item 

556 

557 

558def _is_linestring_geom(geometry_coordinates: GeojsonCoordinates) -> bool: 

559 """Check if coordinates are of linestring geometry type. 

560 

561 - Fiona linestring coordinates are of type: list[tuple[float,float,...]]) 

562 - GeoJSON linestring coordinates are of type: list[list[float]] 

563 

564 Args: 

565 geometry_coordinates (list): Fiona or GeoJSON coordinates sequence 

566 

567 Returns: 

568 bool: if geometry_coordinates is linestring return True else False 

569 """ 

570 

571 return ( 

572 len(geometry_coordinates) > 0 

573 and isinstance(geometry_coordinates[0], Sequence) 

574 and all(isinstance(x, float | int) for x in geometry_coordinates[0]) 

575 ) # also test for int just in case... 

576 

577 

578def _transform_positions_in_coordinates( 

579 coordinates: GeojsonCoordinates, 

580 callback: Callable[[GeojsonCoordinates], T], 

581) -> Nested | T: 

582 if not isinstance(coordinates, tuple): 

583 return list(map(lambda x: _transform_positions_in_coordinates(x, callback), coordinates)) 

584 return callback(coordinates) 

585 

586 

587def _get_intermediate_nr_points_and_segment_length(dist: float, max_segment_length: float) -> tuple[int, float]: 

588 if dist <= max_segment_length: 

589 raise GeodenseError(f"max_segment_length ({max_segment_length}) cannot be bigger or equal than dist ({dist})") 

590 remainder = dist % max_segment_length 

591 nr_segments = int(dist // max_segment_length) 

592 if remainder > 0: 

593 nr_segments += 1 

594 new_max_segment_length = dist / nr_segments # space segments evenly over delta(a,b) 

595 nr_points = nr_segments - 1 # convert nr of segments to nr of intermediate points, should be at least 1 

596 return nr_points, new_max_segment_length 

597 

598 

599def _add_vertices_to_line_segment(linestring: LineStringCoords, coord_index: int, densify_config: DenseConfig) -> int: 

600 """Adds vertices to linestring in place, and returns number of vertices added to linestring. 

601 

602 Args: 

603 ft_linesegment (_type_): line segment to add vertices 

604 coord_index (int): coordinate index of line segment to add vertices for 

605 transformer (Transformer): pyproj transformer 

606 max_segment_length (float): max segment length, if exceeded vertices will be added 

607 densify_in_projection (bool): whether to use source projection to densify (not use great-circle distance) 

608 

609 Returns: 

610 int: number of added vertices 

611 """ 

612 

613 a = linestring[coord_index] 

614 b = linestring[coord_index + 1] 

615 

616 prec = densify_config.get_coord_precision() 

617 

618 if not densify_config.in_projection: 

619 linestring_coords = interpolate_geodesic(a, b, densify_config) 

620 else: 

621 linestring_coords = interpolate_src_proj(a, b, densify_config) 

622 

623 p = list(map(lambda x: _round_coordinates(x, prec), linestring_coords)) 

624 

625 linestring[coord_index] = _round_coordinates(linestring[coord_index], prec) 

626 linestring[coord_index + 1] = _round_coordinates(linestring[coord_index + 1], prec) 

627 linestring[coord_index + 1 : coord_index + 1] = p 

628 return len(p) 

629 

630 

631def _round_coordinates(position: Position, precision: int) -> Position: 

632 result: Position = Position2D( 

633 longitude=round(position.longitude, precision), 

634 latitude=round(position.latitude, precision), 

635 ) 

636 

637 if len(position) == THREE_DIMENSIONAL: 

638 position_3d = cast(Position3D, position) 

639 result = Position3D( 

640 longitude=result.longitude, 

641 latitude=result.latitude, 

642 altitude=round(position_3d.altitude, DEFAULT_PRECISION_METERS), 

643 ) 

644 return result 

645 

646 

647def _get_geometry_type( 

648 geometry: GeojsonGeomNoGeomCollection, 

649) -> str: 

650 return cast(str, geometry.type) 

651 

652 

653def _geom_has_3d_coords( 

654 geometry: GeojsonGeomNoGeomCollection, 

655) -> Nested[bool] | bool: 

656 def _position_is_3d(position: GeojsonCoordinates) -> bool: 

657 return len(position) == THREE_DIMENSIONAL 

658 

659 return _transform_positions_in_coordinates(geometry.coordinates, _position_is_3d) 

660 

661 

662def _has_3d_coordinates(geojson_obj: GeojsonObject, silent: bool | None = False) -> Has3D: 

663 has_3d_coords: Nested[bool] | bool = transform_geojson_geometries(geojson_obj, _geom_has_3d_coords) 

664 

665 # in case only value returned from transform_geojson_geometries 

666 if isinstance(has_3d_coords, bool): # noqa: SIM108 

667 has_3d_coords_flat = [has_3d_coords] 

668 else: 

669 has_3d_coords_flat = list(_flatten(has_3d_coords)) 

670 

671 if not all(has_3d_coords_flat) and any(has_3d_coords_flat): # some 3d 

672 if not silent: 

673 warning_message = "geometries with mixed 2D and 3D vertices found" 

674 logger.warning(warning_message) 

675 return Has3D.some 

676 elif all(not x for x in has_3d_coords_flat): # none 3d 

677 return Has3D.none 

678 return Has3D.all 

679 

680 

681def validate_geom_type(geojson_obj: GeojsonObject, command: str = "") -> None: 

682 geom_types: Nested[str] | str = transform_geojson_geometries(geojson_obj, _get_geometry_type) 

683 geom_types = [geom_types] if isinstance(geom_types, str) else geom_types 

684 geom_types_flat = list(_flatten(geom_types)) 

685 if all(g_t in ("Point", "MultiPoint") for g_t in geom_types_flat): 

686 # situation: all geoms point -> error 

687 if command: 

688 error_message = f"cannot run {command} on GeoJSON that only contains (Multi)Point geometries" 

689 else: 

690 error_message = "GeoJSON contains only (Multi)Point geometries" 

691 raise GeodenseError(error_message) 

692 elif any(gt in ["Point", "MultiPoint"] for gt in geom_types_flat): 

693 # sitation: some geoms point -> warning 

694 warning_message = "GeoJSON contains (Multi)Point geometries" 

695 if command: 

696 warning_message = f"{warning_message}, cannot run {command} on (Multi)Point geometries" 

697 

698 logger.warning(warning_message) 

699 else: 

700 # situation: no geoms point -> ok 

701 pass