fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
262
geo-service/utils/geo_utils.py
Normal file
262
geo-service/utils/geo_utils.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Geographic Utility Functions
|
||||
Coordinate transformations, distance calculations, and tile math
|
||||
"""
|
||||
import math
|
||||
from typing import Tuple, Optional
|
||||
import pyproj
|
||||
from shapely.geometry import Point, Polygon, shape
|
||||
from shapely.ops import transform
|
||||
|
||||
|
||||
# Web Mercator (EPSG:3857) and WGS84 (EPSG:4326) transformers
|
||||
WGS84 = pyproj.CRS("EPSG:4326")
|
||||
WEB_MERCATOR = pyproj.CRS("EPSG:3857")
|
||||
ETRS89_LAEA = pyproj.CRS("EPSG:3035") # Equal area for Europe
|
||||
|
||||
|
||||
def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Convert latitude/longitude to tile coordinates (XYZ scheme).
|
||||
|
||||
Args:
|
||||
lat: Latitude in degrees (-85.05 to 85.05)
|
||||
lon: Longitude in degrees (-180 to 180)
|
||||
zoom: Zoom level (0-22)
|
||||
|
||||
Returns:
|
||||
Tuple of (x, y) tile coordinates
|
||||
"""
|
||||
n = 2 ** zoom
|
||||
x = int((lon + 180.0) / 360.0 * n)
|
||||
lat_rad = math.radians(lat)
|
||||
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
|
||||
|
||||
# Clamp to valid range
|
||||
x = max(0, min(n - 1, x))
|
||||
y = max(0, min(n - 1, y))
|
||||
|
||||
return x, y
|
||||
|
||||
|
||||
def tile_to_bounds(z: int, x: int, y: int) -> Tuple[float, float, float, float]:
|
||||
"""
|
||||
Convert tile coordinates to bounding box.
|
||||
|
||||
Args:
|
||||
z: Zoom level
|
||||
x: Tile X coordinate
|
||||
y: Tile Y coordinate
|
||||
|
||||
Returns:
|
||||
Tuple of (west, south, east, north) in degrees
|
||||
"""
|
||||
n = 2 ** z
|
||||
|
||||
west = x / n * 360.0 - 180.0
|
||||
east = (x + 1) / n * 360.0 - 180.0
|
||||
|
||||
north_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
|
||||
south_rad = math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))
|
||||
|
||||
north = math.degrees(north_rad)
|
||||
south = math.degrees(south_rad)
|
||||
|
||||
return west, south, east, north
|
||||
|
||||
|
||||
def tile_to_center(z: int, x: int, y: int) -> Tuple[float, float]:
|
||||
"""
|
||||
Get the center point of a tile.
|
||||
|
||||
Returns:
|
||||
Tuple of (longitude, latitude) in degrees
|
||||
"""
|
||||
west, south, east, north = tile_to_bounds(z, x, y)
|
||||
return (west + east) / 2, (south + north) / 2
|
||||
|
||||
|
||||
def calculate_distance(
|
||||
lat1: float, lon1: float, lat2: float, lon2: float
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the distance between two points using the Haversine formula.
|
||||
|
||||
Args:
|
||||
lat1, lon1: First point coordinates in degrees
|
||||
lat2, lon2: Second point coordinates in degrees
|
||||
|
||||
Returns:
|
||||
Distance in meters
|
||||
"""
|
||||
R = 6371000 # Earth's radius in meters
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
delta_lat = math.radians(lat2 - lat1)
|
||||
delta_lon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (
|
||||
math.sin(delta_lat / 2) ** 2
|
||||
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2
|
||||
)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def transform_coordinates(
|
||||
geometry,
|
||||
from_crs: str = "EPSG:4326",
|
||||
to_crs: str = "EPSG:3857",
|
||||
):
|
||||
"""
|
||||
Transform a Shapely geometry between coordinate reference systems.
|
||||
|
||||
Args:
|
||||
geometry: Shapely geometry object
|
||||
from_crs: Source CRS (default WGS84)
|
||||
to_crs: Target CRS (default Web Mercator)
|
||||
|
||||
Returns:
|
||||
Transformed geometry
|
||||
"""
|
||||
transformer = pyproj.Transformer.from_crs(
|
||||
from_crs,
|
||||
to_crs,
|
||||
always_xy=True,
|
||||
)
|
||||
|
||||
return transform(transformer.transform, geometry)
|
||||
|
||||
|
||||
def calculate_area_km2(geojson: dict) -> float:
|
||||
"""
|
||||
Calculate the area of a GeoJSON polygon in square kilometers.
|
||||
|
||||
Uses ETRS89-LAEA projection for accurate area calculation in Europe.
|
||||
|
||||
Args:
|
||||
geojson: GeoJSON geometry dict
|
||||
|
||||
Returns:
|
||||
Area in square kilometers
|
||||
"""
|
||||
geom = shape(geojson)
|
||||
geom_projected = transform_coordinates(geom, "EPSG:4326", "EPSG:3035")
|
||||
return geom_projected.area / 1_000_000
|
||||
|
||||
|
||||
def is_within_bounds(
|
||||
point: Tuple[float, float],
|
||||
bounds: Tuple[float, float, float, float],
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a point is within a bounding box.
|
||||
|
||||
Args:
|
||||
point: (longitude, latitude) tuple
|
||||
bounds: (west, south, east, north) tuple
|
||||
|
||||
Returns:
|
||||
True if point is within bounds
|
||||
"""
|
||||
lon, lat = point
|
||||
west, south, east, north = bounds
|
||||
return west <= lon <= east and south <= lat <= north
|
||||
|
||||
|
||||
def get_germany_bounds() -> Tuple[float, float, float, float]:
|
||||
"""Get the bounding box of Germany."""
|
||||
return (5.87, 47.27, 15.04, 55.06)
|
||||
|
||||
|
||||
def meters_per_pixel(lat: float, zoom: int) -> float:
|
||||
"""
|
||||
Calculate the ground resolution at a given latitude and zoom level.
|
||||
|
||||
Args:
|
||||
lat: Latitude in degrees
|
||||
zoom: Zoom level
|
||||
|
||||
Returns:
|
||||
Meters per pixel at that location and zoom
|
||||
"""
|
||||
# Earth's circumference at equator in meters
|
||||
C = 40075016.686
|
||||
|
||||
# Resolution at equator for this zoom level
|
||||
resolution_equator = C / (256 * (2 ** zoom))
|
||||
|
||||
# Adjust for latitude (Mercator projection)
|
||||
return resolution_equator * math.cos(math.radians(lat))
|
||||
|
||||
|
||||
def simplify_polygon(geojson: dict, tolerance: float = 0.0001) -> dict:
|
||||
"""
|
||||
Simplify a polygon geometry to reduce complexity.
|
||||
|
||||
Args:
|
||||
geojson: GeoJSON geometry dict
|
||||
tolerance: Simplification tolerance in degrees
|
||||
|
||||
Returns:
|
||||
Simplified GeoJSON geometry
|
||||
"""
|
||||
from shapely.geometry import mapping
|
||||
|
||||
geom = shape(geojson)
|
||||
simplified = geom.simplify(tolerance, preserve_topology=True)
|
||||
return mapping(simplified)
|
||||
|
||||
|
||||
def buffer_polygon(geojson: dict, distance_meters: float) -> dict:
|
||||
"""
|
||||
Buffer a polygon by a distance in meters.
|
||||
|
||||
Args:
|
||||
geojson: GeoJSON geometry dict
|
||||
distance_meters: Buffer distance in meters
|
||||
|
||||
Returns:
|
||||
Buffered GeoJSON geometry
|
||||
"""
|
||||
from shapely.geometry import mapping
|
||||
|
||||
geom = shape(geojson)
|
||||
|
||||
# Transform to metric CRS, buffer, transform back
|
||||
geom_metric = transform_coordinates(geom, "EPSG:4326", "EPSG:3035")
|
||||
buffered = geom_metric.buffer(distance_meters)
|
||||
geom_wgs84 = transform_coordinates(buffered, "EPSG:3035", "EPSG:4326")
|
||||
|
||||
return mapping(geom_wgs84)
|
||||
|
||||
|
||||
def get_tiles_for_bounds(
|
||||
bounds: Tuple[float, float, float, float],
|
||||
zoom: int,
|
||||
) -> list[Tuple[int, int]]:
|
||||
"""
|
||||
Get all tile coordinates that cover a bounding box.
|
||||
|
||||
Args:
|
||||
bounds: (west, south, east, north) in degrees
|
||||
zoom: Zoom level
|
||||
|
||||
Returns:
|
||||
List of (x, y) tile coordinates
|
||||
"""
|
||||
west, south, east, north = bounds
|
||||
|
||||
# Get corner tiles
|
||||
x_min, y_max = lat_lon_to_tile(south, west, zoom)
|
||||
x_max, y_min = lat_lon_to_tile(north, east, zoom)
|
||||
|
||||
# Generate all tiles in range
|
||||
tiles = []
|
||||
for x in range(x_min, x_max + 1):
|
||||
for y in range(y_min, y_max + 1):
|
||||
tiles.append((x, y))
|
||||
|
||||
return tiles
|
||||
Reference in New Issue
Block a user