Parsovani vzdusneho prostoru

This commit is contained in:
Michal Kváček
2024-03-21 16:40:23 +01:00
parent 7c81069e96
commit ed35de96c9
11 changed files with 7316 additions and 15 deletions
@@ -0,0 +1,44 @@
"""Add airspace_item table
Revision ID: aba4c3876b28
Revises: 7dd139549851
Create Date: 2024-03-18 10:05:57.516697
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'aba4c3876b28'
down_revision = '7dd139549851'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('airspace_item',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('country', sa.String(length=8), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('type', sa.String(length=8), nullable=False),
sa.Column('lower_limit', sa.String(length=32), nullable=False),
sa.Column('upper_limit', sa.String(length=32), nullable=False),
sa.Column('center', mysql.JSON(), nullable=False),
sa.Column('radius', sa.Float(), nullable=True),
sa.Column('bounds', mysql.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_airspace_item_country'), 'airspace_item', ['country'], unique=False)
op.create_index(op.f('ix_airspace_item_type'), 'airspace_item', ['type'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_airspace_item_type'), table_name='airspace_item')
op.drop_index(op.f('ix_airspace_item_country'), table_name='airspace_item')
op.drop_table('airspace_item')
# ### end Alembic commands ###
+11 -4
View File
@@ -5,10 +5,10 @@ services:
- 3306:3306 - 3306:3306
environment: environment:
- MYSQL_USER=root - MYSQL_USER=root
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=ull_tracker - MYSQL_DATABASE=ull_tracker
- MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true
volumes: volumes:
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- db-data:/var/lib/mysql:rw - db-data:/var/lib/mysql:rw
api: api:
build: build:
@@ -23,16 +23,23 @@ services:
TZ: "Europe/Prague" TZ: "Europe/Prague"
API_URL: "http://localhost:8000" API_URL: "http://localhost:8000"
APP_ENV: "development" APP_ENV: "development"
MYSQL_USER: "root"
MYSQL_PASSWORD: "root"
ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001 http://localhost:9101 http://localhost:3000" ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001 http://localhost:9101 http://localhost:3000"
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983" SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
adminer:
image: adminer
restart: always
ports:
- 8080:8080
scrapy: scrapy:
build: build:
context: ../crawlers context: ../crawlers
environment: environment:
- MYSQL_USER=root - MYSQL_USER=root
- MYSQL_PASSWORD= - MYSQL_PASSWORD=root
- MYSQL_DATABASE=ull_tracker - MYSQL_DATABASE=ull_tracker
- MYSQL_HOST=db - MYSQL_HOST=db
volumes: volumes:
@@ -40,4 +47,4 @@ services:
- ../crawlers:/app - ../crawlers:/app
volumes: volumes:
db-data: { } db-data: { }
+3 -3
View File
@@ -1,4 +1,4 @@
fastapi==0.109.0 fastapi==0.110.0
fastapi-jwt==0.2.0 fastapi-jwt==0.2.0
fastapi-mail==1.4.1 fastapi-mail==1.4.1
strawberry-graphql[fastapi]==0.217.1 strawberry-graphql[fastapi]==0.217.1
@@ -12,5 +12,5 @@ sentry-sdk[fastapi]
pillow==10.2.0 pillow==10.2.0
exif==1.6.0 exif==1.6.0
aiocache==0.12.2 aiocache==0.12.2
aiohttp==3.9.1 aiohttp==3.9.3
lxml==5.1.0 lxml==5.1.0
+15
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import datetime import datetime
from typing import Set, List from typing import Set, List
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select, Float, Enum from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select, Float, Enum
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, relationship, as_declarative, mapped_column from sqlalchemy.orm import Mapped, relationship, as_declarative, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -66,6 +67,20 @@ copilot_has_photo = Table(
) )
class AirspaceItem(BaseModel):
__tablename__ = "airspace_item"
id: Mapped[int] = mapped_column(primary_key=True)
country: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
type: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
lower_limit: Mapped[str] = mapped_column(String(32), nullable=False)
upper_limit: Mapped[str] = mapped_column(String(32), nullable=False)
center: Mapped[List[dict]] = mapped_column(JSON())
radius: Mapped[float] = mapped_column(Float, nullable=True)
bounds: Mapped[list[tuple[float, float]]] = mapped_column(JSON())
class Airport(BaseModel): class Airport(BaseModel):
__tablename__ = "airport" __tablename__ = "airport"
+27
View File
@@ -0,0 +1,27 @@
from typing import List, Optional
import strawberry
from database import models
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Airspace
@strawberry.type
class AirspaceQueries:
@strawberry.field()
@error_logging
async def airspaces(
root, info, country: Optional[str] = None, types: Optional[list[str]] = None
) -> List[Airspace]:
filters = []
if country:
filters.append(models.AirspaceItem.country == country)
if types:
filters.append(models.AirspaceItem.type.in_(types))
return await BaseQueryResolver(Airspace, models.AirspaceItem).get_list(
info.context.user_id,
country=country,
filters=filters
)
@@ -24,21 +24,24 @@ class AircraftQueryResolver(BaseQueryResolver):
*args, *args,
**kwargs, **kwargs,
): ):
filters = {} filters = []
search = {}
if object_id: if object_id:
filters['object_id'] = object_id filters.append(models.Aircraft.id == object_id)
if not organization_ids: if not organization_ids:
filters['user_id'] = user_id filters.append(models.Aircraft.created_by_id == user_id)
if kwargs.get("call_sign"): if kwargs.get("call_sign"):
filters['call_sign'] = kwargs["call_sign"] filters.append(models.Aircraft.call_sign == kwargs['call_sign'])
if kwargs.get("search"): if kwargs.get("search"):
filters['search'] = kwargs.pop("search", None) search['search'] = kwargs.pop("search", None)
query = super().get_query( query = super().get_query(
only_my=False, only_my=False,
only_public=kwargs.get("only_public", False), only_public=kwargs.get("only_public", False),
order_by=order_by, order_by=order_by,
**filters filters=filters,
**search
) )
if organization_ids: if organization_ids:
query = ( query = (
@@ -38,6 +38,7 @@ class BaseQueryResolver(BaseResolver):
only_my: Optional[bool] = False, only_my: Optional[bool] = False,
include_others_public: Optional[bool] = False, include_others_public: Optional[bool] = False,
url_slug: Optional[str] = None, url_slug: Optional[str] = None,
filters: Optional[list] = None,
**kwargs, **kwargs,
): ):
query = self.query_builder.get_simple_query( query = self.query_builder.get_simple_query(
@@ -56,8 +57,8 @@ class BaseQueryResolver(BaseResolver):
query = self._handle_search(query, kwargs.pop("search", None)) query = self._handle_search(query, kwargs.pop("search", None))
if kwargs: if filters:
query = query.filter_by(**kwargs) query = query.filter(**kwargs)
return query return query
@@ -48,6 +48,25 @@ class Airport:
pass pass
@strawberry.type
class Airspace:
id: int
name: str
country: str
type: str
lower_limit: str
upper_limit: str
center: Optional[tuple[float, float]]
radius: Optional[float]
bounds: list[tuple[float, float]]
map_center: Optional[Point] = strawberry.field(
resolver=lambda root: Point(lat=root.center[0], lng=root.center[1]) if root.center else None
)
map_bounds: list[Point] = strawberry.field(
resolver=lambda root: [Point(lat=point[0], lng=point[1]) for point in root.bounds]
)
@strawberry_sqlalchemy_type(models.FlightTrack) @strawberry_sqlalchemy_type(models.FlightTrack)
class FlightTrack: class FlightTrack:
point_of_interest: Optional[PointOfInterest] = strawberry.field( point_of_interest: Optional[PointOfInterest] = strawberry.field(
+2
View File
@@ -1,6 +1,7 @@
from strawberry.tools import merge_types from strawberry.tools import merge_types
from .entities.aircraft import AircraftQueries from .entities.aircraft import AircraftQueries
from .entities.airport import AirportQueries from .entities.airport import AirportQueries
from .entities.airspace import AirspaceQueries
from .entities.copilot import CopilotQueries from .entities.copilot import CopilotQueries
from .entities.event import EventQueries from .entities.event import EventQueries
from .entities.flight import FlightQueries from .entities.flight import FlightQueries
@@ -16,6 +17,7 @@ from .entities.user import UserQueries
Query = merge_types('Query', ( Query = merge_types('Query', (
AircraftQueries, AircraftQueries,
AirportQueries, AirportQueries,
AirspaceQueries,
FlightQueries, FlightQueries,
CopilotQueries, CopilotQueries,
UserQueries, UserQueries,
+314
View File
@@ -0,0 +1,314 @@
import asyncio
import dataclasses
import enum
import math
from pathlib import Path
from typing import Optional
import aiohttp
from sqlalchemy import delete
from database import models
from database.transaction import get_session
# https://pyopenair.readthedocs.io/en/latest/openair.html
# http://www.winpilot.com/usersguide/userairspace.asp
class AirspaceItemType(enum.Enum):
AIRSPACE_A = "A"
AIRSPACE_B = "B"
AIRSPACE_C = "C"
AIRSPACE_D = "D"
AIRSPACE_E = "E"
AIRSPACE_F = "F"
AIRSPACE_G = "G"
ATZ = "ATZ"
PARAGLIDING_ZONE = "PGZ"
TRAGA = "GSEC"
CTR = "CTR"
TMA = "TMA"
TMZ = "TMZ"
FIR = "FIR"
RMZ = "RMZ"
RESTRICTED = "R"
DANGER = "Q"
PROHIBITED = "P"
class ArcDirection(enum.Enum):
CLOCKWISE = "+"
COUNTER_CLOCKWISE = "-"
@dataclasses.dataclass
class Coordinates:
lat: float
lng: float
@dataclasses.dataclass
class AirspaceItem:
type: AirspaceItemType
info: str = ""
upper_limit: str = None
lower_limit: str = None
center: Optional[Coordinates] = None
radius_nm: Optional[float] = None
bounds: list[Coordinates] = dataclasses.field(default_factory=lambda: [])
class ArcExpander:
R = 6378.1 # Radius of the Earth
@staticmethod
def get_bearing(center_point, end_point):
lat3 = math.radians(end_point[0])
long3 = math.radians(end_point[1])
lat1 = math.radians(center_point[0])
long1 = math.radians(center_point[1])
dLon = long3 - long1
X = math.cos(lat3) * math.sin(dLon)
Y = math.cos(lat1) * math.sin(lat3) - math.sin(lat1) * math.cos(lat3) * math.cos(dLon)
end_brng = math.atan2(X, Y)
return end_brng
@classmethod
def get_distance(cls, a, b):
lat1 = math.radians(a[0])
lon1 = math.radians(a[1])
lat2 = math.radians(b[0])
lon2 = math.radians(b[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return cls.R * c
@classmethod
def get_arc_coordinates(
cls,
center_point: tuple[float, float],
origin_point: tuple[float, float],
end_point: tuple[float, float]
) -> list[Coordinates]:
initial_bearing = cls.get_bearing(center_point, origin_point)
target_bearing = cls.get_bearing(center_point, end_point)
distance = cls.get_distance(center_point, origin_point)
print(f"DISTANCE {distance} meters, {center_point=}, {origin_point=}, {end_point=}")
def radians_range(start: float, stop: float, step_deg: int = 15):
start_deg = int(math.degrees(start)) % 360
stop_deg = int(math.degrees(stop)) % 360
for i in range(start_deg, stop_deg, step_deg):
yield math.radians(i)
list_bearings = list(radians_range(initial_bearing, target_bearing))
print("RADIANS", list_bearings)
print("\n")
print(f"DEGREES {list(map(math.degrees, list_bearings))}")
print(f"INIT DEGREES {math.degrees(initial_bearing)=}, {math.degrees(target_bearing)=}")
print("==========================================")
coordinates = []
for bearing in list_bearings:
lat1, lon1 = math.radians(center_point[0]), math.radians(center_point[1])
lat2 = math.asin(
math.sin(lat1) * math.cos(distance / cls.R) + math.cos(lat1) * math.sin(distance / cls.R) * math.cos(bearing)
)
lon2 = lon1 + math.atan2(
math.sin(bearing) * math.sin(distance / cls.R) * math.cos(lat1),
math.cos(distance / cls.R) - math.sin(lat1) * math.sin(lat2)
)
lat2, lon2 = math.degrees(lat2), math.degrees(lon2)
coordinates.append(Coordinates(lat2, lon2))
return coordinates
class OpenAirParser:
def __init__(self, file: Path | str):
self.file = None
self.context_type: Optional[AirspaceItemType] = None
self.context = {}
self.file = file
def save_context(self):
if not self.context_type:
return
data = AirspaceItem(type=self.context_type, **self.context)
self.context_type = None
self.context = {}
return data
def parse(self):
with open(self.file, "r") as file:
for line in file:
stripped_line = line.strip()
if not stripped_line:
if self.context_type:
yield self.save_context()
else:
self.parse_line(stripped_line)
if self.context_type:
yield self.save_context()
@staticmethod
def dms_to_dd(dms, hemisphere):
if '.' in dms:
degrees, minutes = dms.split(':')
seconds = 0
else:
degrees, minutes, seconds = dms.split(":")
decimal = float(degrees) + float(minutes) / 60 + float(seconds) / 3600
if hemisphere in ['S', 'W']:
decimal *= -1
return decimal
def parse_coordinates(self, coordinates: str) -> Coordinates:
parts = coordinates.split()
return Coordinates(
self.dms_to_dd(parts[0], parts[1]),
self.dms_to_dd(parts[2], parts[3])
)
def parse_arcs(self, arcs: str) -> list[Coordinates]:
arc_from, arc_to = arcs.split(",")
if self.context.get('arc_direction', ArcDirection.CLOCKWISE) == ArcDirection.CLOCKWISE:
starting_point = self.parse_coordinates(arc_from)
ending_point = self.parse_coordinates(arc_to)
else:
# souradnice jsou zadane proti smeru hodinovych rucicek -> prohodim souradnice a pocitam jako by byly po smeru
starting_point = self.parse_coordinates(arc_to)
ending_point = self.parse_coordinates(arc_from)
center = self.context['center']
del self.context['center']
if self.context.get('arc_direction'):
del self.context['arc_direction']
# https://stackoverflow.com/questions/62712947/calculate-arc-coordinates-based-on-original-point-end-point-center-distance-a
return ArcExpander.get_arc_coordinates(
(center.lat, center.lng),
(starting_point.lat, starting_point.lng),
(ending_point.lat, ending_point.lng)
)
def parse_line(self, line: str):
tokens = line.split(" ")
keyword = tokens[0]
value = None
if len(tokens) > 1:
value = " ".join(tokens[1:])
match keyword:
case '*':
# comment
pass
case 'AC':
try:
self.context_type = AirspaceItemType(value)
except ValueError as e:
print(f"Neznam {e}, ignoruji")
return
case 'AN':
if self.context_type == AirspaceItemType.AIRSPACE_G:
match value[0:2]:
case 'PG':
self.context_type = AirspaceItemType.PARAGLIDING_ZONE
case 'LK':
self.context_type = AirspaceItemType.ATZ
case _:
self.context_type = AirspaceItemType.TRAGA
self.context['info'] = value
case 'AL':
self.context['lower_limit'] = value
case 'AH':
self.context['upper_limit'] = value
case 'DP':
if 'bounds' not in self.context:
self.context['bounds'] = []
self.context['bounds'].append(self.parse_coordinates(value))
case 'V':
match value[0]:
case 'X':
self.context['center'] = self.parse_coordinates(value[2:]) # odstranit X= na zacatku
case 'D':
self.context['arc_direction'] = ArcDirection(value[2:])
case 'DB':
if 'bounds' not in self.context:
self.context['bounds'] = []
self.context['bounds'] = [*self.context['bounds'], *self.parse_arcs(value)]
case 'DC':
self.context['radius_nm'] = float(value)
case _:
print(f"Neznam {keyword}, radek: {line}")
src = "https://www.alus.it/AirspaceConverter/openAIP/cz_asp.txt"
dest = "/app/static/airspaces/cz_asp.txt"
async def download_airspace(source, destination):
async with aiohttp.ClientSession() as session:
async with session.get(source) as response:
with open(destination, "w") as f:
f.write(await response.text(encoding="latin-1"))
async def save_airspace(destination):
parser = OpenAirParser(destination)
async with get_session() as db:
await db.execute(delete(models.AirspaceItem))
for item in parser.parse():
# if 'CTR KARLOVY VARY' not in item.info:
# continue
# print("---" * 10)
# print(len(item.bounds))
# print(item.bounds)
# print("---" * 10)
async with get_session() as db:
await models.AirspaceItem.create(db, {
"country": "cz",
"name": item.info,
"type": item.type.value,
'lower_limit': item.lower_limit,
"upper_limit": item.upper_limit,
"center": [item.center.lat, item.center.lng] if item.center else None,
"radius": item.radius_nm * 1000 * 1.852 if item.radius_nm else None,
"bounds": [(round(i.lat, 6), round(i.lng, 6)) for i in item.bounds]
})
async def refresh_airspaces(src, dest):
await download_airspace(src, dest)
await save_airspace(dest)
asyncio.run(refresh_airspaces(src, dest))
File diff suppressed because it is too large Load Diff