diff --git a/Makefile b/Makefile index 0a8713e..582dab2 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ .PHONY: apis apis: ./tools/openapi_conversion/generate_apis.sh + +.PHONY: test +test: + PYTHONPATH="$(PYTHONPATH):./src" pytest tests/ \ No newline at end of file diff --git a/src/uas_standards/eurocae_ed269.py b/src/uas_standards/eurocae_ed269.py new file mode 100644 index 0000000..3cace7f --- /dev/null +++ b/src/uas_standards/eurocae_ed269.py @@ -0,0 +1,155 @@ +from datetime import time +from enum import Enum +from typing import List, Any, Optional, Dict, Union +import arrow + +from implicitdict import ImplicitDict, StringBasedDateTime + + +class Restriction(str, Enum): + PROHIBITED = "PROHIBITED" + REQ_AUTHORISATION = "REQ_AUTHORISATION" + CONDITIONAL = "CONDITIONAL" + NO_RESTRICTION = "NO_RESTRICTION" + + +class Reason(str, Enum): + AIR_TRAFFIC = "AIR_TRAFFIC" + SENSITIVE = "SENSITIVE" + PRIVACY = "PRIVACY" + POPULATION = "POPULATION" + NATURE = "NATURE" + NOISE = "NOISE" + FOREIGN_TERRITORY = "FOREIGN_TERRITORY" + EMERGENCY = "EMERGENCY" + OTHER = "OTHER" + + +class YESNO(str, Enum): + YES = "YES" + NO = "NO" + + +class Purpose(str, Enum): + AUTHORIZATION = "AUTHORIZATION" + NOTIFICATION = "NOTIFICATION" + INFORMATION = "INFORMATION" + + +class UASZoneAuthority(ImplicitDict): + name: Optional[str] # max length: 200 + service: Optional[str] # max length: 200 + email: Optional[str] + contactName: Optional[str] # max length: 200 + siteURL: Optional[str] + phone: Optional[str] # max length: 200 + purpose: Optional[Purpose] + intervalBefore: Optional[str] + + +class VerticalReferenceType(str, Enum): + AGL = "AGL" + AMSL = "AMSL" + + +class HorizontalProjectionType(str, Enum): + Circle = "Circle" + Polygon = "Polygon" + + +class CircleOrPolygonType(ImplicitDict): + type: HorizontalProjectionType + center: Optional[List[float]] # 2 items + radius: Optional[float] # > 0 + coordinates: Optional[List[List[float]]] # min 4 items # 2 items + + +class UomDimensions(str, Enum): + M = "M" + FT = "FT" + + +class UASZoneAirspaceVolume(ImplicitDict): + uomDimensions: UomDimensions + lowerLimit: Optional[int] + lowerVerticalReference: VerticalReferenceType + upperLimit: Optional[int] + upperVerticalReference: VerticalReferenceType + horizontalProjection: CircleOrPolygonType + + +class WeekDateType(str, Enum): + MON = "MON" + TUE = "TUE" + WED = "WED" + THU = "THU" + FRI = "FRI" + SAT = "SAT" + SUN = "SUN" + ANY = "ANY" + + +class ED269TimeType(str): + """String that allows values which describe a time in ED-269 flavour of ISO 8601 format. + + ED-269 standard specifies that a time instant type should be in the form of hh:mmS where S is + the timezone. However, examples are using the following format: 00:00:00.00Z + This class supports both formats as inputs and uses the long form as the output format. + """ + + time: time + """`time` representation of the str value with timezone""" + + def __new__(cls, value: Union[str, time]): + if isinstance(value, str): + t = arrow.get(value, ["HH:mm:ss.SZ", "HH:mmZ"]).timetz() + else: + t = value + str_value = str.__new__( + cls, t.strftime("%H:%M:%S.%f")[:11] + t.strftime("%z").replace("+0000", "Z") + ) + str_value.time = t + return str_value + + +class DailyPeriod(ImplicitDict): + day: List[WeekDateType] # min items: 1, max items: 7 + startTime: ED269TimeType + endTime: ED269TimeType + + +class ApplicableTimePeriod(ImplicitDict): + permanent: YESNO + startDateTime: Optional[StringBasedDateTime] + endDateTime: Optional[StringBasedDateTime] + schedule: Optional[List[DailyPeriod]] # min items: 1 + + +class UASZoneVersion(ImplicitDict): + title: Optional[str] + identifier: str # max length: 7 + country: str # length: 3 + name: Optional[str] # max length: 200 + type: str + restriction: Restriction + restrictionConditions: Optional[List[str]] + region: Optional[int] + reason: Optional[List[Reason]] # max length: 9 + otherReasonInfo: Optional[str] # max length: 30 + regulationExemption: Optional[YESNO] + uSpaceClass: Optional[str] # max length: 100 + message: Optional[str] # max length: 200 + applicability: List[ApplicableTimePeriod] + zoneAuthority: List[UASZoneAuthority] + geometry: List[UASZoneAirspaceVolume] # min items: 1 + extendedProperties: Optional[Any] + + +class ED269Schema(ImplicitDict): + title: Optional[str] + description: Optional[str] + features: List[UASZoneVersion] + + @staticmethod + def from_dict(raw_data: Dict) -> "ED269Schema": + return ImplicitDict.parse(raw_data, ED269Schema) diff --git a/tests/test_eurocae_ed269.json b/tests/test_eurocae_ed269.json new file mode 100644 index 0000000..38e5089 --- /dev/null +++ b/tests/test_eurocae_ed269.json @@ -0,0 +1,457 @@ +{ + "title": "UASZoneTestData 2022-10-16", + "description": "Sample data for Automated Testing development", + "features": [ + { + "identifier": "Montreux Concert Area", + "country": "CHE", + "name": "Sample protected area active during evening", + "type": "COMMON", + "restriction": "PROHIBITED", + "reason": [ + "OTHER" + ], + "otherReasonInfo": "Concert Area", + "message": "Concert area - prohibited flights during active times", + "applicability": [ + { + "startDateTime": "2022-06-15T09:00:00.00Z", + "endDateTime": "2023-06-15T09:00:00.00Z", + "permanent": "NO", + "schedule": [ + { + "day": [ + "SAT", + "SUN" + ], + "startTime": "17:00:00.00Z", + "endTime": "23:59:59.00Z" + } + ] + } + ], + "zoneAuthority": [ + { + "name": "Local Canton", + "contactName": "Commune de Montreux", + "siteURL": "https://www.montreux.ch/accueil", + "purpose": "AUTHORIZATION" + } + ], + "geometry": [ + { + "uomDimensions": "M", + "lowerLimit": 0, + "lowerVerticalReference": "AGL", + "upperLimit": 6259, + "upperVerticalReference": "AMSL", + "horizontalProjection": { + "type": "Circle", + "center": [ + 6.913146, + 46.430758 + ], + "radius": 3000 + } + } + ] + }, + { + "identifier": "Flugplatz Reichenbach", + "country": "CHE", + "name": "Flugplatz Reichenbach", + "type": "COMMON", + "restriction": "PROHIBITED", + "reason": [ + "AIR_TRAFFIC" + ], + "message": "Flugplatz Reichenbach Drone Access Restrictions", + "applicability": [ + { + "startDateTime": "2022-06-15T09:00:00.00Z", + "endDateTime": "2023-06-15T09:00:00.00Z", + "permanent": "YES" + } + ], + "zoneAuthority": [ + { + "name": "Flugplatz Reichenbach", + "contactName": "Flugplatzleitung", + "siteURL": "https://www.flugplatz-reichenbach.ch/", + "phone": "079 642 17 61" + } + ], + "geometry": [ + { + "uomDimensions": "M", + "lowerLimit": 0, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Circle", + "center": [ + 7.67807, + 46.612893 + ], + "radius": 1000 + } + }, + { + "uomDimensions": "M", + "lowerLimit": 50, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Circle", + "center": [ + 7.67807, + 46.612893 + ], + "radius": 2500 + } + }, + { + "uomDimensions": "M", + "lowerLimit": 100, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Circle", + "center": [ + 7.67807, + 46.612893 + ], + "radius": 3500 + } + } + ] + }, + { + "identifier": "Lausanne Airport", + "country": "CHE", + "name": "Lausanne Airport", + "type": "COMMON", + "restriction": "PROHIBITED", + "reason": [ + "AIR_TRAFFIC", + "OTHER" + ], + "otherReasonInfo": "Lausanne Airport Operational Restrictions", + "message": "Drone operations must only fly below the maximum allowed ceiling near active airports", + "applicability": [ + { + "permanent": "YES" + } + ], + "zoneAuthority": [ + { + "name": "Lausanne Airport", + "siteURL": "http://www.lausanne-airport.ch/" + } + ], + "geometry": [ + { + "uomDimensions": "M", + "lowerLimit": 0, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Polygon", + "coordinates": [ + [ + [ + 6.609821, + 46.532886 + ], + [ + 6.624755, + 46.532886 + ], + [ + 6.624755, + 46.558624 + ], + [ + 6.609821, + 46.558624 + ], + [ + 6.609821, + 46.532886 + ] + ] + ] + } + }, + { + "uomDimensions": "M", + "lowerLimit": 30, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Polygon", + "coordinates": [ + [ + [ + 6.596603, + 46.531941 + ], + [ + 6.596603, + 46.531941 + ], + [ + 6.637458, + 46.55945 + ], + [ + 6.596603, + 46.55945 + ], + [ + 6.596603, + 46.531941 + ] + ] + ] + } + }, + { + "uomDimensions": "M", + "lowerLimit": 60, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Polygon", + "coordinates": [ + [ + [ + 6.585617, + 46.526036 + ], + [ + 6.585617, + 46.526036 + ], + [ + 6.649818, + 46.565233 + ], + [ + 6.585617, + 46.565233 + ], + [ + 6.585617, + 46.526036 + ] + ] + ] + } + } + ] + }, + { + "identifier": "MONTREUX Wildlife Preserve", + "country": "CHE", + "name": "Lake Geneva Wildlife Preserve", + "type": "COMMON", + "restriction": "REQ_AUTHORISATION", + "reason": [ + "NATURE" + ], + "otherReasonInfo": "Lake Geneva Wildlife Preserve", + "message": "Drone operations must only be permitted inside the Lake Geneva Wild Preserve with prior authorisation", + "applicability": [ + { + "permanent": "YES" + } + ], + "zoneAuthority": [ + { + "name": "Lake Geneva Wildlife Preserve", + "siteURL": "https://www.nationalpark.ch/en/" + } + ], + "geometry": [ + { + "uomDimensions": "M", + "lowerLimit": 0, + "lowerVerticalReference": "AGL", + "upperLimit": 120, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Polygon", + "coordinates": [ + [ + [ + 6.852035, + 46.453943 + ], + [ + 6.806716, + 46.3917 + ], + [ + 6.827659, + 46.383649 + ], + [ + 6.893234, + 46.393239 + ], + [ + 6.932373, + 46.403302 + ], + [ + 6.920871, + 46.42993 + ], + [ + 6.852035, + 46.453943 + ] + ] + ] + } + } + ] + }, + { + "identifier": "Gantrisch Nature Park", + "country": "CHE", + "name": "Gantrisch Nature Park", + "type": "COMMON", + "restriction": "NO_RESTRICTION", + "reason": [ + "NATURE" + ], + "uSpaceClass": "OPEN", + "message": "Drone operations inside Gantrisch Nature Park should minimise disturbance to wildlife", + "applicability": [ + { + "permanent": "YES" + } + ], + "zoneAuthority": [ + { + "name": "Gantrisch Nature Park", + "siteURL": "https://www.nationalpark.ch/en/" + } + ], + "geometry": [ + { + "uomDimensions": "FT", + "lowerLimit": 0, + "lowerVerticalReference": "AGL", + "upperLimit": 250, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.315521, + 46.818857 + ], + [ + 7.282562, + 46.703143 + ], + [ + 7.506408, + 46.717268 + ], + [ + 7.516021, + 46.797239 + ], + [ + 7.404785, + 46.844225 + ], + [ + 7.315521, + 46.818857 + ] + ] + ] + } + } + ] + }, + { + "identifier": "Gantrisch Nature Park", + "country": "CHE", + "name": "Gantrisch Nature Park", + "type": "COMMON", + "restriction": "REQ_AUTHORISATION", + "reason": [ + "NATURE" + ], + "uSpaceClass": [ + "SPECIFIC", + "CERTIFIED" + ], + "message": "Drone operations inside Gantrisch Nature Park can only be permitted in VLOS unless prior authorisation is granted", + "applicability": [ + { + "permanent": "YES" + } + ], + "zoneAuthority": [ + { + "name": "Gantrisch Nature Park", + "siteURL": "https://www.nationalpark.ch/en/" + } + ], + "geometry": [ + { + "uomDimensions": "FT", + "lowerLimit": 0, + "lowerVerticalReference": "AGL", + "upperLimit": 250, + "upperVerticalReference": "AGL", + "horizontalProjection": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.315521, + 46.818857 + ], + [ + 7.282562, + 46.703143 + ], + [ + 7.506408, + 46.717268 + ], + [ + 7.516021, + 46.797239 + ], + [ + 7.404785, + 46.844225 + ], + [ + 7.315521, + 46.818857 + ] + ] + ] + } + } + ] + } + ] +} diff --git a/tests/test_eurocae_ed269.py b/tests/test_eurocae_ed269.py new file mode 100644 index 0000000..c713641 --- /dev/null +++ b/tests/test_eurocae_ed269.py @@ -0,0 +1,62 @@ +import json +import os +from datetime import timedelta +from implicitdict import ImplicitDict +from uas_standards.eurocae_ed269 import ED269Schema, ED269TimeType + + +def test_sample(): + with open( + os.path.join(os.path.dirname(__file__), "test_eurocae_ed269.json") + ) as f: + data = json.load(f) + + ED269Schema.from_dict(data) + + +def test_timetype(): + class MyTimedData(ImplicitDict): + t1: ED269TimeType + t2: ED269TimeType + t3: ED269TimeType + t4: ED269TimeType + + data = ImplicitDict.parse( + { + "t1": "12:34:56.78Z", + "t2": "12:34Z", + "t3": "12:34:56.78-0100", + "t4": "00:00:00.00+0100", + }, + MyTimedData, + ) + + assert data["t1"].time.hour == 12 + assert data["t1"].time.minute == 34 + assert data["t1"].time.second == 56 + assert data["t1"].time.microsecond == 780000 + assert data["t1"].time.utcoffset() == timedelta(hours=0) + assert str(data["t1"]) == "12:34:56.78Z" + + assert data["t2"].time.hour == 12 + assert data["t2"].time.minute == 34 + assert data["t2"].time.second == 0 + assert data["t2"].time.microsecond == 0 + assert data["t2"].time.utcoffset() == timedelta(hours=0) + assert str(data["t2"]) == "12:34:00.00Z" + + assert data["t3"].time.hour == 12 + assert data["t3"].time.minute == 34 + assert data["t3"].time.second == 56 + assert data["t3"].time.microsecond == 780000 + assert data["t3"].time.utcoffset() == timedelta(hours=-1) + assert str(data["t3"]) == "12:34:56.78-0100" + + assert data["t4"].time.hour == 0 + assert data["t4"].time.minute == 0 + assert data["t4"].time.second == 0 + assert data["t4"].time.microsecond == 0 + assert data["t4"].time.utcoffset() == timedelta(hours=1) + assert str(data["t4"]) == "00:00:00.00+0100" + + assert data["t3"].time > data["t2"].time # t2 is an hour earlier than t3