diff --git a/README.md b/README.md index 9c6c9bf..06a7525 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ # geospatial-utils + +This repository contains a suite of tools to manage geospatial data + +## Getting started + +To run the tool, install Docker. Then, run the following command: `./geospatial-utils/run_locally.sh --help` + +## Tools + +### ED-269 to ED-318 converter + +A converter to transform ED-269 to ED-318 is provided by using the `convert` command. diff --git a/geospatial-utils/config.py b/geospatial-utils/config.py new file mode 100644 index 0000000..49bf9d4 --- /dev/null +++ b/geospatial-utils/config.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import datetime + +from implicitdict import ImplicitDict, StringBasedDateTime +from uas_standards.eurocae_ed318 import TextShortType + + +class ConverterConfiguration(ImplicitDict): + ed318_additions: ED318Additions + + +class ED318Additions(ImplicitDict): + default_lang: str + provider: list[TextShortType] + description: list[TextShortType] + technicalLimitation: list[TextShortType] + issued: StringBasedDateTime + otherGeoid: str + feature_collection_bbox: list[float] + collection_name: str + + +FOCA = ED318Additions( + default_lang="en-GB", + provider=[ + TextShortType(lang="de-CH", text="BAZL"), + TextShortType(lang="fr-CH", text="OFAC"), + TextShortType(lang="it-CH", text="UFAC"), + TextShortType(lang="en-GB", text="FOCA"), + ], + description=[ # TODO: To validate + TextShortType( + lang="de-CH", + text="Schweizerische UAS Geozones, herausgegeben vom Bundesamt für Zivilluftfahrt (BAZL). Umwandlung aus dem Modell ED-269.", + ), + TextShortType( + lang="fr-CH", + text="UAS Geozones suisses publiées par l'Office fédéral de l'aviation civile (OFAC). Conversion à partir du modèle ED-269", + ), + TextShortType( + lang="it-CH", + text="Geozones UAS svizzere emesse dall'Ufficio federale dell'aviazione civile (UFAC). Conversione dal modello ED-269", + ), + TextShortType( + lang="en-GB", + text="Swiss UAS Geozones issued by the Federal Office of Civil Aviation (FOCA). Conversion from the ED-269 model", + ), + ], + technicalLimitation=[ # TODO: To validate + TextShortType( + lang="de-CH", + text="Der Datensatz entsteht durch die Umwandlung der Originaldaten des ED-269-Modells ins neue ED-318. Für die Umwandlung sind einige Datenänderungen nötig. Diese Datei wurde in INTERLIS 2.4 erstellt.", + ), + TextShortType( + lang="fr-CH", + text="Le fichier a été créé en convertissant les données originales du modèle ED-269 dans le nouveau ED-318. La conversion nécessite des modifications des données. Ce fichier a été créé en INTERLIS 2.4", + ), + TextShortType( + lang="it-CH", + text="Il dataset è stato creato convertendo i dati originali del modello ED-269 nel nuovo ED-318. Per la conversione alcune modifiche dei dati sono necessarie. Questo file è stato creato in INTERLIS 2.4", + ), + TextShortType( + lang="en-GB", + text="The dataset was created by converting the original data from the ED-269 model to the new ED-318. Some data modifications are necessary for conversion. This file was created in INTERLIS 2.4", + ), + ], + issued=StringBasedDateTime(datetime.now()), + otherGeoid="CHGeo2004", + collection_name="Swiss UAS Geozones according to ED-318 converted from the ED-269 data model", + feature_collection_bbox=[2485410.215, 1075268.136, 2833857.724, 1295933.698], +) diff --git a/geospatial-utils/convert.py b/geospatial-utils/convert.py new file mode 100644 index 0000000..e0e49a1 --- /dev/null +++ b/geospatial-utils/convert.py @@ -0,0 +1,217 @@ +from config import ED318Additions +from uas_standards.eurocae_ed318 import ( + Authority, + CodeVerticalReferenceType, + CodeWeekDayType, + CodeZoneReasonType, + CodeZoneType, + DailyPeriod, + DatasetMetadata, + ED318Schema, + ExtentCircle, + Feature, + Geometry, + GeometryCollection, + Point, + Polygon, + TextShortType, + TimePeriod, + UASZone, + UomDistance, + VerticalLayer, +) +from uas_standards.eurocae_ed269 import ( + ApplicableTimePeriod, + ED269Schema, + HorizontalProjectionType, + Reason, + Restriction, + UASZoneAirspaceVolume, + UASZoneAuthority, + UomDimensions, +) + +COUNTRY_REGION_MAPPING = {"CHE": 0, "LIE": 27} + + +def _convert_restriction(restriction: Restriction) -> CodeZoneType: + if restriction is Restriction.REQ_AUTHORISATION: + return CodeZoneType.REQ_AUTHORIZATION + return CodeZoneType(restriction) + + +def _convert_uom(uom_dimensions: UomDimensions) -> UomDistance: + return UomDistance(uom_dimensions.lower()) + + +def _convert_authority(za: UASZoneAuthority, default_lang: str) -> Authority: + return Authority( + name=[TextShortType(text=za.name, lang=default_lang)] if "name" in za else [], + service=[TextShortType(text=za.service, lang=default_lang)] + if "service" in za + else [], + contactName=[TextShortType(text=za.contactName, lang=default_lang)] + if "contactName" in za + else [], + siteURL=za.siteURL if "siteURL" in za else None, + email=za.email if "email" in za else None, + phone=za.phone if "phone" in za and za.phone else None, + purpose=za.purpose if "purpose" in za else None, + intervalBefore=za.intervalBefore if "intervalBefore" in za else None, + ) + + +def _convert_geometry(g: UASZoneAirspaceVolume) -> Geometry: + vertical_layer = VerticalLayer( + upper=g.upperLimit if "upperLimit" in g else None, + upperReference=CodeVerticalReferenceType(g.upperVerticalReference), + lower=g.lowerLimit if "lowerLimit" in g else None, + lowerReference=CodeVerticalReferenceType(g.lowerVerticalReference), + uom=_convert_uom(g.uomDimensions), + ) + + hp = g.horizontalProjection + if hp.type is HorizontalProjectionType.Circle: + return Point( + coordinates=hp.center if "center" in hp else None, + extent=ExtentCircle(radius=hp.radius) if "radius" in hp else None, + layer=vertical_layer, + ) + + return Polygon( + type="Polygon", + coordinates=hp.coordinates if "coordinates" in hp else None, + layer=vertical_layer, + ) + + +def _convert_reasons(reason: list[Reason] | None) -> list[CodeZoneReasonType] | None: + reason_types: list[CodeZoneReasonType] = [] + for r in reason or []: + if r == Reason.FOREIGN_TERRITORY: + raise NotImplementedError( + "Reason FOREIGN_TERRITORY is not supported yet. (Value inexistent in ED-318)" + ) + reason_types.append(CodeZoneReasonType(r)) + return reason_types if len(reason_types) > 0 else None + + +def _convert_applicability(a: ApplicableTimePeriod) -> TimePeriod | None: + schedule: list[DailyPeriod] = [] + if "schedule" in a: + for d in a.schedule or []: + daily_period = DailyPeriod( + day=CodeWeekDayType(d.day) if "day" in d else None, + startTime=d.startTime if "startTime" in d else None, + startEvent=None, + endTime=d.endTime if "endTime" in d else None, + endEvent=None, + ) + + if len(daily_period) > 0: + schedule.append(daily_period) + + time_period = TimePeriod( + startDateTime=a.startDateTime if "startDateTime" in a else None, + endDateTime=a.endDateTime if "endDateTime" in a else None, + schedule=schedule if len(schedule) > 0 else None, + ) + return time_period if len(time_period) > 0 else None + + +def from_ed269_to_ed318(ed269_data: ED269Schema, config: ED318Additions) -> ED318Schema: + """Convert ED269 data to ED318 data. + Missing data in the new format is provided as a config.""" + + dataset_metadata = DatasetMetadata( + validFrom=None, + validTo=None, + provider=config.provider, + description=config.description, + issued=config.issued, + otherGeoid=config.otherGeoid, + ) + + features: list[Feature] = [] + for i, zv in enumerate(ed269_data.features): + zone_authority: list[Authority] = [] + for za in zv.zoneAuthority: + zone_authority.append(_convert_authority(za, config.default_lang)) + + geometries: list[Geometry] = [] + for g in zv.geometry: + geometries.append(_convert_geometry(g)) + + if len(geometries) > 1: + geometry = GeometryCollection( + type="GeometryCollection", geometries=geometries + ) + elif len(geometries) == 1: + geometry = geometries[0] + else: + raise ValueError(f"No geometry found for geozone {zv.name}") + + limited_applicability = [_convert_applicability(a) for a in zv.applicability] + + # Ensures it is not a table of None since permanent zone may be represented like this. + if ( + sum( + [ + 1 if la is not None and len(la) > 0 else 0 + for la in limited_applicability + ] + ) + == 0 + ): + limited_applicability = None + + # Ensures the converter accepts either an optional string as specified in the standard + # definition or a list of str of 0 or 1 item as provided in the jsonschema in the standard. + restriction_conditions: str | None = None + if "restrictionConditions" in zv and zv.restrictionConditions is not None: + if isinstance(zv.restrictionConditions, dict): + if len(zv.restrictionConditions) == 0: + restriction_conditions = None + elif len(zv.restrictionConditions) == 1: + restriction_conditions = zv.restrictionConditions[0] + else: + raise ValueError("Unexpected array with more than one item.") + else: + restriction_conditions = str(zv.restrictionConditions) + + feature = Feature( + id=str(i), + type="Feature", + properties=UASZone( + identifier=zv.identifier, + country=zv.country, + name=[TextShortType(text=zv.name, lang=config.default_lang)], + type=_convert_restriction(zv.restriction), + variant=zv.type, + restrictionConditions=restriction_conditions, + region=COUNTRY_REGION_MAPPING[zv.country], + reason=_convert_reasons(zv.reason) if "reason" in zv else None, + otherReasonInfo=[ + TextShortType(text=zv.otherReasonInfo, lang=config.default_lang) + ], + regulationExemption=zv.regulationExemption, + message=[TextShortType(text=zv.message, lang=config.default_lang)] + if "message" in zv + else [], + extendedProperties=zv.extendedProperties + if "extendedProperties" in zv + else None, + limitedApplicability=limited_applicability, + zoneAuthority=zone_authority, + dataSource=None, + ), + geometry=geometry, + ) + + features.append(feature) + + return ED318Schema( + type="FeatureCollection", + metadata=dataset_metadata, + features=features, + ) diff --git a/geospatial-utils/fileutils/ed269.py b/geospatial-utils/fileutils/ed269.py index 5c1c39a..d5f9474 100644 --- a/geospatial-utils/fileutils/ed269.py +++ b/geospatial-utils/fileutils/ed269.py @@ -1,5 +1,6 @@ import json import pathlib + from uas_standards.eurocae_ed269 import ED269Schema diff --git a/geospatial-utils/main.py b/geospatial-utils/main.py index 02251cb..fbf33fb 100644 --- a/geospatial-utils/main.py +++ b/geospatial-utils/main.py @@ -1,9 +1,12 @@ import argparse import json -import pathlib -import fileutils import os +import pathlib import sys + +import config +import convert +import fileutils from fileutils import ed269 from loguru import logger @@ -39,13 +42,15 @@ def main(): source = fileutils.get(args.input_url, int(args.ttl)) logger.debug(f"Local input copy: {source.absolute()}") - data = ed269.loads(source) - # output = pathlib.Path(args.output_file) - # output.write_text(json.dumps(data, indent=2)) + ed269_data = ed269.loads(source) + # TODO: Move hard-coded configuration to a json file. + logger.warning("Additional data not provided in ED269 is hard-coded with Swiss FOCA information. This will be moved to a configurable file in the near future.") + ed318_data = convert.from_ed269_to_ed318(ed269_data, config=config.FOCA) + output = pathlib.Path(args.output_file) + output.write_text(json.dumps(ed318_data), encoding="utf-8") + + logger.info(f"Successful conversion. ED-318 saved to {output.absolute()}") - logger.debug(json.dumps(data, indent=2)) - logger.error("Work in progress. The parser has not been fully implemented yet. The ED269 parser output was printed above for inspection. (debug log level required)") - sys.exit(1) else: parser.print_help() sys.exit(1) diff --git a/geospatial-utils/run_locally.sh b/geospatial-utils/run_locally.sh index f3cfc14..4d54c9b 100755 --- a/geospatial-utils/run_locally.sh +++ b/geospatial-utils/run_locally.sh @@ -17,10 +17,6 @@ cd geospatial-utils || exit 1 make image ) -# https://stackoverflow.com/a/9057392 -# shellcheck disable=SC2124 -OTHER_ARGS=${@:2} - if [ "$CI" == "true" ]; then docker_args="" else @@ -38,4 +34,4 @@ docker run ${docker_args} --name geospatial-utils \ -v ./geospatial-utils/output:/app/geospatial-utils/output \ -w /app/geospatial-utils \ interuss/geospatial-utils \ - uv run main.py $OTHER_ARGS \ No newline at end of file + uv run main.py ${@} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1615833..4c7a2e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "loguru>=0.7.3", "requests>=2.32.5", "ruff", - "uas-standards>=4.0.0", + "uas-standards>=4.1.0", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index b98a4ae..a2d9280 100644 --- a/uv.lock +++ b/uv.lock @@ -92,7 +92,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "requests", specifier = ">=2.32.5" }, { name = "ruff" }, - { name = "uas-standards", specifier = ">=4.0.0" }, + { name = "uas-standards", specifier = ">=4.1.0" }, ] [[package]] @@ -304,14 +304,14 @@ wheels = [ [[package]] name = "uas-standards" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "implicitdict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/b1/1bfe5e08436faf65b7e0eef795577cec9177dd4a3cb318faafa9fdcd2568/uas_standards-4.0.0.tar.gz", hash = "sha256:77bde013efe5be472e9099c9f55e005439686d5192ed6ea4e4109aa62104a92e", size = 116943, upload-time = "2025-08-19T15:46:30.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/b8/0c9413ff91dce6dc365b9069a09b519787718c2e1c0d9b59c45d54528bfa/uas_standards-4.1.0.tar.gz", hash = "sha256:8e91ebeb996bb1c60ed15384910c9a3edb1628141d15e8e5a2a66c3cc6fac68d", size = 123184, upload-time = "2025-10-01T11:45:55.003Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/70/dce704cba3631141f0da26d9f26e033413f4e6ad09c9132ff0366a418d7e/uas_standards-4.0.0-py3-none-any.whl", hash = "sha256:d4f163a4b96a673371d2cf081ab70b50b50423b8600fa71ed39be2d3f7cf016c", size = 83522, upload-time = "2025-08-19T15:46:29.699Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/ebb33199d3996fe2004c2dbda9f231fbd874e41b52b11b6cc5bf545347de/uas_standards-4.1.0-py3-none-any.whl", hash = "sha256:c190de8c8e6856e15a7cc4a91434aab906ceef01ac434ad7b5de1654b7d15256", size = 85419, upload-time = "2025-10-01T11:45:53.786Z" }, ] [[package]]