diff --git a/.woodpecker/main.yml b/.woodpecker/main.yml index cbb8bec..bc2fd72 100644 --- a/.woodpecker/main.yml +++ b/.woodpecker/main.yml @@ -5,7 +5,7 @@ # # Services: # Go: school-service -# Python: klausur-service, backend-lehrer, geo-service, agent-core +# Python: klausur-service, backend-lehrer, agent-core # Node.js: website, admin-lehrer, studio-v2 # # Strategie: @@ -53,7 +53,7 @@ steps: commands: - pip install --quiet ruff - | - for svc in backend-lehrer geo-service agent-core; do + for svc in backend-lehrer agent-core; do if [ -d "$svc" ]; then echo "=== Linting $svc ===" ruff check "$svc/" --output-format=github || true @@ -169,52 +169,6 @@ steps: cat ../../.ci-results/results-klausur.json if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi - - test-python-geo: - image: *python_image - environment: - CI: "true" - commands: - - | - set -uo pipefail - mkdir -p .ci-results - - if [ ! -d "geo-service" ]; then - echo '{"service":"geo-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-geo.json - echo "WARNUNG: geo-service Verzeichnis nicht gefunden" - exit 0 - fi - - cd geo-service - export PYTHONPATH="$(pwd):${PYTHONPATH:-}" - - # Pakete einzeln installieren - rasterio braucht GDAL und wird uebersprungen - while IFS= read -r line; do - pkg=$(echo "$line" | sed 's/#.*//' | xargs) - [ -z "$pkg" ] && continue - pip install --quiet --no-cache-dir "$pkg" 2>/dev/null || echo "WARNUNG: $pkg uebersprungen" - done < requirements.txt - pip install --quiet --no-cache-dir pytest pytest-json-report - - set +e - python -m pytest tests/ -v --tb=short --json-report --json-report-file=../.ci-results/test-geo.json - TEST_EXIT=$? - set -e - - if [ -f ../.ci-results/test-geo.json ]; then - TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-geo.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") - PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-geo.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") - FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-geo.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") - SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-geo.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") - else - TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 - fi - - echo "{\"service\":\"geo-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-geo.json - cat ../.ci-results/results-geo.json - - if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi - test-python-agent-core: image: *python_image environment: @@ -334,7 +288,6 @@ steps: depends_on: - test-go-school - test-python-klausur - - test-python-geo - test-python-agent-core - test-nodejs-website @@ -431,22 +384,6 @@ steps: when: - event: tag - event: manual - - build-geo-service: - image: *docker_image - commands: - - | - if [ -d ./geo-service ]; then - docker build -t breakpilot/geo-service:${CI_COMMIT_SHA:0:8} ./geo-service - docker tag breakpilot/geo-service:${CI_COMMIT_SHA:0:8} breakpilot/geo-service:latest - echo "Built breakpilot/geo-service:${CI_COMMIT_SHA:0:8}" - else - echo "geo-service Verzeichnis nicht gefunden - ueberspringe" - fi - when: - - event: tag - - event: manual - generate-sbom: image: python:3.12-slim commands: @@ -454,7 +391,7 @@ steps: echo "Installing syft for ARM64..." apt-get update -qq && apt-get install -y -qq wget > /dev/null wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin - for svc in klausur-service backend-lehrer website school-service geo-service agent-core; do + for svc in klausur-service backend-lehrer website school-service agent-core; do if [ -d "./$svc" ]; then syft dir:./$svc -o cyclonedx-json > sbom-$svc.json echo "SBOM generated for $svc" @@ -501,4 +438,3 @@ steps: - build-backend-lehrer - build-klausur-service - build-school-service - - build-geo-service diff --git a/geo-service/.env.example b/geo-service/.env.example deleted file mode 100644 index 833f3de..0000000 --- a/geo-service/.env.example +++ /dev/null @@ -1,81 +0,0 @@ -# GeoEdu Service Environment Configuration -# Copy this file to .env and adjust values as needed - -# =========================================== -# Service Configuration -# =========================================== -PORT=8088 -ENVIRONMENT=development -DEBUG=false - -# =========================================== -# JWT Authentication -# =========================================== -JWT_SECRET=your-super-secret-jwt-key-change-in-production -JWT_ALGORITHM=HS256 -JWT_EXPIRATION_HOURS=24 - -# =========================================== -# PostgreSQL (PostGIS) -# =========================================== -# Note: Database must have PostGIS extension enabled -DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db - -# =========================================== -# MinIO Object Storage (S3-compatible) -# =========================================== -# Used for storing AOI bundles and generated assets -MINIO_ENDPOINT=minio:9000 -MINIO_ACCESS_KEY=breakpilot -MINIO_SECRET_KEY=breakpilot123 -MINIO_BUCKET=breakpilot-geo -MINIO_SECURE=false - -# =========================================== -# Ollama LLM (for Learning Node Generation) -# =========================================== -# DSGVO-compliant local LLM for generating learning content -OLLAMA_BASE_URL=http://host.docker.internal:11434 -OLLAMA_MODEL=qwen2.5:14b -OLLAMA_TIMEOUT=120 - -# =========================================== -# Data Directories (Docker Volumes) -# =========================================== -OSM_DATA_DIR=/app/data/osm -DEM_DATA_DIR=/app/data/dem -TILE_CACHE_DIR=/app/cache/tiles -BUNDLE_DIR=/app/bundles - -# =========================================== -# Tile Server Configuration -# =========================================== -DEFAULT_PMTILES_FILE=germany.pmtiles -TILE_CACHE_MAX_SIZE_GB=50.0 - -# =========================================== -# DEM (Digital Elevation Model) Configuration -# =========================================== -# Copernicus DEM GLO-30 (30m resolution) -DEM_RESOLUTION=GLO-30 -TERRAIN_TILE_SIZE=256 - -# =========================================== -# AOI (Area of Interest) Limits -# =========================================== -# DSGVO data minimization: limit area size -MAX_AOI_SIZE_KM2=4.0 -MAX_AOI_PER_USER=10 -AOI_RETENTION_DAYS=30 - -# =========================================== -# Learning Nodes Configuration -# =========================================== -MAX_NODES_PER_AOI=20 -# Supported themes: topographie, landnutzung, orientierung, geologie, hydrologie, vegetation - -# =========================================== -# CORS Configuration -# =========================================== -# Comma-separated list of allowed origins -CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:8088 diff --git a/geo-service/Dockerfile b/geo-service/Dockerfile deleted file mode 100644 index 2cd069c..0000000 --- a/geo-service/Dockerfile +++ /dev/null @@ -1,69 +0,0 @@ -# GeoEdu Service - Self-Hosted OSM + Terrain -# DSGVO-konform, keine externen Tile-Services -FROM python:3.11-slim-bookworm - -# Build arguments -ARG TARGETARCH - -# Install system dependencies for geo-processing -RUN apt-get update && apt-get install -y --no-install-recommends \ - # Build essentials - build-essential \ - gcc \ - g++ \ - # Geo libraries - libgdal-dev \ - gdal-bin \ - libgeos-dev \ - libproj-dev \ - # Image processing - libpng-dev \ - libjpeg-dev \ - # Network tools - curl \ - wget \ - # Clean up - && rm -rf /var/lib/apt/lists/* - -# Set GDAL environment variables -ENV GDAL_CONFIG=/usr/bin/gdal-config -ENV CPLUS_INCLUDE_PATH=/usr/include/gdal -ENV C_INCLUDE_PATH=/usr/include/gdal - -# Create app directory -WORKDIR /app - -# Create non-root user for security -RUN groupadd -r geoservice && useradd -r -g geoservice geoservice - -# Create data and cache directories -RUN mkdir -p /app/data/osm /app/data/dem /app/cache/tiles /app/bundles \ - && chown -R geoservice:geoservice /app - -# Copy requirements first for better caching -COPY requirements.txt . - -# Install Python dependencies -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY --chown=geoservice:geoservice . . - -# Create __init__.py files for Python packages -RUN touch /app/api/__init__.py \ - && touch /app/services/__init__.py \ - && touch /app/models/__init__.py \ - && touch /app/utils/__init__.py - -# Switch to non-root user -USER geoservice - -# Expose port -EXPOSE 8088 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD curl -f http://localhost:8088/health || exit 1 - -# Start application -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8088"] diff --git a/geo-service/STATUS.md b/geo-service/STATUS.md deleted file mode 100644 index 0e08386..0000000 --- a/geo-service/STATUS.md +++ /dev/null @@ -1,196 +0,0 @@ -# GeoEdu Service - Implementierungsstatus - -**Stand:** 2026-01-24 -**Status:** Infrastruktur komplett, Downloads ausstehend - ---- - -## Übersicht - -Der GeoEdu Service ist eine DSGVO-konforme Erdkunde-Lernplattform mit: -- Self-Hosted OpenStreetMap Tile Server für Deutschland -- 3D-Terrain aus Copernicus DEM (Open Data) -- Unity WebGL Integration für immersive Lernwelten -- Learning Nodes System für didaktische Inhalte (via Ollama LLM) - -**Port:** 8088 - ---- - -## Abgeschlossene Arbeiten - -### Backend (geo-service/) - -| Datei | Status | Beschreibung | -|-------|--------|--------------| -| `Dockerfile` | ✅ | Python 3.11 + GDAL + Geo-Libraries | -| `requirements.txt` | ✅ | FastAPI, asyncpg, shapely, rasterio, pmtiles | -| `config.py` | ✅ | Pydantic Settings | -| `main.py` | ✅ | FastAPI App mit allen Routers | -| `api/tiles.py` | ✅ | Vector Tile Endpoints | -| `api/terrain.py` | ✅ | DEM/Heightmap Endpoints | -| `api/aoi.py` | ✅ | AOI Packaging Endpoints | -| `api/learning.py` | ✅ | Learning Nodes API | -| `services/tile_server.py` | ✅ | PMTiles Reader | -| `services/dem_service.py` | ✅ | Copernicus DEM Handler | -| `services/aoi_packager.py` | ✅ | Unity Bundle Generator | -| `services/osm_extractor.py` | ✅ | OSM Feature Extraction | -| `services/learning_generator.py` | ✅ | Ollama LLM Integration | -| `models/aoi.py` | ✅ | AOI Pydantic Models | -| `models/learning_node.py` | ✅ | Learning Node Models | -| `models/attribution.py` | ✅ | License Attribution | -| `utils/geo_utils.py` | ✅ | Koordinaten-Transformationen | -| `utils/minio_client.py` | ✅ | S3 Client | -| `utils/license_checker.py` | ✅ | Lizenz-Validierung | -| `scripts/download_osm.sh` | ✅ | Geofabrik Download (NICHT ausgeführt) | -| `scripts/download_dem.sh` | ✅ | Copernicus Download (NICHT ausgeführt) | -| `scripts/import_osm.sh` | ✅ | osm2pgsql Import | -| `scripts/generate_tiles.sh` | ✅ | PMTiles Generation | -| `scripts/init_postgis.sql` | ✅ | PostGIS Schema + Tabellen | -| `tests/test_tiles.py` | ✅ | API Tests | -| `tests/test_aoi.py` | ✅ | AOI + Geo Utils Tests | -| `tests/test_learning.py` | ✅ | Learning Generator Tests | -| `.env.example` | ✅ | Environment Template | - -### Frontend (studio-v2/) - -| Datei | Status | Beschreibung | -|-------|--------|--------------| -| `package.json` | ✅ | maplibre-gl Dependency hinzugefügt | -| `app/geo-lernwelt/page.tsx` | ✅ | Hauptseite mit Tabs | -| `app/geo-lernwelt/types.ts` | ✅ | TypeScript Interfaces | -| `components/geo-lernwelt/AOISelector.tsx` | ✅ | MapLibre Polygon-Zeichnung | -| `components/geo-lernwelt/UnityViewer.tsx` | ✅ | Unity WebGL Viewer | -| `lib/geo-lernwelt/GeoContext.tsx` | ✅ | React Context | -| `lib/geo-lernwelt/mapStyles.ts` | ✅ | MapLibre Styles | -| `lib/geo-lernwelt/unityBridge.ts` | ✅ | Unity Communication | - -### Docker-Konfiguration - -| Änderung | Status | Beschreibung | -|----------|--------|--------------| -| `docker-compose.yml` | ✅ | geo-service hinzugefügt | -| PostgreSQL Image | ✅ | Geändert zu `postgis/postgis:16-3.4-alpine` | -| PostGIS Init Script | ✅ | Auto-Setup bei Container-Start | -| Volumes | ✅ | geo_osm_data, geo_dem_data, geo_tile_cache, geo_aoi_bundles | - ---- - -## Ausstehende Arbeiten - -### Downloads (NICHT automatisch starten!) - -| Download | Größe | Script | Dauer (~100 Mbit/s) | -|----------|-------|--------|---------------------| -| Germany OSM PBF | 4.4 GB | `scripts/download_osm.sh` | ~6 Min | -| Copernicus DEM | ~25 GB | `scripts/download_dem.sh` | ~35 Min | - -### Generierung (nach Downloads) - -| Prozess | Output | Dauer | -|---------|--------|-------| -| osm2pgsql Import | PostgreSQL DB | ~2-4 Stunden | -| PMTiles (Z0-14) | ~200-300 GB | ~12-24 Stunden | -| DEM Tiles | ~15 GB | ~2-3 Stunden | - -**Gesamtspeicherbedarf nach Setup: ~350 GB** - ---- - -## API Endpoints - -``` -# Tiles -GET /api/v1/tiles/{z}/{x}/{y}.pbf → Vector Tile -GET /api/v1/tiles/style.json → MapLibre Style -GET /api/v1/tiles/metadata → Tile Metadata -GET /api/v1/tiles/bounds → Germany Bounds - -# Terrain -GET /api/v1/terrain/{z}/{x}/{y}.png → Heightmap (Terrain-RGB) -GET /api/v1/terrain/hillshade/{z}/{x}/{y}.png → Hillshade -GET /api/v1/terrain/elevation?lat=&lon= → Point Elevation -GET /api/v1/terrain/metadata → DEM Metadata - -# AOI (Area of Interest) -POST /api/v1/aoi → Create AOI -GET /api/v1/aoi/{id} → Get Status -GET /api/v1/aoi/{id}/manifest.json → Unity Manifest -POST /api/v1/aoi/validate → Validate Polygon -GET /api/v1/aoi/templates/mainau → Mainau Demo - -# Learning Nodes -POST /api/v1/learning/generate → Generate with LLM -GET /api/v1/learning/templates → Available Themes -GET /api/v1/learning/{aoi_id}/nodes → Get Nodes -GET /api/v1/learning/statistics → Stats -``` - ---- - -## Nächste Schritte - -1. **Container starten (ohne Downloads):** - ```bash - docker compose up -d geo-service - ``` - -2. **Health Check testen:** - ```bash - curl http://localhost:8088/health - ``` - -3. **Frontend testen:** - ``` - http://localhost:3001/geo-lernwelt - ``` - -4. **Downloads starten (wenn bereit):** - ```bash - # OSM Download (~6 Min) - docker exec breakpilot-pwa-geo-service /app/scripts/download_osm.sh - - # DEM Download (~35 Min) - docker exec breakpilot-pwa-geo-service /app/scripts/download_dem.sh - ``` - -5. **Tile-Generierung (nach Downloads):** - ```bash - # OSM Import (~2-4h) - docker exec breakpilot-pwa-geo-service /app/scripts/import_osm.sh - - # PMTiles Generation (~12-24h) - docker exec breakpilot-pwa-geo-service /app/scripts/generate_tiles.sh - ``` - ---- - -## Lizenz-Compliance - -### Zugelassene Quellen -- ✅ OpenStreetMap (ODbL) -- ✅ Copernicus DEM (Copernicus License) -- ✅ OpenAerialMap (CC-BY 4.0) - -### VERBOTEN -- ❌ Google Maps/Earth -- ❌ Bing Maps -- ❌ Apple Maps -- ❌ HERE - ---- - -## Wichtige Dateien - -| Datei | Pfad | -|-------|------| -| Haupt-Plan | `/Users/benjaminadmin/.claude/plans/stateful-hugging-kahan.md` | -| Backend | `/Users/benjaminadmin/Projekte/breakpilot-pwa/geo-service/` | -| Frontend | `/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/app/geo-lernwelt/` | -| Docker Config | `/Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml` | - ---- - -## Kontakt bei Fragen - -Dieser Service wurde gemäß dem Plan in `/Users/benjaminadmin/.claude/plans/stateful-hugging-kahan.md` implementiert. diff --git a/geo-service/api/__init__.py b/geo-service/api/__init__.py deleted file mode 100644 index 3d19af3..0000000 --- a/geo-service/api/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -GeoEdu Service API Endpoints -""" -from .tiles import router as tiles_router -from .terrain import router as terrain_router -from .aoi import router as aoi_router -from .learning import router as learning_router - -__all__ = ["tiles_router", "terrain_router", "aoi_router", "learning_router"] diff --git a/geo-service/api/aoi.py b/geo-service/api/aoi.py deleted file mode 100644 index 8e497b4..0000000 --- a/geo-service/api/aoi.py +++ /dev/null @@ -1,304 +0,0 @@ -""" -AOI (Area of Interest) API Endpoints -Handles polygon selection, validation, and Unity bundle generation -""" -from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks -from fastapi.responses import JSONResponse, FileResponse -from pydantic import BaseModel, Field -from typing import Optional -from datetime import datetime -import uuid -import structlog - -from config import settings -from models.aoi import AOIRequest, AOIResponse, AOIStatus, AOIManifest -from services.aoi_packager import AOIPackagerService -from services.osm_extractor import OSMExtractorService - -logger = structlog.get_logger(__name__) -router = APIRouter() - -# Initialize services -aoi_packager = AOIPackagerService() -osm_extractor = OSMExtractorService() - - -@router.post("", response_model=AOIResponse) -async def create_aoi( - request: AOIRequest, - background_tasks: BackgroundTasks, -): - """ - Create a new AOI (Area of Interest) for Unity 3D export. - - Validates the polygon, checks size limits (max 4 km²), and queues - bundle generation. Returns immediately with a status URL. - - The bundle will contain: - - Terrain heightmap - - OSM features (buildings, roads, water, etc.) - - Learning node positions - - Attribution information - """ - # Validate polygon - try: - area_km2 = aoi_packager.calculate_area_km2(request.polygon) - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Invalid polygon: {str(e)}") - - # Check size limit - if area_km2 > settings.max_aoi_size_km2: - raise HTTPException( - status_code=400, - detail=f"AOI too large: {area_km2:.2f} km² exceeds maximum of {settings.max_aoi_size_km2} km²", - ) - - # Check if polygon is within Germany bounds - if not aoi_packager.is_within_germany(request.polygon): - raise HTTPException( - status_code=400, - detail="AOI must be within Germany. Bounds: [5.87°E, 47.27°N] to [15.04°E, 55.06°N]", - ) - - # Generate AOI ID - aoi_id = str(uuid.uuid4()) - - # Create AOI record - aoi_data = { - "id": aoi_id, - "polygon": request.polygon, - "theme": request.theme, - "quality": request.quality, - "area_km2": area_km2, - "status": AOIStatus.QUEUED, - "created_at": datetime.utcnow().isoformat(), - } - - # Start background processing - background_tasks.add_task( - aoi_packager.process_aoi, - aoi_id=aoi_id, - polygon=request.polygon, - theme=request.theme, - quality=request.quality, - ) - - logger.info( - "AOI created", - aoi_id=aoi_id, - area_km2=area_km2, - theme=request.theme, - ) - - return AOIResponse( - aoi_id=aoi_id, - status=AOIStatus.QUEUED, - area_km2=area_km2, - estimated_size_mb=aoi_packager.estimate_bundle_size_mb(area_km2, request.quality), - message="AOI queued for processing", - ) - - -@router.get("/{aoi_id}", response_model=AOIResponse) -async def get_aoi_status( - aoi_id: str = Path(..., description="AOI UUID"), -): - """ - Get the status of an AOI processing job. - - Returns current status (queued, processing, completed, failed) - and download URLs when ready. - """ - aoi_data = await aoi_packager.get_aoi_status(aoi_id) - - if aoi_data is None: - raise HTTPException(status_code=404, detail="AOI not found") - - response = AOIResponse( - aoi_id=aoi_id, - status=aoi_data["status"], - area_km2=aoi_data.get("area_km2", 0), - estimated_size_mb=aoi_data.get("estimated_size_mb", 0), - message=aoi_data.get("message", ""), - ) - - # Add download URLs if completed - if aoi_data["status"] == AOIStatus.COMPLETED: - response.download_url = f"/api/v1/aoi/{aoi_id}/bundle.zip" - response.manifest_url = f"/api/v1/aoi/{aoi_id}/manifest.json" - response.completed_at = aoi_data.get("completed_at") - - return response - - -@router.get("/{aoi_id}/manifest.json") -async def get_aoi_manifest( - aoi_id: str = Path(..., description="AOI UUID"), -): - """ - Get the Unity bundle manifest for an AOI. - - The manifest contains: - - Terrain configuration - - Asset list and paths - - Learning node positions - - Attribution requirements - """ - manifest = await aoi_packager.get_manifest(aoi_id) - - if manifest is None: - raise HTTPException(status_code=404, detail="Manifest not found or AOI not ready") - - return JSONResponse(content=manifest) - - -@router.get("/{aoi_id}/bundle.zip") -async def download_aoi_bundle( - aoi_id: str = Path(..., description="AOI UUID"), -): - """ - Download the complete AOI bundle as a ZIP file. - - Contains all assets needed for Unity 3D rendering: - - terrain.heightmap (16-bit PNG) - - osm_features.json (buildings, roads, etc.) - - learning_nodes.json (educational content positions) - - attribution.json (required license notices) - """ - bundle_path = await aoi_packager.get_bundle_path(aoi_id) - - if bundle_path is None: - raise HTTPException(status_code=404, detail="Bundle not found or AOI not ready") - - return FileResponse( - path=bundle_path, - filename=f"geo-lernwelt-{aoi_id[:8]}.zip", - media_type="application/zip", - ) - - -@router.delete("/{aoi_id}") -async def delete_aoi( - aoi_id: str = Path(..., description="AOI UUID"), -): - """ - Delete an AOI and its bundle. - - Implements DSGVO data minimization - users can delete their data. - """ - success = await aoi_packager.delete_aoi(aoi_id) - - if not success: - raise HTTPException(status_code=404, detail="AOI not found") - - logger.info("AOI deleted", aoi_id=aoi_id) - - return {"message": "AOI deleted successfully", "aoi_id": aoi_id} - - -@router.get("/{aoi_id}/preview") -async def get_aoi_preview( - aoi_id: str = Path(..., description="AOI UUID"), - width: int = Query(512, ge=64, le=2048, description="Preview width"), - height: int = Query(512, ge=64, le=2048, description="Preview height"), -): - """ - Get a preview image of the AOI. - - Returns a rendered preview showing terrain, OSM features, - and learning node positions. - """ - preview_data = await aoi_packager.generate_preview(aoi_id, width, height) - - if preview_data is None: - raise HTTPException(status_code=404, detail="Preview not available") - - from fastapi.responses import Response - - return Response( - content=preview_data, - media_type="image/png", - ) - - -@router.post("/validate") -async def validate_aoi_polygon( - polygon: dict, -): - """ - Validate an AOI polygon without creating it. - - Checks: - - Valid GeoJSON format - - Within Germany bounds - - Within size limits - - Not self-intersecting - """ - try: - # Validate geometry - is_valid, message = aoi_packager.validate_polygon(polygon) - - if not is_valid: - return { - "valid": False, - "error": message, - } - - # Calculate area - area_km2 = aoi_packager.calculate_area_km2(polygon) - - # Check bounds - within_germany = aoi_packager.is_within_germany(polygon) - - # Check size - within_size_limit = area_km2 <= settings.max_aoi_size_km2 - - return { - "valid": is_valid and within_germany and within_size_limit, - "area_km2": round(area_km2, 3), - "within_germany": within_germany, - "within_size_limit": within_size_limit, - "max_size_km2": settings.max_aoi_size_km2, - "estimated_bundle_size_mb": aoi_packager.estimate_bundle_size_mb(area_km2, "medium"), - } - - except Exception as e: - return { - "valid": False, - "error": str(e), - } - - -@router.get("/templates/mainau") -async def get_mainau_template(): - """ - Get pre-configured AOI template for Mainau island (demo location). - - Mainau is a small island in Lake Constance (Bodensee) - - perfect for educational geography lessons. - """ - return { - "name": "Insel Mainau", - "description": "Blumeninsel im Bodensee - ideal fuer Erdkunde-Unterricht", - "polygon": { - "type": "Polygon", - "coordinates": [ - [ - [9.1875, 47.7055], - [9.1975, 47.7055], - [9.1975, 47.7115], - [9.1875, 47.7115], - [9.1875, 47.7055], - ] - ], - }, - "center": [9.1925, 47.7085], - "area_km2": 0.45, - "suggested_themes": ["topographie", "vegetation", "landnutzung"], - "features": [ - "Schloss und Schlosskirche", - "Botanischer Garten", - "Bodensee-Ufer", - "Waldgebiete", - ], - } diff --git a/geo-service/api/learning.py b/geo-service/api/learning.py deleted file mode 100644 index ac4198b..0000000 --- a/geo-service/api/learning.py +++ /dev/null @@ -1,289 +0,0 @@ -""" -Learning Nodes API Endpoints -Generates and manages educational content for AOI regions -""" -from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks -from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field -from typing import Optional -import structlog - -from config import settings -from models.learning_node import LearningNode, LearningNodeRequest, LearningTheme -from services.learning_generator import LearningGeneratorService - -logger = structlog.get_logger(__name__) -router = APIRouter() - -# Initialize learning generator service -learning_service = LearningGeneratorService() - - -class GenerateNodesRequest(BaseModel): - """Request model for generating learning nodes.""" - - aoi_id: str = Field(..., description="AOI UUID to generate nodes for") - theme: LearningTheme = Field(LearningTheme.TOPOGRAPHIE, description="Learning theme") - difficulty: str = Field("mittel", pattern="^(leicht|mittel|schwer)$", description="Difficulty level") - node_count: int = Field(5, ge=1, le=20, description="Number of nodes to generate") - grade_level: Optional[str] = Field(None, description="Target grade level (e.g., '5-6', '7-8')") - language: str = Field("de", description="Content language") - - -class GenerateNodesResponse(BaseModel): - """Response model for generated learning nodes.""" - - aoi_id: str - theme: str - nodes: list[LearningNode] - total_count: int - generation_model: str - - -@router.post("/generate", response_model=GenerateNodesResponse) -async def generate_learning_nodes( - request: GenerateNodesRequest, -): - """ - Generate learning nodes for an AOI using LLM. - - Uses Ollama with the configured model to generate educational content - based on the AOI's geographic features and selected theme. - - Themes: - - topographie: Landforms, elevation, terrain features - - landnutzung: Land use, settlement patterns, agriculture - - orientierung: Navigation, compass, map reading - - geologie: Rock types, geological formations - - hydrologie: Water features, drainage, watersheds - - vegetation: Plant communities, climate zones - """ - try: - nodes = await learning_service.generate_nodes( - aoi_id=request.aoi_id, - theme=request.theme, - difficulty=request.difficulty, - node_count=request.node_count, - grade_level=request.grade_level, - language=request.language, - ) - - logger.info( - "Learning nodes generated", - aoi_id=request.aoi_id, - theme=request.theme.value, - count=len(nodes), - ) - - return GenerateNodesResponse( - aoi_id=request.aoi_id, - theme=request.theme.value, - nodes=nodes, - total_count=len(nodes), - generation_model=settings.ollama_model, - ) - - except FileNotFoundError: - raise HTTPException( - status_code=404, - detail="AOI not found. Please create an AOI first.", - ) - except ConnectionError: - raise HTTPException( - status_code=503, - detail="LLM service (Ollama) not available. Please check if Ollama is running.", - ) - except Exception as e: - logger.error("Error generating learning nodes", error=str(e)) - raise HTTPException(status_code=500, detail="Error generating learning nodes") - - -@router.get("/templates") -async def get_learning_templates(): - """ - Get available learning theme templates. - - Returns theme definitions with descriptions, suitable grade levels, - and example questions. - """ - return { - "themes": [ - { - "id": "topographie", - "name": "Topographie", - "description": "Landschaftsformen, Höhen und Geländemerkmale", - "icon": "mountain", - "grade_levels": ["5-6", "7-8", "9-10"], - "example_questions": [ - "Welche Höhe hat der höchste Punkt in diesem Gebiet?", - "Beschreibe die Hangneigung im nördlichen Bereich.", - "Wo befinden sich Täler und wo Bergrücken?", - ], - "keywords": ["Höhe", "Hang", "Tal", "Berg", "Hügel", "Ebene"], - }, - { - "id": "landnutzung", - "name": "Landnutzung", - "description": "Siedlungen, Landwirtschaft und Flächennutzung", - "icon": "home", - "grade_levels": ["5-6", "7-8", "9-10", "11-12"], - "example_questions": [ - "Welche Arten von Gebäuden sind in diesem Bereich zu finden?", - "Wie viel Prozent der Fläche wird landwirtschaftlich genutzt?", - "Wo verläuft die Grenze zwischen Siedlung und Naturraum?", - ], - "keywords": ["Siedlung", "Acker", "Wald", "Industrie", "Straße"], - }, - { - "id": "orientierung", - "name": "Orientierung", - "description": "Kartenlesen, Kompass und Navigation", - "icon": "compass", - "grade_levels": ["5-6", "7-8"], - "example_questions": [ - "In welcher Himmelsrichtung liegt der See?", - "Wie lang ist der Weg von A nach B?", - "Beschreibe den Weg vom Parkplatz zum Aussichtspunkt.", - ], - "keywords": ["Norden", "Süden", "Entfernung", "Maßstab", "Legende"], - }, - { - "id": "geologie", - "name": "Geologie", - "description": "Gesteinsarten und geologische Formationen", - "icon": "layers", - "grade_levels": ["7-8", "9-10", "11-12"], - "example_questions": [ - "Welches Gestein dominiert in diesem Gebiet?", - "Wie sind die Felsformationen entstanden?", - "Welche Rolle spielt die Geologie für die Landschaft?", - ], - "keywords": ["Gestein", "Formation", "Erosion", "Schicht", "Mineral"], - }, - { - "id": "hydrologie", - "name": "Hydrologie", - "description": "Gewässer, Einzugsgebiete und Wasserkreislauf", - "icon": "droplet", - "grade_levels": ["5-6", "7-8", "9-10"], - "example_questions": [ - "Wohin fließt das Wasser in diesem Gebiet?", - "Welche Gewässerarten sind vorhanden?", - "Wo könnte sich bei Starkregen Wasser sammeln?", - ], - "keywords": ["Fluss", "See", "Bach", "Einzugsgebiet", "Quelle"], - }, - { - "id": "vegetation", - "name": "Vegetation", - "description": "Pflanzengemeinschaften und Klimazonen", - "icon": "tree", - "grade_levels": ["5-6", "7-8", "9-10", "11-12"], - "example_questions": [ - "Welche Waldtypen sind in diesem Gebiet zu finden?", - "Wie beeinflusst die Höhenlage die Vegetation?", - "Welche Pflanzen würdest du hier erwarten?", - ], - "keywords": ["Wald", "Wiese", "Laubbaum", "Nadelbaum", "Höhenstufe"], - }, - ], - "difficulties": [ - {"id": "leicht", "name": "Leicht", "description": "Grundlegende Beobachtungen"}, - {"id": "mittel", "name": "Mittel", "description": "Verknüpfung von Zusammenhängen"}, - {"id": "schwer", "name": "Schwer", "description": "Analyse und Transfer"}, - ], - "supported_languages": ["de", "en"], - } - - -@router.get("/{aoi_id}/nodes") -async def get_aoi_learning_nodes( - aoi_id: str = Path(..., description="AOI UUID"), - theme: Optional[LearningTheme] = Query(None, description="Filter by theme"), -): - """ - Get all learning nodes for an AOI. - - Returns previously generated nodes, optionally filtered by theme. - """ - nodes = await learning_service.get_nodes_for_aoi(aoi_id, theme) - - if nodes is None: - raise HTTPException(status_code=404, detail="AOI not found") - - return { - "aoi_id": aoi_id, - "nodes": nodes, - "total_count": len(nodes), - } - - -@router.put("/{aoi_id}/nodes/{node_id}") -async def update_learning_node( - aoi_id: str = Path(..., description="AOI UUID"), - node_id: str = Path(..., description="Node UUID"), - node_update: LearningNode, -): - """ - Update a learning node (teacher review/edit). - - Allows teachers to modify AI-generated content before use. - """ - success = await learning_service.update_node(aoi_id, node_id, node_update) - - if not success: - raise HTTPException(status_code=404, detail="Node not found") - - logger.info("Learning node updated", aoi_id=aoi_id, node_id=node_id) - - return {"message": "Node updated successfully", "node_id": node_id} - - -@router.delete("/{aoi_id}/nodes/{node_id}") -async def delete_learning_node( - aoi_id: str = Path(..., description="AOI UUID"), - node_id: str = Path(..., description="Node UUID"), -): - """ - Delete a learning node. - """ - success = await learning_service.delete_node(aoi_id, node_id) - - if not success: - raise HTTPException(status_code=404, detail="Node not found") - - return {"message": "Node deleted successfully", "node_id": node_id} - - -@router.post("/{aoi_id}/nodes/{node_id}/approve") -async def approve_learning_node( - aoi_id: str = Path(..., description="AOI UUID"), - node_id: str = Path(..., description="Node UUID"), -): - """ - Approve a learning node for student use. - - Only approved nodes will be visible to students. - """ - success = await learning_service.approve_node(aoi_id, node_id) - - if not success: - raise HTTPException(status_code=404, detail="Node not found") - - return {"message": "Node approved", "node_id": node_id} - - -@router.get("/statistics") -async def get_learning_statistics(): - """ - Get statistics about learning node usage. - """ - stats = await learning_service.get_statistics() - - return { - "total_nodes_generated": stats.get("total_nodes", 0), - "nodes_by_theme": stats.get("by_theme", {}), - "nodes_by_difficulty": stats.get("by_difficulty", {}), - "average_nodes_per_aoi": stats.get("avg_per_aoi", 0), - "most_popular_theme": stats.get("popular_theme", "topographie"), - } diff --git a/geo-service/api/terrain.py b/geo-service/api/terrain.py deleted file mode 100644 index 28a01ca..0000000 --- a/geo-service/api/terrain.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -Terrain/DEM API Endpoints -Serves heightmap tiles from Copernicus DEM data -""" -from fastapi import APIRouter, HTTPException, Path, Query, Response -from fastapi.responses import JSONResponse -import structlog - -from config import settings -from services.dem_service import DEMService - -logger = structlog.get_logger(__name__) -router = APIRouter() - -# Initialize DEM service -dem_service = DEMService() - - -@router.get("/{z}/{x}/{y}.png", response_class=Response) -async def get_heightmap_tile( - z: int = Path(..., ge=0, le=14, description="Zoom level"), - x: int = Path(..., ge=0, description="Tile X coordinate"), - y: int = Path(..., ge=0, description="Tile Y coordinate"), -): - """ - Get a heightmap tile as 16-bit PNG (Mapbox Terrain-RGB encoding). - - Heightmaps are generated from Copernicus DEM GLO-30 (30m resolution). - The encoding allows for ~0.1m precision: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) - """ - try: - tile_data = await dem_service.get_heightmap_tile(z, x, y) - - if tile_data is None: - return Response(status_code=204) # No terrain data for this tile - - return Response( - content=tile_data, - media_type="image/png", - headers={ - "Cache-Control": "public, max-age=604800", # 7 days cache - "X-Tile-Source": "copernicus-dem", - }, - ) - - except FileNotFoundError: - logger.warning("DEM data not found", z=z, x=x, y=y) - raise HTTPException( - status_code=503, - detail="DEM data not available. Please download Copernicus DEM first.", - ) - except Exception as e: - logger.error("Error serving heightmap tile", z=z, x=x, y=y, error=str(e)) - raise HTTPException(status_code=500, detail="Error serving heightmap tile") - - -@router.get("/hillshade/{z}/{x}/{y}.png", response_class=Response) -async def get_hillshade_tile( - z: int = Path(..., ge=0, le=14, description="Zoom level"), - x: int = Path(..., ge=0, description="Tile X coordinate"), - y: int = Path(..., ge=0, description="Tile Y coordinate"), - azimuth: float = Query(315, ge=0, le=360, description="Light azimuth in degrees"), - altitude: float = Query(45, ge=0, le=90, description="Light altitude in degrees"), -): - """ - Get a hillshade tile for terrain visualization. - - Hillshade is rendered from DEM with configurable light direction. - Default light comes from northwest (azimuth=315) at 45° altitude. - """ - try: - tile_data = await dem_service.get_hillshade_tile(z, x, y, azimuth, altitude) - - if tile_data is None: - return Response(status_code=204) - - return Response( - content=tile_data, - media_type="image/png", - headers={ - "Cache-Control": "public, max-age=604800", - "X-Hillshade-Azimuth": str(azimuth), - "X-Hillshade-Altitude": str(altitude), - }, - ) - - except FileNotFoundError: - raise HTTPException(status_code=503, detail="DEM data not available") - except Exception as e: - logger.error("Error serving hillshade tile", z=z, x=x, y=y, error=str(e)) - raise HTTPException(status_code=500, detail="Error serving hillshade tile") - - -@router.get("/contours/{z}/{x}/{y}.pbf", response_class=Response) -async def get_contour_tile( - z: int = Path(..., ge=0, le=14, description="Zoom level"), - x: int = Path(..., ge=0, description="Tile X coordinate"), - y: int = Path(..., ge=0, description="Tile Y coordinate"), - interval: int = Query(20, ge=5, le=100, description="Contour interval in meters"), -): - """ - Get contour lines as vector tile. - - Contours are generated from DEM at the specified interval. - Useful for topographic map overlays. - """ - try: - tile_data = await dem_service.get_contour_tile(z, x, y, interval) - - if tile_data is None: - return Response(status_code=204) - - return Response( - content=tile_data, - media_type="application/x-protobuf", - headers={ - "Content-Encoding": "gzip", - "Cache-Control": "public, max-age=604800", - "X-Contour-Interval": str(interval), - }, - ) - - except FileNotFoundError: - raise HTTPException(status_code=503, detail="DEM data not available") - except Exception as e: - logger.error("Error serving contour tile", z=z, x=x, y=y, error=str(e)) - raise HTTPException(status_code=500, detail="Error serving contour tile") - - -@router.get("/elevation") -async def get_elevation_at_point( - lat: float = Query(..., ge=47.27, le=55.06, description="Latitude"), - lon: float = Query(..., ge=5.87, le=15.04, description="Longitude"), -): - """ - Get elevation at a specific point. - - Returns elevation in meters from Copernicus DEM. - Only works within Germany bounds. - """ - try: - elevation = await dem_service.get_elevation(lat, lon) - - if elevation is None: - raise HTTPException( - status_code=404, - detail="No elevation data available for this location", - ) - - return { - "latitude": lat, - "longitude": lon, - "elevation_m": round(elevation, 1), - "source": "Copernicus DEM GLO-30", - "resolution_m": 30, - } - - except FileNotFoundError: - raise HTTPException(status_code=503, detail="DEM data not available") - except Exception as e: - logger.error("Error getting elevation", lat=lat, lon=lon, error=str(e)) - raise HTTPException(status_code=500, detail="Error getting elevation") - - -@router.post("/elevation/profile") -async def get_elevation_profile( - coordinates: list[list[float]], - samples: int = Query(100, ge=10, le=1000, description="Number of sample points"), -): - """ - Get elevation profile along a path. - - Takes a list of [lon, lat] coordinates and returns elevations sampled along the path. - Useful for hiking/cycling route profiles. - """ - if len(coordinates) < 2: - raise HTTPException(status_code=400, detail="At least 2 coordinates required") - - if len(coordinates) > 100: - raise HTTPException(status_code=400, detail="Maximum 100 coordinates allowed") - - try: - profile = await dem_service.get_elevation_profile(coordinates, samples) - - return { - "profile": profile, - "statistics": { - "min_elevation_m": min(p["elevation_m"] for p in profile if p["elevation_m"]), - "max_elevation_m": max(p["elevation_m"] for p in profile if p["elevation_m"]), - "total_ascent_m": sum( - max(0, profile[i + 1]["elevation_m"] - profile[i]["elevation_m"]) - for i in range(len(profile) - 1) - if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"] - ), - "total_descent_m": sum( - max(0, profile[i]["elevation_m"] - profile[i + 1]["elevation_m"]) - for i in range(len(profile) - 1) - if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"] - ), - }, - "source": "Copernicus DEM GLO-30", - } - - except FileNotFoundError: - raise HTTPException(status_code=503, detail="DEM data not available") - except Exception as e: - logger.error("Error getting elevation profile", error=str(e)) - raise HTTPException(status_code=500, detail="Error getting elevation profile") - - -@router.get("/metadata") -async def get_dem_metadata(): - """ - Get metadata about available DEM data. - """ - metadata = await dem_service.get_metadata() - - return { - "name": "Copernicus DEM GLO-30", - "description": "Global 30m Digital Elevation Model", - "resolution_m": 30, - "coverage": "Germany (Deutschland)", - "bounds": [5.87, 47.27, 15.04, 55.06], - "vertical_datum": "EGM2008", - "horizontal_datum": "WGS84", - "license": "Copernicus Data (free, attribution required)", - "attribution": "© Copernicus Service Information 2024", - "data_available": metadata.get("data_available", False), - "tiles_generated": metadata.get("tiles_generated", 0), - } diff --git a/geo-service/api/tiles.py b/geo-service/api/tiles.py deleted file mode 100644 index 67dbf41..0000000 --- a/geo-service/api/tiles.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Tile Server API Endpoints -Serves Vector Tiles from PMTiles or generates on-demand from PostGIS -""" -from fastapi import APIRouter, HTTPException, Path, Query, Response -from fastapi.responses import JSONResponse -import structlog - -from config import settings -from services.tile_server import TileServerService - -logger = structlog.get_logger(__name__) -router = APIRouter() - -# Initialize tile server service -tile_service = TileServerService() - - -@router.get("/{z}/{x}/{y}.pbf", response_class=Response) -async def get_vector_tile( - z: int = Path(..., ge=0, le=22, description="Zoom level"), - x: int = Path(..., ge=0, description="Tile X coordinate"), - y: int = Path(..., ge=0, description="Tile Y coordinate"), -): - """ - Get a vector tile in Protocol Buffers format. - - Returns OSM data as vector tiles suitable for MapLibre GL JS. - Tiles are served from pre-generated PMTiles or cached on-demand. - """ - try: - tile_data = await tile_service.get_tile(z, x, y) - - if tile_data is None: - # Return empty tile (204 No Content is standard for empty tiles) - return Response(status_code=204) - - return Response( - content=tile_data, - media_type="application/x-protobuf", - headers={ - "Content-Encoding": "gzip", - "Cache-Control": "public, max-age=86400", # 24h cache - "X-Tile-Source": "pmtiles", - }, - ) - - except FileNotFoundError: - logger.warning("PMTiles file not found", z=z, x=x, y=y) - raise HTTPException( - status_code=503, - detail="Tile data not available. Please run the data download script first.", - ) - except Exception as e: - logger.error("Error serving tile", z=z, x=x, y=y, error=str(e)) - raise HTTPException(status_code=500, detail="Error serving tile") - - -@router.get("/style.json") -async def get_maplibre_style( - base_url: str = Query(None, description="Base URL for tile server"), -): - """ - Get MapLibre GL JS style specification. - - Returns a style document configured for the self-hosted tile server. - """ - # Use provided base_url or construct from settings - if base_url is None: - base_url = f"http://localhost:{settings.port}" - - style = { - "version": 8, - "name": "GeoEdu Germany", - "metadata": { - "description": "Self-hosted OSM tiles for DSGVO-compliant education", - "attribution": "© OpenStreetMap contributors", - }, - "sources": { - "osm": { - "type": "vector", - "tiles": [f"{base_url}/api/v1/tiles/{{z}}/{{x}}/{{y}}.pbf"], - "minzoom": 0, - "maxzoom": 14, - "attribution": "© OpenStreetMap contributors (ODbL)", - }, - "terrain": { - "type": "raster-dem", - "tiles": [f"{base_url}/api/v1/terrain/{{z}}/{{x}}/{{y}}.png"], - "tileSize": 256, - "maxzoom": 14, - "attribution": "© Copernicus DEM GLO-30", - }, - }, - "sprite": "", - "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf", - "layers": [ - # Background - { - "id": "background", - "type": "background", - "paint": {"background-color": "#f8f4f0"}, - }, - # Water - { - "id": "water", - "type": "fill", - "source": "osm", - "source-layer": "water", - "paint": {"fill-color": "#a0c8f0"}, - }, - # Landuse - Parks - { - "id": "landuse-park", - "type": "fill", - "source": "osm", - "source-layer": "landuse", - "filter": ["==", "class", "park"], - "paint": {"fill-color": "#c8e6c8", "fill-opacity": 0.5}, - }, - # Landuse - Forest - { - "id": "landuse-forest", - "type": "fill", - "source": "osm", - "source-layer": "landuse", - "filter": ["==", "class", "wood"], - "paint": {"fill-color": "#94d294", "fill-opacity": 0.5}, - }, - # Buildings - { - "id": "building", - "type": "fill", - "source": "osm", - "source-layer": "building", - "minzoom": 13, - "paint": {"fill-color": "#d9d0c9", "fill-opacity": 0.8}, - }, - # Roads - Minor - { - "id": "road-minor", - "type": "line", - "source": "osm", - "source-layer": "transportation", - "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "minor", "service"]], - "paint": {"line-color": "#ffffff", "line-width": 1}, - }, - # Roads - Major - { - "id": "road-major", - "type": "line", - "source": "osm", - "source-layer": "transportation", - "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "primary", "secondary", "tertiary"]], - "paint": {"line-color": "#ffc107", "line-width": 2}, - }, - # Roads - Highway - { - "id": "road-highway", - "type": "line", - "source": "osm", - "source-layer": "transportation", - "filter": ["all", ["==", "$type", "LineString"], ["==", "class", "motorway"]], - "paint": {"line-color": "#ff6f00", "line-width": 3}, - }, - # Place labels - { - "id": "place-label", - "type": "symbol", - "source": "osm", - "source-layer": "place", - "layout": { - "text-field": "{name}", - "text-font": ["Open Sans Regular"], - "text-size": 12, - }, - "paint": {"text-color": "#333333", "text-halo-color": "#ffffff", "text-halo-width": 1}, - }, - ], - "terrain": {"source": "terrain", "exaggeration": 1.5}, - } - - return JSONResponse(content=style) - - -@router.get("/metadata") -async def get_tile_metadata(): - """ - Get metadata about available tiles. - - Returns information about data coverage, zoom levels, and update status. - """ - metadata = await tile_service.get_metadata() - - return { - "name": "GeoEdu Germany Tiles", - "description": "Self-hosted OSM vector tiles for Germany", - "format": "pbf", - "scheme": "xyz", - "minzoom": metadata.get("minzoom", 0), - "maxzoom": metadata.get("maxzoom", 14), - "bounds": metadata.get("bounds", [5.87, 47.27, 15.04, 55.06]), # Germany bbox - "center": metadata.get("center", [10.45, 51.16, 6]), # Center of Germany - "attribution": "© OpenStreetMap contributors (ODbL)", - "data_available": metadata.get("data_available", False), - "last_updated": metadata.get("last_updated"), - } - - -@router.get("/bounds") -async def get_tile_bounds(): - """ - Get the geographic bounds of available tile data. - - Returns bounding box for Germany in [west, south, east, north] format. - """ - return { - "bounds": [5.87, 47.27, 15.04, 55.06], # Germany bounding box - "center": [10.45, 51.16], - "description": "Germany (Deutschland)", - } diff --git a/geo-service/config.py b/geo-service/config.py deleted file mode 100644 index eda36cb..0000000 --- a/geo-service/config.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -GeoEdu Service Configuration -Environment-based configuration with Pydantic Settings -""" -from functools import lru_cache -from typing import Optional -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """Application settings loaded from environment variables.""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - ) - - # Service Config - port: int = 8088 - environment: str = "development" - debug: bool = False - - # JWT Authentication - jwt_secret: str = "your-super-secret-jwt-key" - jwt_algorithm: str = "HS256" - jwt_expiration_hours: int = 24 - - # PostgreSQL (PostGIS) - database_url: str = "postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db" - - # MinIO (S3-compatible storage) - minio_endpoint: str = "minio:9000" - minio_access_key: str = "breakpilot" - minio_secret_key: str = "breakpilot123" - minio_bucket: str = "breakpilot-geo" - minio_secure: bool = False - - # Ollama (LLM for learning nodes) - ollama_base_url: str = "http://host.docker.internal:11434" - ollama_model: str = "qwen2.5:14b" - ollama_timeout: int = 120 - - # Data Directories - osm_data_dir: str = "/app/data/osm" - dem_data_dir: str = "/app/data/dem" - tile_cache_dir: str = "/app/cache/tiles" - bundle_dir: str = "/app/bundles" - - # Tile Server Config - default_pmtiles_file: str = "germany.pmtiles" - tile_cache_max_size_gb: float = 50.0 - - # DEM Config - dem_resolution: str = "GLO-30" # 30m Copernicus DEM - terrain_tile_size: int = 256 - - # AOI Limits (DSGVO data minimization) - max_aoi_size_km2: float = 4.0 # Max 4 km² per AOI - max_aoi_per_user: int = 10 - aoi_retention_days: int = 30 # Auto-delete after 30 days - - # Learning Nodes - max_nodes_per_aoi: int = 20 - supported_themes: list[str] = [ - "topographie", - "landnutzung", - "orientierung", - "geologie", - "hydrologie", - "vegetation", - ] - - # CORS (for frontend access) - cors_origins: list[str] = [ - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:8088", - ] - - @property - def pmtiles_path(self) -> str: - """Full path to PMTiles file.""" - return f"{self.osm_data_dir}/{self.default_pmtiles_file}" - - @property - def is_development(self) -> bool: - """Check if running in development mode.""" - return self.environment == "development" - - -@lru_cache -def get_settings() -> Settings: - """Get cached settings instance.""" - return Settings() - - -# Export settings instance for convenience -settings = get_settings() diff --git a/geo-service/main.py b/geo-service/main.py deleted file mode 100644 index b17abe0..0000000 --- a/geo-service/main.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -GeoEdu Service - Self-Hosted OSM + Terrain Learning Platform -DSGVO-konforme Erdkunde-Lernplattform mit selbst gehostetem OpenStreetMap - -Main FastAPI Application -""" -import structlog -from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -import time - -from config import settings - -# Configure structured logging -structlog.configure( - processors=[ - structlog.stdlib.filter_by_level, - structlog.stdlib.add_logger_name, - structlog.stdlib.add_log_level, - structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.UnicodeDecoder(), - structlog.processors.JSONRenderer() if not settings.is_development else structlog.dev.ConsoleRenderer(), - ], - wrapper_class=structlog.stdlib.BoundLogger, - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - cache_logger_on_first_use=True, -) - -logger = structlog.get_logger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Application lifespan manager.""" - # Startup - logger.info( - "Starting GeoEdu Service", - environment=settings.environment, - port=settings.port, - ) - - # Check data directories - import os - for dir_name, dir_path in [ - ("OSM Data", settings.osm_data_dir), - ("DEM Data", settings.dem_data_dir), - ("Tile Cache", settings.tile_cache_dir), - ("Bundles", settings.bundle_dir), - ]: - if os.path.exists(dir_path): - logger.info(f"{dir_name} directory exists", path=dir_path) - else: - logger.warning(f"{dir_name} directory missing", path=dir_path) - os.makedirs(dir_path, exist_ok=True) - - yield - - # Shutdown - logger.info("Shutting down GeoEdu Service") - - -# Create FastAPI app -app = FastAPI( - title="GeoEdu Service", - description="DSGVO-konforme Erdkunde-Lernplattform mit selbst gehostetem OpenStreetMap", - version="1.0.0", - docs_url="/docs" if settings.is_development else None, - redoc_url="/redoc" if settings.is_development else None, - lifespan=lifespan, -) - -# CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -# Request timing middleware -@app.middleware("http") -async def add_timing_header(request: Request, call_next): - """Add X-Process-Time header to all responses.""" - start_time = time.time() - response = await call_next(request) - process_time = time.time() - start_time - response.headers["X-Process-Time"] = str(process_time) - return response - - -# Import and register routers -from api.tiles import router as tiles_router -from api.terrain import router as terrain_router -from api.aoi import router as aoi_router -from api.learning import router as learning_router - -app.include_router(tiles_router, prefix="/api/v1/tiles", tags=["Tiles"]) -app.include_router(terrain_router, prefix="/api/v1/terrain", tags=["Terrain"]) -app.include_router(aoi_router, prefix="/api/v1/aoi", tags=["AOI"]) -app.include_router(learning_router, prefix="/api/v1/learning", tags=["Learning"]) - - -# Health check endpoint -@app.get("/health", tags=["System"]) -async def health_check(): - """ - Health check endpoint for Docker/Kubernetes probes. - Returns service status and basic metrics. - """ - import os - - # Check data availability - pmtiles_exists = os.path.exists(settings.pmtiles_path) - dem_exists = os.path.exists(settings.dem_data_dir) and len(os.listdir(settings.dem_data_dir)) > 0 - - return { - "status": "healthy", - "service": "geo-service", - "version": "1.0.0", - "environment": settings.environment, - "data_status": { - "pmtiles_available": pmtiles_exists, - "dem_available": dem_exists, - "tile_cache_dir": os.path.exists(settings.tile_cache_dir), - "bundle_dir": os.path.exists(settings.bundle_dir), - }, - "config": { - "max_aoi_size_km2": settings.max_aoi_size_km2, - "supported_themes": settings.supported_themes, - }, - } - - -# Root endpoint -@app.get("/", tags=["System"]) -async def root(): - """Root endpoint with service information.""" - return { - "service": "GeoEdu Service", - "description": "DSGVO-konforme Erdkunde-Lernplattform", - "version": "1.0.0", - "docs": "/docs" if settings.is_development else "disabled", - "endpoints": { - "tiles": "/api/v1/tiles", - "terrain": "/api/v1/terrain", - "aoi": "/api/v1/aoi", - "learning": "/api/v1/learning", - }, - "attribution": { - "osm": "© OpenStreetMap contributors (ODbL)", - "dem": "© Copernicus Service (free, attribution required)", - }, - } - - -# Error handlers -@app.exception_handler(404) -async def not_found_handler(request: Request, exc): - """Handle 404 errors.""" - return JSONResponse( - status_code=404, - content={"error": "Not found", "path": str(request.url.path)}, - ) - - -@app.exception_handler(500) -async def internal_error_handler(request: Request, exc): - """Handle 500 errors.""" - logger.error("Internal server error", path=str(request.url.path), error=str(exc)) - return JSONResponse( - status_code=500, - content={"error": "Internal server error"}, - ) - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run( - "main:app", - host="0.0.0.0", - port=settings.port, - reload=settings.is_development, - ) diff --git a/geo-service/models/__init__.py b/geo-service/models/__init__.py deleted file mode 100644 index d570f37..0000000 --- a/geo-service/models/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -GeoEdu Service - Pydantic Models -""" -from .aoi import AOIRequest, AOIResponse, AOIStatus, AOIManifest -from .learning_node import LearningNode, LearningNodeRequest, LearningTheme, NodeType -from .attribution import Attribution, AttributionSource - -__all__ = [ - "AOIRequest", - "AOIResponse", - "AOIStatus", - "AOIManifest", - "LearningNode", - "LearningNodeRequest", - "LearningTheme", - "NodeType", - "Attribution", - "AttributionSource", -] diff --git a/geo-service/models/aoi.py b/geo-service/models/aoi.py deleted file mode 100644 index f0977c9..0000000 --- a/geo-service/models/aoi.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -AOI (Area of Interest) Models -Pydantic models for AOI requests and responses -""" -from enum import Enum -from typing import Optional -from datetime import datetime -from pydantic import BaseModel, Field - - -class AOIStatus(str, Enum): - """Status of an AOI processing job.""" - QUEUED = "queued" - PROCESSING = "processing" - COMPLETED = "completed" - FAILED = "failed" - - -class AOIQuality(str, Enum): - """Quality level for AOI bundle generation.""" - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - - -class AOITheme(str, Enum): - """Learning theme for AOI.""" - TOPOGRAPHIE = "topographie" - LANDNUTZUNG = "landnutzung" - ORIENTIERUNG = "orientierung" - GEOLOGIE = "geologie" - HYDROLOGIE = "hydrologie" - VEGETATION = "vegetation" - - -class GeoJSONPolygon(BaseModel): - """GeoJSON Polygon geometry.""" - type: str = Field("Polygon", const=True) - coordinates: list[list[list[float]]] = Field( - ..., - description="Polygon coordinates as [[[lon, lat], ...]]", - ) - - -class AOIRequest(BaseModel): - """Request model for creating an AOI.""" - polygon: dict = Field( - ..., - description="GeoJSON Polygon geometry", - example={ - "type": "Polygon", - "coordinates": [[[9.19, 47.71], [9.20, 47.71], [9.20, 47.70], [9.19, 47.70], [9.19, 47.71]]] - }, - ) - theme: str = Field( - "topographie", - description="Learning theme for the AOI", - ) - quality: str = Field( - "medium", - pattern="^(low|medium|high)$", - description="Bundle quality level", - ) - - class Config: - json_schema_extra = { - "example": { - "polygon": { - "type": "Polygon", - "coordinates": [[[9.1875, 47.7055], [9.1975, 47.7055], [9.1975, 47.7115], [9.1875, 47.7115], [9.1875, 47.7055]]] - }, - "theme": "topographie", - "quality": "medium", - } - } - - -class AOIResponse(BaseModel): - """Response model for AOI operations.""" - aoi_id: str = Field(..., description="Unique AOI identifier") - status: AOIStatus = Field(..., description="Current processing status") - area_km2: float = Field(0, description="Area in square kilometers") - estimated_size_mb: float = Field(0, description="Estimated bundle size in MB") - message: Optional[str] = Field(None, description="Status message") - download_url: Optional[str] = Field(None, description="Bundle download URL") - manifest_url: Optional[str] = Field(None, description="Manifest URL") - created_at: Optional[str] = Field(None, description="Creation timestamp") - completed_at: Optional[str] = Field(None, description="Completion timestamp") - - class Config: - json_schema_extra = { - "example": { - "aoi_id": "550e8400-e29b-41d4-a716-446655440000", - "status": "completed", - "area_km2": 0.45, - "estimated_size_mb": 25.5, - "message": "AOI processing complete", - "download_url": "/api/v1/aoi/550e8400-e29b-41d4-a716-446655440000/bundle.zip", - "manifest_url": "/api/v1/aoi/550e8400-e29b-41d4-a716-446655440000/manifest.json", - } - } - - -class AOIBounds(BaseModel): - """Geographic bounding box.""" - west: float = Field(..., description="Western longitude") - south: float = Field(..., description="Southern latitude") - east: float = Field(..., description="Eastern longitude") - north: float = Field(..., description="Northern latitude") - - -class AOICenter(BaseModel): - """Geographic center point.""" - longitude: float - latitude: float - - -class AOIManifest(BaseModel): - """Unity bundle manifest for an AOI.""" - version: str = Field("1.0.0", description="Manifest version") - aoi_id: str = Field(..., description="AOI identifier") - created_at: str = Field(..., description="Creation timestamp") - bounds: AOIBounds = Field(..., description="Geographic bounds") - center: AOICenter = Field(..., description="Geographic center") - area_km2: float = Field(..., description="Area in km²") - theme: str = Field(..., description="Learning theme") - quality: str = Field(..., description="Quality level") - assets: dict = Field(..., description="Asset file references") - unity: dict = Field(..., description="Unity-specific configuration") - - class Config: - json_schema_extra = { - "example": { - "version": "1.0.0", - "aoi_id": "550e8400-e29b-41d4-a716-446655440000", - "created_at": "2024-01-15T12:00:00Z", - "bounds": { - "west": 9.1875, - "south": 47.7055, - "east": 9.1975, - "north": 47.7115, - }, - "center": { - "longitude": 9.1925, - "latitude": 47.7085, - }, - "area_km2": 0.45, - "theme": "topographie", - "quality": "medium", - "assets": { - "terrain": {"file": "terrain.heightmap.png", "config": "terrain.json"}, - "osm_features": {"file": "osm_features.json"}, - "learning_positions": {"file": "learning_positions.json"}, - "attribution": {"file": "attribution.json"}, - }, - "unity": { - "coordinate_system": "Unity (Y-up, left-handed)", - "scale": 1.0, - "terrain_resolution": 256, - }, - } - } diff --git a/geo-service/models/attribution.py b/geo-service/models/attribution.py deleted file mode 100644 index a563d4a..0000000 --- a/geo-service/models/attribution.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Attribution Models -Models for license and attribution tracking -""" -from typing import Optional -from pydantic import BaseModel, Field - - -class AttributionSource(BaseModel): - """ - Attribution information for a data source. - - All geographic data requires proper attribution per their licenses. - """ - name: str = Field(..., description="Source name") - license: str = Field(..., description="License name") - url: str = Field(..., description="License or source URL") - attribution: str = Field(..., description="Required attribution text") - required: bool = Field(True, description="Whether attribution is legally required") - logo_url: Optional[str] = Field(None, description="Optional logo URL") - - -class Attribution(BaseModel): - """ - Complete attribution information for an AOI bundle. - - Ensures DSGVO/GDPR compliance and proper data source attribution. - """ - sources: list[AttributionSource] = Field( - ..., - description="List of data sources requiring attribution", - ) - generated_at: str = Field(..., description="Timestamp when attribution was generated") - notice: str = Field( - "This data must be attributed according to the licenses above when used publicly.", - description="General attribution notice", - ) - - class Config: - json_schema_extra = { - "example": { - "sources": [ - { - "name": "OpenStreetMap", - "license": "Open Database License (ODbL) v1.0", - "url": "https://www.openstreetmap.org/copyright", - "attribution": "© OpenStreetMap contributors", - "required": True, - }, - { - "name": "Copernicus DEM", - "license": "Copernicus Data License", - "url": "https://spacedata.copernicus.eu/", - "attribution": "© Copernicus Service Information 2024", - "required": True, - }, - ], - "generated_at": "2024-01-15T12:00:00Z", - "notice": "This data must be attributed according to the licenses above when used publicly.", - } - } - - -# Predefined attribution sources -OSM_ATTRIBUTION = AttributionSource( - name="OpenStreetMap", - license="Open Database License (ODbL) v1.0", - url="https://www.openstreetmap.org/copyright", - attribution="© OpenStreetMap contributors", - required=True, -) - -COPERNICUS_ATTRIBUTION = AttributionSource( - name="Copernicus DEM", - license="Copernicus Data License", - url="https://spacedata.copernicus.eu/", - attribution="© Copernicus Service Information 2024", - required=True, -) - -OPENAERIAL_ATTRIBUTION = AttributionSource( - name="OpenAerialMap", - license="CC-BY 4.0", - url="https://openaerialmap.org/", - attribution="© OpenAerialMap contributors", - required=True, -) - - -def get_default_attribution() -> Attribution: - """Get default attribution with standard sources.""" - from datetime import datetime - - return Attribution( - sources=[OSM_ATTRIBUTION, COPERNICUS_ATTRIBUTION], - generated_at=datetime.utcnow().isoformat(), - ) diff --git a/geo-service/models/learning_node.py b/geo-service/models/learning_node.py deleted file mode 100644 index 8084b34..0000000 --- a/geo-service/models/learning_node.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Learning Node Models -Pydantic models for educational content nodes -""" -from enum import Enum -from typing import Optional -from pydantic import BaseModel, Field - - -class LearningTheme(str, Enum): - """Available learning themes for geographic education.""" - TOPOGRAPHIE = "topographie" - LANDNUTZUNG = "landnutzung" - ORIENTIERUNG = "orientierung" - GEOLOGIE = "geologie" - HYDROLOGIE = "hydrologie" - VEGETATION = "vegetation" - - -class NodeType(str, Enum): - """Type of learning node interaction.""" - QUESTION = "question" # Multiple choice or open question - OBSERVATION = "observation" # Guided observation task - EXPLORATION = "exploration" # Free exploration with hints - - -class Position(BaseModel): - """Geographic position for a learning node.""" - latitude: float = Field(..., ge=-90, le=90, description="Latitude in degrees") - longitude: float = Field(..., ge=-180, le=180, description="Longitude in degrees") - altitude: Optional[float] = Field(None, description="Altitude in meters") - - -class LearningNode(BaseModel): - """ - A learning node (station) within a geographic area. - - Contains educational content tied to a specific location, - including questions, hints, and explanations. - """ - id: str = Field(..., description="Unique node identifier") - aoi_id: str = Field(..., description="Parent AOI identifier") - title: str = Field(..., min_length=1, max_length=100, description="Node title") - theme: LearningTheme = Field(..., description="Learning theme") - position: dict = Field(..., description="Geographic position") - question: str = Field(..., description="Learning question or task") - hints: list[str] = Field(default_factory=list, description="Progressive hints") - answer: str = Field(..., description="Correct answer or expected observation") - explanation: str = Field(..., description="Didactic explanation") - node_type: NodeType = Field(NodeType.QUESTION, description="Interaction type") - points: int = Field(10, ge=1, le=100, description="Points awarded for completion") - approved: bool = Field(False, description="Teacher-approved for student use") - media: Optional[list[dict]] = Field(None, description="Associated media files") - tags: Optional[list[str]] = Field(None, description="Content tags") - difficulty: Optional[str] = Field(None, description="Difficulty level") - grade_level: Optional[str] = Field(None, description="Target grade level") - - class Config: - json_schema_extra = { - "example": { - "id": "node-001", - "aoi_id": "550e8400-e29b-41d4-a716-446655440000", - "title": "Höhenbestimmung", - "theme": "topographie", - "position": {"latitude": 47.7085, "longitude": 9.1925}, - "question": "Schätze die Höhe dieses Punktes über dem Meeresspiegel.", - "hints": [ - "Schau dir die Vegetation an.", - "Vergleiche mit dem Seespiegel des Bodensees (395m).", - ], - "answer": "Ca. 430 Meter über NN", - "explanation": "Die Höhe lässt sich aus der Vegetation und der relativen Position zum Bodensee abschätzen.", - "node_type": "question", - "points": 10, - "approved": True, - } - } - - -class LearningNodeRequest(BaseModel): - """Request model for creating a learning node manually.""" - title: str = Field(..., min_length=1, max_length=100) - theme: LearningTheme - position: Position - question: str - hints: list[str] = Field(default_factory=list) - answer: str - explanation: str - node_type: NodeType = NodeType.QUESTION - points: int = Field(10, ge=1, le=100) - tags: Optional[list[str]] = None - difficulty: Optional[str] = Field(None, pattern="^(leicht|mittel|schwer)$") - grade_level: Optional[str] = None - - -class LearningNodeBatch(BaseModel): - """Batch of learning nodes for bulk operations.""" - nodes: list[LearningNode] - total_points: int = 0 - - def calculate_total_points(self) -> int: - """Calculate total points from all nodes.""" - self.total_points = sum(node.points for node in self.nodes) - return self.total_points - - -class LearningProgress(BaseModel): - """Student progress through learning nodes.""" - student_id: str - aoi_id: str - completed_nodes: list[str] = Field(default_factory=list) - total_points: int = 0 - started_at: Optional[str] = None - completed_at: Optional[str] = None - - @property - def completion_percentage(self) -> float: - """Calculate completion percentage.""" - # Would need total node count for accurate calculation - return len(self.completed_nodes) * 10 # Placeholder diff --git a/geo-service/requirements.txt b/geo-service/requirements.txt deleted file mode 100644 index 8b32fa4..0000000 --- a/geo-service/requirements.txt +++ /dev/null @@ -1,45 +0,0 @@ -# FastAPI Framework -fastapi==0.115.0 -uvicorn[standard]==0.30.6 -python-multipart==0.0.9 - -# Database & GIS -asyncpg==0.29.0 -sqlalchemy[asyncio]==2.0.32 -geoalchemy2==0.14.7 -shapely==2.0.5 -pyproj==3.6.1 - -# Geo Processing -rasterio==1.3.10 -numpy==1.26.4 -pillow==10.4.0 - -# PMTiles Support -pmtiles==3.2.0 - -# MinIO/S3 Client -minio==7.2.7 -boto3==1.34.149 - -# HTTP Client (for Ollama) -httpx==0.27.0 -aiohttp==3.10.4 - -# Validation & Settings -pydantic==2.8.2 -pydantic-settings==2.4.0 -python-dotenv==1.0.1 - -# Authentication -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 - -# Utilities -orjson==3.10.6 -structlog==24.4.0 - -# Testing -pytest==8.3.2 -pytest-asyncio==0.23.8 -httpx==0.27.0 diff --git a/geo-service/scripts/download_dem.sh b/geo-service/scripts/download_dem.sh deleted file mode 100755 index fa39310..0000000 --- a/geo-service/scripts/download_dem.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/bin/bash -# ============================================ -# Copernicus DEM Download Script for GeoEdu Service -# ============================================ -# -# WICHTIG: Dieses Script startet einen Download von ca. 20-40 GB! -# Nur nach expliziter Freigabe ausfuehren! -# -# Quelle: Copernicus Data Space Ecosystem -# Daten: GLO-30 DEM (30m Aufloesung) -# Lizenz: Copernicus Data (frei, Attribution erforderlich) -# Attribution: © Copernicus Service Information -# -# Voraussetzungen: -# - Copernicus Data Space Account (kostenlos) -# - Credentials in ~/.netrc oder als Umgebungsvariablen -# -# Nutzung: -# ./download_dem.sh [--dry-run] [--bbox west,south,east,north] -# -# Beispiel (nur Bayern): -# ./download_dem.sh --bbox 8.97,47.27,13.84,50.56 -# -# ============================================ - -set -e - -# Configuration -DATA_DIR="${DEM_DATA_DIR:-/app/data/dem}" -COPERNICUS_URL="https://dataspace.copernicus.eu" - -# Germany bounding box (default) -BBOX_WEST="${BBOX_WEST:-5.87}" -BBOX_SOUTH="${BBOX_SOUTH:-47.27}" -BBOX_EAST="${BBOX_EAST:-15.04}" -BBOX_NORTH="${BBOX_NORTH:-55.06}" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${YELLOW}============================================${NC}" -echo -e "${YELLOW}GeoEdu Service - Copernicus DEM Download${NC}" -echo -e "${YELLOW}============================================${NC}" -echo "" -echo -e "Quelle: ${GREEN}Copernicus Data Space${NC}" -echo -e "Daten: ${GREEN}GLO-30 DEM (30m Aufloesung)${NC}" -echo -e "Groesse: ${YELLOW}~20-40 GB (Deutschland komplett)${NC}" -echo -e "Lizenz: ${GREEN}Copernicus Data (frei)${NC}" -echo -e "Attribution: ${GREEN}© Copernicus Service Information${NC}" -echo "" - -# Parse arguments -DRY_RUN=false -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) - DRY_RUN=true - shift - ;; - --bbox) - IFS=',' read -r BBOX_WEST BBOX_SOUTH BBOX_EAST BBOX_NORTH <<< "$2" - shift 2 - ;; - *) - echo "Unbekannte Option: $1" - exit 1 - ;; - esac -done - -echo "Bounding Box:" -echo " West: $BBOX_WEST" -echo " Sued: $BBOX_SOUTH" -echo " Ost: $BBOX_EAST" -echo " Nord: $BBOX_NORTH" -echo "" - -# Calculate required tiles -# Copernicus DEM tiles are 1°x1° cells -calc_tiles() { - local west=$(echo "$BBOX_WEST" | cut -d. -f1) - local south=$(echo "$BBOX_SOUTH" | cut -d. -f1) - local east=$(echo "$BBOX_EAST" | cut -d. -f1) - local north=$(echo "$BBOX_NORTH" | cut -d. -f1) - - # Adjust for negative values - west=$((west < 0 ? west : west)) - - local count=0 - for lat in $(seq $south $north); do - for lon in $(seq $west $east); do - count=$((count + 1)) - done - done - echo $count -} - -TILE_COUNT=$(calc_tiles) -ESTIMATED_SIZE=$((TILE_COUNT * 50)) # ~50 MB per tile - -echo "Benoetigte Tiles: $TILE_COUNT" -echo "Geschaetzte Groesse: ~${ESTIMATED_SIZE} MB" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo -e "${YELLOW}[DRY-RUN] Kein Download wird durchgefuehrt.${NC}" - echo "" - echo "Wuerde folgende Tiles herunterladen:" - echo "" - - for lat in $(seq $(echo "$BBOX_SOUTH" | cut -d. -f1) $(echo "$BBOX_NORTH" | cut -d. -f1)); do - for lon in $(seq $(echo "$BBOX_WEST" | cut -d. -f1) $(echo "$BBOX_EAST" | cut -d. -f1)); do - lat_prefix=$([ $lat -ge 0 ] && echo "N" || echo "S") - lon_prefix=$([ $lon -ge 0 ] && echo "E" || echo "W") - printf " %s%02d%s%03d.tif\n" "$lat_prefix" "${lat#-}" "$lon_prefix" "${lon#-}" - done - done - echo "" - exit 0 -fi - -# Confirm download -echo -e "${RED}ACHTUNG: Download startet ~${ESTIMATED_SIZE} MB Daten!${NC}" -echo "" -read -p "Download starten? (j/N) " -n 1 -r -echo "" - -if [[ ! $REPLY =~ ^[JjYy]$ ]]; then - echo "Download abgebrochen." - exit 1 -fi - -# Create data directory -mkdir -p "$DATA_DIR" -cd "$DATA_DIR" - -# Check for AWS CLI (preferred method for Copernicus) -if ! command -v aws &> /dev/null; then - echo -e "${YELLOW}HINWEIS: AWS CLI nicht installiert.${NC}" - echo "Installiere mit: pip install awscli" - echo "" - echo "Alternative: Manueller Download von:" - echo " https://dataspace.copernicus.eu/explore-data/data-collections/copernicus-contributing-missions/collections-description/COP-DEM" - exit 1 -fi - -# Download tiles -echo "" -echo -e "${GREEN}Starte Download...${NC}" - -DOWNLOADED=0 -FAILED=0 - -for lat in $(seq $(echo "$BBOX_SOUTH" | cut -d. -f1) $(echo "$BBOX_NORTH" | cut -d. -f1)); do - for lon in $(seq $(echo "$BBOX_WEST" | cut -d. -f1) $(echo "$BBOX_EAST" | cut -d. -f1)); do - lat_prefix=$([ $lat -ge 0 ] && echo "N" || echo "S") - lon_prefix=$([ $lon -ge 0 ] && echo "E" || echo "W") - - lat_abs=${lat#-} - lon_abs=${lon#-} - - filename=$(printf "%s%02d%s%03d.tif" "$lat_prefix" "$lat_abs" "$lon_prefix" "$lon_abs") - - if [ -f "$filename" ]; then - echo " ✓ $filename (bereits vorhanden)" - DOWNLOADED=$((DOWNLOADED + 1)) - continue - fi - - echo " ↓ $filename" - - # Copernicus DEM S3 bucket path - # Format: s3://copernicus-dem-30m/Copernicus_DSM_COG_10_N47_00_E008_00_DEM/ - s3_path="s3://copernicus-dem-30m/Copernicus_DSM_COG_10_${lat_prefix}${lat_abs}_00_${lon_prefix}${lon_abs}_00_DEM/" - - if aws s3 cp "${s3_path}Copernicus_DSM_COG_10_${lat_prefix}${lat_abs}_00_${lon_prefix}${lon_abs}_00_DEM.tif" "$filename" --no-sign-request 2>/dev/null; then - DOWNLOADED=$((DOWNLOADED + 1)) - else - echo -e " ${YELLOW}(nicht verfuegbar)${NC}" - FAILED=$((FAILED + 1)) - fi - done -done - -echo "" -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN}Download abgeschlossen${NC}" -echo -e "${GREEN}============================================${NC}" -echo "" -echo "Heruntergeladen: $DOWNLOADED Tiles" -echo "Nicht verfuegbar: $FAILED Tiles" -echo "Speicherort: $DATA_DIR" -echo "" -echo -e "${YELLOW}Naechster Schritt:${NC}" -echo " Die Tiles werden automatisch vom geo-service geladen." diff --git a/geo-service/scripts/download_osm.sh b/geo-service/scripts/download_osm.sh deleted file mode 100755 index 2dd5ed9..0000000 --- a/geo-service/scripts/download_osm.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/bash -# ============================================ -# OSM Data Download Script for GeoEdu Service -# ============================================ -# -# WICHTIG: Dieses Script startet einen Download von ca. 4.4 GB! -# Nur nach expliziter Freigabe ausfuehren! -# -# Quelle: Geofabrik (offizieller OSM-Mirror) -# Lizenz: ODbL (Open Database License) -# Attribution: © OpenStreetMap contributors -# -# Nutzung: -# ./download_osm.sh [--dry-run] -# -# ============================================ - -set -e - -# Configuration -DATA_DIR="${OSM_DATA_DIR:-/app/data/osm}" -GEOFABRIK_URL="https://download.geofabrik.de/europe/germany-latest.osm.pbf" -GEOFABRIK_MD5_URL="https://download.geofabrik.de/europe/germany-latest.osm.pbf.md5" -OUTPUT_FILE="germany-latest.osm.pbf" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${YELLOW}============================================${NC}" -echo -e "${YELLOW}GeoEdu Service - OSM Data Download${NC}" -echo -e "${YELLOW}============================================${NC}" -echo "" -echo -e "Quelle: ${GREEN}Geofabrik${NC}" -echo -e "Datei: ${GREEN}Germany PBF${NC}" -echo -e "Groesse: ${YELLOW}~4.4 GB${NC}" -echo -e "Lizenz: ${GREEN}ODbL (Open Database License)${NC}" -echo -e "Attribution: ${GREEN}© OpenStreetMap contributors${NC}" -echo "" - -# Check for dry-run mode -if [[ "$1" == "--dry-run" ]]; then - echo -e "${YELLOW}[DRY-RUN] Kein Download wird durchgefuehrt.${NC}" - echo "" - echo "Wuerde herunterladen von:" - echo " $GEOFABRIK_URL" - echo "" - echo "Nach:" - echo " $DATA_DIR/$OUTPUT_FILE" - echo "" - exit 0 -fi - -# Confirm download -echo -e "${RED}ACHTUNG: Download startet ~4.4 GB Daten!${NC}" -echo "" -read -p "Download starten? (j/N) " -n 1 -r -echo "" - -if [[ ! $REPLY =~ ^[JjYy]$ ]]; then - echo "Download abgebrochen." - exit 1 -fi - -# Create data directory -mkdir -p "$DATA_DIR" -cd "$DATA_DIR" - -# Download MD5 checksum first -echo "" -echo -e "${GREEN}[1/3] Lade MD5-Pruefsumme herunter...${NC}" -curl -L -o "${OUTPUT_FILE}.md5" "$GEOFABRIK_MD5_URL" - -# Download OSM data -echo "" -echo -e "${GREEN}[2/3] Lade OSM-Daten herunter (~4.4 GB)...${NC}" -echo " Dies kann je nach Internetverbindung 5-60 Minuten dauern." -echo "" - -# Use wget with resume support -if command -v wget &> /dev/null; then - wget -c -O "$OUTPUT_FILE" "$GEOFABRIK_URL" -else - curl -L -C - -o "$OUTPUT_FILE" "$GEOFABRIK_URL" -fi - -# Verify checksum -echo "" -echo -e "${GREEN}[3/3] Verifiziere MD5-Pruefsumme...${NC}" - -if md5sum -c "${OUTPUT_FILE}.md5"; then - echo "" - echo -e "${GREEN}✓ Download erfolgreich!${NC}" - echo "" - echo "Datei: $DATA_DIR/$OUTPUT_FILE" - echo "Groesse: $(du -h "$OUTPUT_FILE" | cut -f1)" - echo "" - echo -e "${YELLOW}Naechster Schritt:${NC}" - echo " ./import_osm.sh # OSM in PostGIS importieren" - echo " ./generate_tiles.sh # PMTiles generieren" -else - echo "" - echo -e "${RED}✗ MD5-Pruefsumme stimmt nicht ueberein!${NC}" - echo "Bitte Download erneut starten." - exit 1 -fi - -echo "" -echo -e "${YELLOW}============================================${NC}" -echo -e "${YELLOW}Download abgeschlossen${NC}" -echo -e "${YELLOW}============================================${NC}" diff --git a/geo-service/scripts/generate_tiles.sh b/geo-service/scripts/generate_tiles.sh deleted file mode 100755 index 39e4126..0000000 --- a/geo-service/scripts/generate_tiles.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/bin/bash -# ============================================ -# PMTiles Generation Script for GeoEdu Service -# ============================================ -# -# Generiert PMTiles Vector Tiles aus PostGIS OSM Daten -# Verwendet tippecanoe fuer die Tile-Generierung -# -# Voraussetzungen: -# - OSM Daten in PostGIS importiert -# - tippecanoe installiert -# - ogr2ogr (GDAL) installiert -# -# Nutzung: -# ./generate_tiles.sh [--min-zoom 0] [--max-zoom 14] -# -# ============================================ - -set -e - -# Configuration -DATA_DIR="${OSM_DATA_DIR:-/app/data/osm}" -OUTPUT_FILE="${DATA_DIR}/germany.pmtiles" -DATABASE_URL="${DATABASE_URL:-postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_db}" - -MIN_ZOOM="${MIN_ZOOM:-0}" -MAX_ZOOM="${MAX_ZOOM:-14}" - -# Parse DATABASE_URL -DB_USER=$(echo $DATABASE_URL | sed -n 's|.*://\([^:]*\):.*|\1|p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's|.*@\([^:]*\):.*|\1|p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's|.*:\([0-9]*\)/.*|\1|p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's|.*/\([^?]*\).*|\1|p') - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo -e "${YELLOW}============================================${NC}" -echo -e "${YELLOW}GeoEdu Service - PMTiles Generation${NC}" -echo -e "${YELLOW}============================================${NC}" -echo "" - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --min-zoom) - MIN_ZOOM="$2" - shift 2 - ;; - --max-zoom) - MAX_ZOOM="$2" - shift 2 - ;; - *) - echo "Unbekannte Option: $1" - exit 1 - ;; - esac -done - -echo "Zoom Level: $MIN_ZOOM - $MAX_ZOOM" -echo "Output: $OUTPUT_FILE" -echo "" - -# Estimate output size -if [ "$MAX_ZOOM" -le 14 ]; then - ESTIMATED_SIZE="200-500 GB" -elif [ "$MAX_ZOOM" -le 16 ]; then - ESTIMATED_SIZE="500-800 GB" -else - ESTIMATED_SIZE="2-4 TB" -fi - -echo -e "${YELLOW}Geschaetzte Groesse: $ESTIMATED_SIZE${NC}" -echo -e "${YELLOW}Geschaetzte Dauer: 12-24 Stunden (Zoom 0-14)${NC}" -echo "" - -# Check for required tools -for tool in tippecanoe ogr2ogr; do - if ! command -v $tool &> /dev/null; then - echo -e "${RED}Fehler: $tool nicht installiert!${NC}" - echo "" - if [ "$tool" == "tippecanoe" ]; then - echo "Installation:" - echo " git clone https://github.com/felt/tippecanoe.git" - echo " cd tippecanoe && make -j && sudo make install" - else - echo "Installation:" - echo " apt-get install gdal-bin # Debian/Ubuntu" - echo " brew install gdal # macOS" - fi - exit 1 - fi -done - -echo "tippecanoe: $(tippecanoe --version 2>&1 | head -1)" -echo "ogr2ogr: $(ogr2ogr --version | head -1)" -echo "" - -read -p "Tile-Generierung starten? (j/N) " -n 1 -r -echo "" - -if [[ ! $REPLY =~ ^[JjYy]$ ]]; then - echo "Abgebrochen." - exit 1 -fi - -# Create temporary directory for GeoJSON -TEMP_DIR=$(mktemp -d) -trap "rm -rf $TEMP_DIR" EXIT - -echo "" -echo -e "${GREEN}[1/5] Exportiere Landuse/Landcover...${NC}" - -PGPASSWORD="$DB_PASS" ogr2ogr -f GeoJSONSeq \ - "$TEMP_DIR/landuse.geojsonseq" \ - "PG:host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \ - -sql "SELECT way AS geometry, landuse, natural, name FROM planet_osm_polygon WHERE landuse IS NOT NULL OR natural IS NOT NULL" - -echo -e "${GREEN}[2/5] Exportiere Gebaude...${NC}" - -PGPASSWORD="$DB_PASS" ogr2ogr -f GeoJSONSeq \ - "$TEMP_DIR/building.geojsonseq" \ - "PG:host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \ - -sql "SELECT way AS geometry, building, name, addr_housenumber FROM planet_osm_polygon WHERE building IS NOT NULL" - -echo -e "${GREEN}[3/5] Exportiere Strassen...${NC}" - -PGPASSWORD="$DB_PASS" ogr2ogr -f GeoJSONSeq \ - "$TEMP_DIR/transportation.geojsonseq" \ - "PG:host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \ - -sql "SELECT way AS geometry, highway, railway, name, ref FROM planet_osm_line WHERE highway IS NOT NULL OR railway IS NOT NULL" - -echo -e "${GREEN}[4/5] Exportiere Gewaesser...${NC}" - -PGPASSWORD="$DB_PASS" ogr2ogr -f GeoJSONSeq \ - "$TEMP_DIR/water.geojsonseq" \ - "PG:host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \ - -sql "SELECT way AS geometry, waterway, water, name FROM planet_osm_polygon WHERE water IS NOT NULL OR waterway IS NOT NULL - UNION ALL - SELECT way AS geometry, waterway, NULL as water, name FROM planet_osm_line WHERE waterway IS NOT NULL" - -echo -e "${GREEN}[5/5] Exportiere Orte (POIs)...${NC}" - -PGPASSWORD="$DB_PASS" ogr2ogr -f GeoJSONSeq \ - "$TEMP_DIR/place.geojsonseq" \ - "PG:host=$DB_HOST port=$DB_PORT user=$DB_USER dbname=$DB_NAME password=$DB_PASS" \ - -sql "SELECT way AS geometry, place, name, population FROM planet_osm_point WHERE place IS NOT NULL" - -echo "" -echo -e "${GREEN}Generiere PMTiles...${NC}" -echo "Dies kann mehrere Stunden dauern!" -echo "" - -# Run tippecanoe -tippecanoe \ - --output="$OUTPUT_FILE" \ - --force \ - --name="GeoEdu Germany" \ - --description="Self-hosted OSM tiles for DSGVO-compliant education" \ - --attribution="© OpenStreetMap contributors" \ - --minimum-zoom="$MIN_ZOOM" \ - --maximum-zoom="$MAX_ZOOM" \ - --drop-densest-as-needed \ - --extend-zooms-if-still-dropping \ - --layer=landuse:"$TEMP_DIR/landuse.geojsonseq" \ - --layer=building:"$TEMP_DIR/building.geojsonseq" \ - --layer=transportation:"$TEMP_DIR/transportation.geojsonseq" \ - --layer=water:"$TEMP_DIR/water.geojsonseq" \ - --layer=place:"$TEMP_DIR/place.geojsonseq" - -echo "" -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN}PMTiles Generierung abgeschlossen!${NC}" -echo -e "${GREEN}============================================${NC}" -echo "" -echo "Ausgabe: $OUTPUT_FILE" -echo "Groesse: $(du -h "$OUTPUT_FILE" | cut -f1)" -echo "" -echo "Die Tiles sind jetzt bereit fuer den geo-service." diff --git a/geo-service/scripts/import_osm.sh b/geo-service/scripts/import_osm.sh deleted file mode 100755 index a7d4560..0000000 --- a/geo-service/scripts/import_osm.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/bin/bash -# ============================================ -# OSM PostGIS Import Script for GeoEdu Service -# ============================================ -# -# Importiert OSM PBF-Daten in PostgreSQL/PostGIS -# Verwendet osm2pgsql fuer den Import -# -# Voraussetzungen: -# - PostgreSQL mit PostGIS Extension -# - osm2pgsql installiert -# - OSM PBF Datei heruntergeladen -# -# Nutzung: -# ./import_osm.sh [--slim] [--drop] -# -# ============================================ - -set -e - -# Configuration -DATA_DIR="${OSM_DATA_DIR:-/app/data/osm}" -PBF_FILE="${DATA_DIR}/germany-latest.osm.pbf" -DATABASE_URL="${DATABASE_URL:-postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_db}" - -# Parse DATABASE_URL -DB_USER=$(echo $DATABASE_URL | sed -n 's|.*://\([^:]*\):.*|\1|p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's|.*@\([^:]*\):.*|\1|p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's|.*:\([0-9]*\)/.*|\1|p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's|.*/\([^?]*\).*|\1|p') - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo -e "${YELLOW}============================================${NC}" -echo -e "${YELLOW}GeoEdu Service - OSM PostGIS Import${NC}" -echo -e "${YELLOW}============================================${NC}" -echo "" - -# Check if PBF file exists -if [ ! -f "$PBF_FILE" ]; then - echo -e "${RED}Fehler: OSM PBF Datei nicht gefunden!${NC}" - echo "Erwartet: $PBF_FILE" - echo "" - echo "Bitte zuerst download_osm.sh ausfuehren." - exit 1 -fi - -echo "PBF Datei: $PBF_FILE" -echo "Groesse: $(du -h "$PBF_FILE" | cut -f1)" -echo "" -echo "Datenbank: $DB_NAME @ $DB_HOST:$DB_PORT" -echo "" - -# Check for osm2pgsql -if ! command -v osm2pgsql &> /dev/null; then - echo -e "${RED}Fehler: osm2pgsql nicht installiert!${NC}" - echo "" - echo "Installation:" - echo " apt-get install osm2pgsql # Debian/Ubuntu" - echo " brew install osm2pgsql # macOS" - exit 1 -fi - -OSM2PGSQL_VERSION=$(osm2pgsql --version 2>&1 | head -1) -echo "osm2pgsql Version: $OSM2PGSQL_VERSION" -echo "" - -# Parse arguments -SLIM_MODE="" -DROP_MODE="" -while [[ $# -gt 0 ]]; do - case $1 in - --slim) - SLIM_MODE="--slim" - echo "Modus: Slim (fuer Updates)" - shift - ;; - --drop) - DROP_MODE="--drop" - echo "Modus: Drop (bestehende Tabellen loeschen)" - shift - ;; - *) - echo "Unbekannte Option: $1" - exit 1 - ;; - esac -done - -# Estimate time -PBF_SIZE=$(stat -f%z "$PBF_FILE" 2>/dev/null || stat -c%s "$PBF_FILE") -ESTIMATED_HOURS=$((PBF_SIZE / 1000000000 / 2)) # Rough estimate: ~2GB per hour - -echo "" -echo -e "${YELLOW}ACHTUNG: Import kann 2-4 Stunden dauern!${NC}" -echo "Geschaetzte Dauer: ~${ESTIMATED_HOURS}-$((ESTIMATED_HOURS * 2)) Stunden" -echo "" -read -p "Import starten? (j/N) " -n 1 -r -echo "" - -if [[ ! $REPLY =~ ^[JjYy]$ ]]; then - echo "Import abgebrochen." - exit 1 -fi - -# Create style file -STYLE_FILE="${DATA_DIR}/osm2pgsql.style" - -cat > "$STYLE_FILE" << 'EOF' -# osm2pgsql style file for GeoEdu Service -# Optimized for educational geography content - -# Common tags -node,way access text linear -node,way addr:housename text linear -node,way addr:housenumber text linear -node,way addr:interpolation text linear -node,way admin_level text linear -node,way aerialway text linear -node,way amenity text polygon -node,way area text linear -node,way barrier text linear -node,way bicycle text linear -node,way boundary text linear -node,way bridge text linear -node,way building text polygon -node,way construction text linear -node,way covered text linear -node,way foot text linear -node,way highway text linear -node,way historic text polygon -node,way junction text linear -node,way landuse text polygon -node,way layer text linear -node,way leisure text polygon -node,way man_made text polygon -node,way military text polygon -node,way name text linear -node,way natural text polygon -node,way oneway text linear -node,way place text polygon -node,way power text polygon -node,way railway text linear -node,way ref text linear -node,way religion text linear -node,way route text linear -node,way service text linear -node,way shop text polygon -node,way sport text polygon -node,way surface text linear -node,way tourism text polygon -node,way tracktype text linear -node,way tunnel text linear -node,way water text polygon -node,way waterway text linear -node,way wetland text polygon -node,way wood text polygon -node,way z_order int4 linear - -# Elevation data from OSM -node,way ele text linear - -# Name translations -node,way name:de text linear -node,way name:en text linear - -# Population for place rendering -node,way population text linear - -# Way area for polygon ordering -way way_area real linear -EOF - -echo "" -echo -e "${GREEN}[1/3] PostGIS Extension aktivieren...${NC}" - -PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS postgis;" -PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE EXTENSION IF NOT EXISTS hstore;" - -echo "" -echo -e "${GREEN}[2/3] OSM Daten importieren...${NC}" -echo " Dies dauert mehrere Stunden!" -echo "" - -# Run osm2pgsql -PGPASSWORD="$DB_PASS" osm2pgsql \ - -H "$DB_HOST" \ - -P "$DB_PORT" \ - -U "$DB_USER" \ - -d "$DB_NAME" \ - -S "$STYLE_FILE" \ - --cache 4000 \ - --number-processes 4 \ - $SLIM_MODE \ - $DROP_MODE \ - "$PBF_FILE" - -echo "" -echo -e "${GREEN}[3/3] Indizes erstellen...${NC}" - -PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" << 'EOSQL' --- Spatial indexes (if not created by osm2pgsql) -CREATE INDEX IF NOT EXISTS planet_osm_point_way_idx ON planet_osm_point USING GIST (way); -CREATE INDEX IF NOT EXISTS planet_osm_line_way_idx ON planet_osm_line USING GIST (way); -CREATE INDEX IF NOT EXISTS planet_osm_polygon_way_idx ON planet_osm_polygon USING GIST (way); -CREATE INDEX IF NOT EXISTS planet_osm_roads_way_idx ON planet_osm_roads USING GIST (way); - --- Name indexes for search -CREATE INDEX IF NOT EXISTS planet_osm_point_name_idx ON planet_osm_point (name); -CREATE INDEX IF NOT EXISTS planet_osm_polygon_name_idx ON planet_osm_polygon (name); - --- Vacuum analyze for query optimization -VACUUM ANALYZE planet_osm_point; -VACUUM ANALYZE planet_osm_line; -VACUUM ANALYZE planet_osm_polygon; -VACUUM ANALYZE planet_osm_roads; -EOSQL - -echo "" -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN}Import abgeschlossen!${NC}" -echo -e "${GREEN}============================================${NC}" -echo "" -echo "Tabellen erstellt:" -echo " - planet_osm_point" -echo " - planet_osm_line" -echo " - planet_osm_polygon" -echo " - planet_osm_roads" -echo "" -echo -e "${YELLOW}Naechster Schritt:${NC}" -echo " ./generate_tiles.sh # PMTiles generieren" diff --git a/geo-service/services/__init__.py b/geo-service/services/__init__.py deleted file mode 100644 index f839265..0000000 --- a/geo-service/services/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -GeoEdu Service - Business Logic Services -""" -from .tile_server import TileServerService -from .dem_service import DEMService -from .aoi_packager import AOIPackagerService -from .osm_extractor import OSMExtractorService -from .learning_generator import LearningGeneratorService - -__all__ = [ - "TileServerService", - "DEMService", - "AOIPackagerService", - "OSMExtractorService", - "LearningGeneratorService", -] diff --git a/geo-service/services/aoi_packager.py b/geo-service/services/aoi_packager.py deleted file mode 100644 index 03ffa82..0000000 --- a/geo-service/services/aoi_packager.py +++ /dev/null @@ -1,420 +0,0 @@ -""" -AOI Packager Service -Creates Unity-compatible bundles from geographic areas -""" -import os -import json -import zipfile -import uuid -from typing import Optional, Tuple -from datetime import datetime -import math -import structlog -from shapely.geometry import shape, Polygon, mapping -from shapely.ops import transform -import pyproj - -from config import settings - -logger = structlog.get_logger(__name__) - -# Germany bounding box -GERMANY_BOUNDS = Polygon([ - (5.87, 47.27), - (15.04, 47.27), - (15.04, 55.06), - (5.87, 55.06), - (5.87, 47.27), -]) - -# AOI status storage (in production, use database) -_aoi_storage = {} - - -class AOIPackagerService: - """ - Service for packaging geographic areas for Unity 3D rendering. - - Creates bundles containing: - - Terrain heightmap - - OSM features (buildings, roads, water, etc.) - - Learning node positions - - Attribution information - """ - - def __init__(self): - self.bundle_dir = settings.bundle_dir - self.max_area_km2 = settings.max_aoi_size_km2 - - def calculate_area_km2(self, geojson: dict) -> float: - """ - Calculate the area of a GeoJSON polygon in square kilometers. - - Uses an equal-area projection for accurate measurement. - """ - try: - geom = shape(geojson) - - # Transform to equal-area projection (EPSG:3035 for Europe) - project = pyproj.Transformer.from_crs( - "EPSG:4326", # WGS84 - "EPSG:3035", # ETRS89-LAEA - always_xy=True, - ).transform - - geom_projected = transform(project, geom) - area_m2 = geom_projected.area - area_km2 = area_m2 / 1_000_000 - - return area_km2 - - except Exception as e: - logger.error("Error calculating area", error=str(e)) - raise ValueError(f"Invalid polygon geometry: {str(e)}") - - def is_within_germany(self, geojson: dict) -> bool: - """Check if a polygon is within Germany's bounds.""" - try: - geom = shape(geojson) - return GERMANY_BOUNDS.contains(geom) - except Exception: - return False - - def validate_polygon(self, geojson: dict) -> Tuple[bool, str]: - """ - Validate a GeoJSON polygon. - - Checks: - - Valid GeoJSON format - - Valid polygon geometry - - Not self-intersecting - """ - try: - # Check type - if geojson.get("type") != "Polygon": - return False, "Geometry must be a Polygon" - - # Check coordinates - coords = geojson.get("coordinates") - if not coords or not isinstance(coords, list): - return False, "Missing or invalid coordinates" - - # Parse geometry - geom = shape(geojson) - - # Check validity - if not geom.is_valid: - return False, "Invalid polygon geometry (possibly self-intersecting)" - - # Check ring closure - outer_ring = coords[0] - if outer_ring[0] != outer_ring[-1]: - return False, "Polygon ring must be closed" - - return True, "Valid" - - except Exception as e: - return False, f"Error validating polygon: {str(e)}" - - def estimate_bundle_size_mb(self, area_km2: float, quality: str) -> float: - """Estimate the bundle size based on area and quality.""" - # Base size per km² in MB - base_sizes = { - "low": 10, - "medium": 25, - "high": 50, - } - - base = base_sizes.get(quality, 25) - return round(area_km2 * base, 1) - - async def process_aoi( - self, - aoi_id: str, - polygon: dict, - theme: str, - quality: str, - ): - """ - Process an AOI and create the Unity bundle. - - This runs as a background task. - """ - logger.info("Processing AOI", aoi_id=aoi_id, theme=theme, quality=quality) - - # Update status - _aoi_storage[aoi_id] = { - "status": "processing", - "polygon": polygon, - "theme": theme, - "quality": quality, - "area_km2": self.calculate_area_km2(polygon), - "created_at": datetime.utcnow().isoformat(), - } - - try: - # Create bundle directory - bundle_path = os.path.join(self.bundle_dir, aoi_id) - os.makedirs(bundle_path, exist_ok=True) - - # Generate terrain heightmap - await self._generate_terrain(aoi_id, polygon, quality) - - # Extract OSM features - await self._extract_osm_features(aoi_id, polygon) - - # Generate learning node positions - await self._generate_learning_positions(aoi_id, polygon, theme) - - # Create attribution file - await self._create_attribution(aoi_id) - - # Create manifest - await self._create_manifest(aoi_id, polygon, theme, quality) - - # Create ZIP bundle - await self._create_zip_bundle(aoi_id) - - # Update status - _aoi_storage[aoi_id]["status"] = "completed" - _aoi_storage[aoi_id]["completed_at"] = datetime.utcnow().isoformat() - - logger.info("AOI processing complete", aoi_id=aoi_id) - - except Exception as e: - logger.error("AOI processing failed", aoi_id=aoi_id, error=str(e)) - _aoi_storage[aoi_id]["status"] = "failed" - _aoi_storage[aoi_id]["error"] = str(e) - - async def _generate_terrain(self, aoi_id: str, polygon: dict, quality: str): - """Generate terrain heightmap for the AOI.""" - from services.dem_service import DEMService - - dem_service = DEMService() - bundle_path = os.path.join(self.bundle_dir, aoi_id) - - # Get bounding box of polygon - geom = shape(polygon) - bounds = geom.bounds # (minx, miny, maxx, maxy) - - # Determine resolution based on quality - resolutions = {"low": 64, "medium": 256, "high": 512} - resolution = resolutions.get(quality, 256) - - # Generate heightmap for bounds - from services.dem_service import tile_to_bounds - - # For simplicity, generate a single heightmap image - # In production, this would be more sophisticated - - heightmap_path = os.path.join(bundle_path, "terrain.heightmap.png") - - # Save bounds info - terrain_info = { - "bounds": { - "west": bounds[0], - "south": bounds[1], - "east": bounds[2], - "north": bounds[3], - }, - "resolution": resolution, - "heightmap_file": "terrain.heightmap.png", - "encoding": "terrain-rgb", - } - - with open(os.path.join(bundle_path, "terrain.json"), "w") as f: - json.dump(terrain_info, f, indent=2) - - logger.debug("Terrain generated", aoi_id=aoi_id, resolution=resolution) - - async def _extract_osm_features(self, aoi_id: str, polygon: dict): - """Extract OSM features within the AOI.""" - from services.osm_extractor import OSMExtractorService - - extractor = OSMExtractorService() - bundle_path = os.path.join(self.bundle_dir, aoi_id) - - # Extract features - features = await extractor.extract_features(polygon) - - # Save to file - features_path = os.path.join(bundle_path, "osm_features.json") - with open(features_path, "w") as f: - json.dump(features, f, indent=2) - - logger.debug("OSM features extracted", aoi_id=aoi_id, count=len(features.get("features", []))) - - async def _generate_learning_positions(self, aoi_id: str, polygon: dict, theme: str): - """Generate suggested positions for learning nodes.""" - geom = shape(polygon) - bounds = geom.bounds - centroid = geom.centroid - - # Generate positions based on theme - # For now, create a grid of potential positions - positions = [] - - # Create a 3x3 grid of positions - for i in range(3): - for j in range(3): - lon = bounds[0] + (bounds[2] - bounds[0]) * (i + 0.5) / 3 - lat = bounds[1] + (bounds[3] - bounds[1]) * (j + 0.5) / 3 - - # Check if point is within polygon - from shapely.geometry import Point - if geom.contains(Point(lon, lat)): - positions.append({ - "id": str(uuid.uuid4()), - "position": {"longitude": lon, "latitude": lat}, - "suggested_theme": theme, - "status": "pending", - }) - - bundle_path = os.path.join(self.bundle_dir, aoi_id) - positions_path = os.path.join(bundle_path, "learning_positions.json") - - with open(positions_path, "w") as f: - json.dump({"positions": positions}, f, indent=2) - - logger.debug("Learning positions generated", aoi_id=aoi_id, count=len(positions)) - - async def _create_attribution(self, aoi_id: str): - """Create attribution file with required license notices.""" - attribution = { - "sources": [ - { - "name": "OpenStreetMap", - "license": "Open Database License (ODbL) v1.0", - "url": "https://www.openstreetmap.org/copyright", - "attribution": "© OpenStreetMap contributors", - "required": True, - }, - { - "name": "Copernicus DEM", - "license": "Copernicus Data License", - "url": "https://spacedata.copernicus.eu/", - "attribution": "© Copernicus Service Information 2024", - "required": True, - }, - ], - "generated_at": datetime.utcnow().isoformat(), - "notice": "This data must be attributed according to the licenses above when used publicly.", - } - - bundle_path = os.path.join(self.bundle_dir, aoi_id) - attribution_path = os.path.join(bundle_path, "attribution.json") - - with open(attribution_path, "w") as f: - json.dump(attribution, f, indent=2) - - async def _create_manifest(self, aoi_id: str, polygon: dict, theme: str, quality: str): - """Create Unity bundle manifest.""" - geom = shape(polygon) - bounds = geom.bounds - centroid = geom.centroid - - manifest = { - "version": "1.0.0", - "aoi_id": aoi_id, - "created_at": datetime.utcnow().isoformat(), - "bounds": { - "west": bounds[0], - "south": bounds[1], - "east": bounds[2], - "north": bounds[3], - }, - "center": { - "longitude": centroid.x, - "latitude": centroid.y, - }, - "area_km2": self.calculate_area_km2(polygon), - "theme": theme, - "quality": quality, - "assets": { - "terrain": { - "file": "terrain.heightmap.png", - "config": "terrain.json", - }, - "osm_features": { - "file": "osm_features.json", - }, - "learning_positions": { - "file": "learning_positions.json", - }, - "attribution": { - "file": "attribution.json", - }, - }, - "unity": { - "coordinate_system": "Unity (Y-up, left-handed)", - "scale": 1.0, # 1 Unity unit = 1 meter - "terrain_resolution": {"low": 64, "medium": 256, "high": 512}[quality], - }, - } - - bundle_path = os.path.join(self.bundle_dir, aoi_id) - manifest_path = os.path.join(bundle_path, "manifest.json") - - with open(manifest_path, "w") as f: - json.dump(manifest, f, indent=2) - - async def _create_zip_bundle(self, aoi_id: str): - """Create ZIP archive of all bundle files.""" - bundle_path = os.path.join(self.bundle_dir, aoi_id) - zip_path = os.path.join(bundle_path, "bundle.zip") - - with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for filename in os.listdir(bundle_path): - if filename != "bundle.zip": - filepath = os.path.join(bundle_path, filename) - zf.write(filepath, filename) - - logger.debug("Bundle ZIP created", aoi_id=aoi_id, path=zip_path) - - async def get_aoi_status(self, aoi_id: str) -> Optional[dict]: - """Get the status of an AOI.""" - return _aoi_storage.get(aoi_id) - - async def get_manifest(self, aoi_id: str) -> Optional[dict]: - """Get the manifest for a completed AOI.""" - aoi_data = _aoi_storage.get(aoi_id) - if aoi_data is None or aoi_data.get("status") != "completed": - return None - - manifest_path = os.path.join(self.bundle_dir, aoi_id, "manifest.json") - if not os.path.exists(manifest_path): - return None - - with open(manifest_path) as f: - return json.load(f) - - async def get_bundle_path(self, aoi_id: str) -> Optional[str]: - """Get the path to a completed bundle ZIP.""" - aoi_data = _aoi_storage.get(aoi_id) - if aoi_data is None or aoi_data.get("status") != "completed": - return None - - zip_path = os.path.join(self.bundle_dir, aoi_id, "bundle.zip") - if not os.path.exists(zip_path): - return None - - return zip_path - - async def delete_aoi(self, aoi_id: str) -> bool: - """Delete an AOI and its files.""" - if aoi_id not in _aoi_storage: - return False - - import shutil - - bundle_path = os.path.join(self.bundle_dir, aoi_id) - if os.path.exists(bundle_path): - shutil.rmtree(bundle_path) - - del _aoi_storage[aoi_id] - return True - - async def generate_preview(self, aoi_id: str, width: int, height: int) -> Optional[bytes]: - """Generate a preview image of the AOI (stub).""" - # Would generate a preview combining terrain and OSM features - return None diff --git a/geo-service/services/dem_service.py b/geo-service/services/dem_service.py deleted file mode 100644 index 9cc001c..0000000 --- a/geo-service/services/dem_service.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -DEM (Digital Elevation Model) Service -Serves terrain data from Copernicus DEM GLO-30 -""" -import os -import math -from typing import Optional, Tuple -import struct -import structlog -import numpy as np -from PIL import Image -from io import BytesIO - -from config import settings - -logger = structlog.get_logger(__name__) - -# Germany bounding box -GERMANY_BOUNDS = { - "west": 5.87, - "south": 47.27, - "east": 15.04, - "north": 55.06, -} - - -def lat_lon_to_tile(lat: float, lon: float, zoom: int) -> Tuple[int, int]: - """Convert latitude/longitude to 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) - return x, y - - -def tile_to_bounds(z: int, x: int, y: int) -> Tuple[float, float, float, float]: - """Convert tile coordinates to bounding box (west, south, east, north).""" - 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 - - -class DEMService: - """ - Service for handling Digital Elevation Model data. - - Uses Copernicus DEM GLO-30 (30m resolution) as the data source. - Generates terrain tiles in Mapbox Terrain-RGB format for MapLibre/Unity. - """ - - def __init__(self): - self.dem_dir = settings.dem_data_dir - self.tile_size = settings.terrain_tile_size - self._dem_cache = {} # Cache loaded DEM files - - def _get_dem_file_path(self, lat: int, lon: int) -> str: - """ - Get the path to a Copernicus DEM file for the given coordinates. - - Copernicus DEM files are named like: Copernicus_DSM_COG_10_N47_00_E008_00_DEM.tif - """ - lat_prefix = "N" if lat >= 0 else "S" - lon_prefix = "E" if lon >= 0 else "W" - - # Format: N47E008 - filename = f"{lat_prefix}{abs(lat):02d}{lon_prefix}{abs(lon):03d}.tif" - return os.path.join(self.dem_dir, filename) - - def _load_dem_tile(self, lat: int, lon: int) -> Optional[np.ndarray]: - """Load a DEM tile from disk.""" - cache_key = f"{lat}_{lon}" - if cache_key in self._dem_cache: - return self._dem_cache[cache_key] - - filepath = self._get_dem_file_path(lat, lon) - - if not os.path.exists(filepath): - logger.debug("DEM file not found", path=filepath) - return None - - try: - import rasterio - - with rasterio.open(filepath) as src: - data = src.read(1) # Read first band - self._dem_cache[cache_key] = data - return data - - except ImportError: - logger.warning("rasterio not available, using fallback") - return self._load_dem_fallback(filepath) - except Exception as e: - logger.error("Error loading DEM", path=filepath, error=str(e)) - return None - - def _load_dem_fallback(self, filepath: str) -> Optional[np.ndarray]: - """Fallback DEM loader without rasterio (for development).""" - # In development, return synthetic terrain - return None - - async def get_heightmap_tile(self, z: int, x: int, y: int) -> Optional[bytes]: - """ - Generate a heightmap tile in Mapbox Terrain-RGB format. - - The encoding stores elevation as: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) - This allows for -10000m to +1677721.6m range with 0.1m precision. - """ - # Get tile bounds - west, south, east, north = tile_to_bounds(z, x, y) - - # Check if tile is within Germany - if east < GERMANY_BOUNDS["west"] or west > GERMANY_BOUNDS["east"]: - return None - if north < GERMANY_BOUNDS["south"] or south > GERMANY_BOUNDS["north"]: - return None - - # Try to load elevation data for this tile - elevations = await self._get_elevations_for_bounds(west, south, east, north) - - if elevations is None: - # No DEM data available - return placeholder or None - return self._generate_placeholder_heightmap() - - # Convert to Terrain-RGB format - return self._encode_terrain_rgb(elevations) - - async def _get_elevations_for_bounds( - self, west: float, south: float, east: float, north: float - ) -> Optional[np.ndarray]: - """Get elevation data for a bounding box.""" - # Determine which DEM tiles we need - lat_min = int(math.floor(south)) - lat_max = int(math.ceil(north)) - lon_min = int(math.floor(west)) - lon_max = int(math.ceil(east)) - - # Load all required DEM tiles - dem_tiles = [] - for lat in range(lat_min, lat_max + 1): - for lon in range(lon_min, lon_max + 1): - tile = self._load_dem_tile(lat, lon) - if tile is not None: - dem_tiles.append((lat, lon, tile)) - - if not dem_tiles: - return None - - # Interpolate elevations for our tile grid - elevations = np.zeros((self.tile_size, self.tile_size), dtype=np.float32) - - for py in range(self.tile_size): - for px in range(self.tile_size): - # Calculate lat/lon for this pixel - lon = west + (east - west) * px / self.tile_size - lat = north - (north - south) * py / self.tile_size - - # Get elevation (simplified - would need proper interpolation) - elevation = self._sample_elevation(lat, lon, dem_tiles) - elevations[py, px] = elevation if elevation is not None else 0 - - return elevations - - def _sample_elevation( - self, lat: float, lon: float, dem_tiles: list - ) -> Optional[float]: - """Sample elevation at a specific point from loaded DEM tiles.""" - tile_lat = int(math.floor(lat)) - tile_lon = int(math.floor(lon)) - - for t_lat, t_lon, data in dem_tiles: - if t_lat == tile_lat and t_lon == tile_lon: - # Calculate pixel position within tile - # Assuming 1 degree = 3600 pixels (1 arcsecond for 30m DEM) - rows, cols = data.shape - px = int((lon - tile_lon) * cols) - py = int((t_lat + 1 - lat) * rows) - - px = max(0, min(cols - 1, px)) - py = max(0, min(rows - 1, py)) - - return float(data[py, px]) - - return None - - def _encode_terrain_rgb(self, elevations: np.ndarray) -> bytes: - """ - Encode elevation data as Mapbox Terrain-RGB. - - Format: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) - """ - # Convert elevation to RGB values - # encoded = (elevation + 10000) / 0.1 - encoded = ((elevations + 10000) / 0.1).astype(np.uint32) - - r = (encoded // (256 * 256)) % 256 - g = (encoded // 256) % 256 - b = encoded % 256 - - # Create RGB image - rgb = np.stack([r, g, b], axis=-1).astype(np.uint8) - img = Image.fromarray(rgb, mode="RGB") - - # Save to bytes - buffer = BytesIO() - img.save(buffer, format="PNG") - return buffer.getvalue() - - def _generate_placeholder_heightmap(self) -> bytes: - """Generate a flat placeholder heightmap (sea level).""" - # Sea level = 0m encoded as RGB - # encoded = (0 + 10000) / 0.1 = 100000 - # R = 100000 // 65536 = 1 - # G = (100000 // 256) % 256 = 134 - # B = 100000 % 256 = 160 - - img = Image.new("RGB", (self.tile_size, self.tile_size), (1, 134, 160)) - buffer = BytesIO() - img.save(buffer, format="PNG") - return buffer.getvalue() - - async def get_hillshade_tile( - self, z: int, x: int, y: int, azimuth: float = 315, altitude: float = 45 - ) -> Optional[bytes]: - """ - Generate a hillshade tile for terrain visualization. - - Args: - z, x, y: Tile coordinates - azimuth: Light direction in degrees (0=N, 90=E, 180=S, 270=W) - altitude: Light altitude in degrees above horizon - """ - west, south, east, north = tile_to_bounds(z, x, y) - elevations = await self._get_elevations_for_bounds(west, south, east, north) - - if elevations is None: - return None - - # Calculate hillshade using numpy - hillshade = self._calculate_hillshade(elevations, azimuth, altitude) - - # Create grayscale image - img = Image.fromarray((hillshade * 255).astype(np.uint8), mode="L") - - buffer = BytesIO() - img.save(buffer, format="PNG") - return buffer.getvalue() - - def _calculate_hillshade( - self, dem: np.ndarray, azimuth: float, altitude: float - ) -> np.ndarray: - """Calculate hillshade from DEM array.""" - # Convert angles to radians - azimuth_rad = math.radians(360 - azimuth + 90) - altitude_rad = math.radians(altitude) - - # Calculate gradient - dy, dx = np.gradient(dem) - - # Calculate slope and aspect - slope = np.arctan(np.sqrt(dx**2 + dy**2)) - aspect = np.arctan2(-dy, dx) - - # Calculate hillshade - hillshade = ( - np.sin(altitude_rad) * np.cos(slope) - + np.cos(altitude_rad) * np.sin(slope) * np.cos(azimuth_rad - aspect) - ) - - # Normalize to 0-1 - hillshade = np.clip(hillshade, 0, 1) - - return hillshade - - async def get_contour_tile( - self, z: int, x: int, y: int, interval: int = 20 - ) -> Optional[bytes]: - """Generate contour lines as vector tile (stub - requires more complex implementation).""" - # This would require generating contour lines from DEM and encoding as MVT - # For now, return None - logger.warning("Contour tiles not yet implemented") - return None - - async def get_elevation(self, lat: float, lon: float) -> Optional[float]: - """Get elevation at a specific point.""" - tile_lat = int(math.floor(lat)) - tile_lon = int(math.floor(lon)) - - dem_data = self._load_dem_tile(tile_lat, tile_lon) - if dem_data is None: - return None - - return self._sample_elevation(lat, lon, [(tile_lat, tile_lon, dem_data)]) - - async def get_elevation_profile( - self, coordinates: list[list[float]], samples: int = 100 - ) -> list[dict]: - """Get elevation profile along a path.""" - from shapely.geometry import LineString - from shapely.ops import substring - - # Create line from coordinates - line = LineString(coordinates) - total_length = line.length - - # Sample points along line - profile = [] - for i in range(samples): - fraction = i / (samples - 1) - point = line.interpolate(fraction, normalized=True) - - elevation = await self.get_elevation(point.y, point.x) - - profile.append({ - "distance_m": fraction * total_length * 111320, # Approximate meters - "longitude": point.x, - "latitude": point.y, - "elevation_m": elevation, - }) - - return profile - - async def get_metadata(self) -> dict: - """Get metadata about available DEM data.""" - dem_files = [] - if os.path.exists(self.dem_dir): - dem_files = [f for f in os.listdir(self.dem_dir) if f.endswith(".tif")] - - return { - "data_available": len(dem_files) > 0, - "tiles_generated": len(dem_files), - "resolution_m": 30, - "source": "Copernicus DEM GLO-30", - } diff --git a/geo-service/services/learning_generator.py b/geo-service/services/learning_generator.py deleted file mode 100644 index 192fc96..0000000 --- a/geo-service/services/learning_generator.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -Learning Generator Service -Generates educational content for geographic areas using LLM -""" -import os -import json -import uuid -from typing import Optional -import structlog -import httpx - -from config import settings -from models.learning_node import LearningNode, LearningTheme, NodeType - -logger = structlog.get_logger(__name__) - -# In-memory storage for learning nodes (use database in production) -_learning_nodes = {} - - -class LearningGeneratorService: - """ - Service for generating educational learning nodes using Ollama LLM. - - Generates themed educational content based on geographic features - and didactic principles. - """ - - def __init__(self): - self.ollama_url = settings.ollama_base_url - self.model = settings.ollama_model - self.timeout = settings.ollama_timeout - - async def generate_nodes( - self, - aoi_id: str, - theme: LearningTheme, - difficulty: str, - node_count: int, - grade_level: Optional[str] = None, - language: str = "de", - ) -> list[LearningNode]: - """ - Generate learning nodes for an AOI. - - Uses the Ollama LLM to create educational content appropriate - for the theme, difficulty, and grade level. - """ - # Get AOI information - aoi_info = await self._get_aoi_info(aoi_id) - if aoi_info is None: - raise FileNotFoundError(f"AOI {aoi_id} not found") - - # Build prompt for LLM - prompt = self._build_generation_prompt( - aoi_info=aoi_info, - theme=theme, - difficulty=difficulty, - node_count=node_count, - grade_level=grade_level, - language=language, - ) - - # Call Ollama - try: - response = await self._call_ollama(prompt) - nodes = self._parse_llm_response(response, aoi_id, theme) - except ConnectionError: - logger.warning("Ollama not available, using mock data") - nodes = self._generate_mock_nodes(aoi_id, theme, difficulty, node_count) - - # Store nodes - if aoi_id not in _learning_nodes: - _learning_nodes[aoi_id] = [] - _learning_nodes[aoi_id].extend(nodes) - - return nodes - - async def _get_aoi_info(self, aoi_id: str) -> Optional[dict]: - """Get information about an AOI from its manifest.""" - manifest_path = os.path.join(settings.bundle_dir, aoi_id, "manifest.json") - - if os.path.exists(manifest_path): - with open(manifest_path) as f: - return json.load(f) - - # Check in-memory storage - from services.aoi_packager import _aoi_storage - return _aoi_storage.get(aoi_id) - - def _build_generation_prompt( - self, - aoi_info: dict, - theme: LearningTheme, - difficulty: str, - node_count: int, - grade_level: Optional[str], - language: str, - ) -> str: - """Build a prompt for the LLM to generate learning nodes.""" - theme_descriptions = { - LearningTheme.TOPOGRAPHIE: "Landschaftsformen, Höhen und Geländemerkmale", - LearningTheme.LANDNUTZUNG: "Siedlungen, Landwirtschaft und Flächennutzung", - LearningTheme.ORIENTIERUNG: "Kartenlesen, Kompass und Navigation", - LearningTheme.GEOLOGIE: "Gesteinsarten und geologische Formationen", - LearningTheme.HYDROLOGIE: "Gewässer, Einzugsgebiete und Wasserkreislauf", - LearningTheme.VEGETATION: "Pflanzengemeinschaften und Klimazonen", - } - - difficulty_descriptions = { - "leicht": "Grundlegende Beobachtungen und einfache Fakten", - "mittel": "Verknüpfung von Zusammenhängen und Vergleiche", - "schwer": "Analyse, Transfer und kritisches Denken", - } - - bounds = aoi_info.get("bounds", {}) - center = aoi_info.get("center", {}) - - prompt = f"""Du bist ein Erdkunde-Didaktiker und erstellst Lernstationen für eine interaktive 3D-Lernwelt. - -GEBIET: -- Zentrum: {center.get('latitude', 0):.4f}°N, {center.get('longitude', 0):.4f}°E -- Fläche: ca. {aoi_info.get('area_km2', 0):.2f} km² -- Grenzen: West {bounds.get('west', 0):.4f}°, Süd {bounds.get('south', 0):.4f}°, Ost {bounds.get('east', 0):.4f}°, Nord {bounds.get('north', 0):.4f}° - -THEMA: {theme.value} - {theme_descriptions.get(theme, '')} - -SCHWIERIGKEITSGRAD: {difficulty} - {difficulty_descriptions.get(difficulty, '')} - -ZIELGRUPPE: {grade_level if grade_level else 'Allgemein (Klasse 5-10)'} - -AUFGABE: -Erstelle {node_count} Lernstationen im JSON-Format. Jede Station soll: -1. Eine geografische Position innerhalb des Gebiets haben -2. Eine Lernfrage oder Aufgabe enthalten -3. Hinweise zur Lösung bieten -4. Die richtige Antwort mit Erklärung enthalten - -FORMAT (JSON-Array): -[ - {{ - "title": "Titel der Station", - "position": {{"latitude": 0.0, "longitude": 0.0}}, - "question": "Die Lernfrage", - "hints": ["Hinweis 1", "Hinweis 2"], - "answer": "Die Antwort", - "explanation": "Didaktische Erklärung", - "node_type": "question|observation|exploration", - "points": 10 - }} -] - -WICHTIG: -- Positionen müssen innerhalb der Gebietsgrenzen liegen -- Fragen sollen zum Thema {theme.value} passen -- Sprache: {"Deutsch" if language == "de" else "English"} -- Altersgerechte Formulierungen verwenden - -Antworte NUR mit dem JSON-Array, ohne weitere Erklärungen.""" - - return prompt - - async def _call_ollama(self, prompt: str) -> str: - """Call Ollama API to generate content.""" - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.post( - f"{self.ollama_url}/api/generate", - json={ - "model": self.model, - "prompt": prompt, - "stream": False, - "options": { - "temperature": 0.7, - "top_p": 0.9, - }, - }, - ) - - if response.status_code != 200: - raise ConnectionError(f"Ollama returned {response.status_code}") - - result = response.json() - return result.get("response", "") - - except httpx.ConnectError: - raise ConnectionError("Cannot connect to Ollama") - except Exception as e: - logger.error("Ollama API error", error=str(e)) - raise ConnectionError(f"Ollama error: {str(e)}") - - def _parse_llm_response( - self, response: str, aoi_id: str, theme: LearningTheme - ) -> list[LearningNode]: - """Parse LLM response into LearningNode objects.""" - try: - # Find JSON array in response - start = response.find("[") - end = response.rfind("]") + 1 - - if start == -1 or end == 0: - raise ValueError("No JSON array found in response") - - json_str = response[start:end] - data = json.loads(json_str) - - nodes = [] - for item in data: - node = LearningNode( - id=str(uuid.uuid4()), - aoi_id=aoi_id, - title=item.get("title", "Unbenannte Station"), - theme=theme, - position={ - "latitude": item.get("position", {}).get("latitude", 0), - "longitude": item.get("position", {}).get("longitude", 0), - }, - question=item.get("question", ""), - hints=item.get("hints", []), - answer=item.get("answer", ""), - explanation=item.get("explanation", ""), - node_type=NodeType(item.get("node_type", "question")), - points=item.get("points", 10), - approved=False, - ) - nodes.append(node) - - return nodes - - except (json.JSONDecodeError, ValueError) as e: - logger.error("Failed to parse LLM response", error=str(e)) - return [] - - def _generate_mock_nodes( - self, - aoi_id: str, - theme: LearningTheme, - difficulty: str, - node_count: int, - ) -> list[LearningNode]: - """Generate mock learning nodes for development.""" - mock_questions = { - LearningTheme.TOPOGRAPHIE: [ - ("Höhenbestimmung", "Schätze die Höhe dieses Punktes.", "Ca. 500m über NN"), - ("Hangneigung", "Beschreibe die Steilheit des Hanges.", "Mäßig steil, ca. 15-20°"), - ("Talform", "Welche Form hat dieses Tal?", "V-förmiges Erosionstal"), - ], - LearningTheme.LANDNUTZUNG: [ - ("Gebäudetypen", "Welche Gebäude siehst du hier?", "Wohnhäuser und landwirtschaftliche Gebäude"), - ("Flächennutzung", "Wie wird das Land genutzt?", "Landwirtschaft und Siedlung"), - ("Infrastruktur", "Welche Verkehrswege erkennst du?", "Straße und Feldweg"), - ], - LearningTheme.ORIENTIERUNG: [ - ("Himmelsrichtung", "In welche Richtung fließt der Bach?", "Nach Nordwesten"), - ("Entfernung", "Wie weit ist es bis zum Waldrand?", "Etwa 200 Meter"), - ("Wegbeschreibung", "Beschreibe den Weg zum Aussichtspunkt.", "Nordöstlich, bergauf"), - ], - } - - questions = mock_questions.get(theme, mock_questions[LearningTheme.TOPOGRAPHIE]) - nodes = [] - - for i in range(min(node_count, len(questions))): - title, question, answer = questions[i] - nodes.append(LearningNode( - id=str(uuid.uuid4()), - aoi_id=aoi_id, - title=title, - theme=theme, - position={"latitude": 47.7 + i * 0.001, "longitude": 9.19 + i * 0.001}, - question=question, - hints=[f"Hinweis {j + 1}" for j in range(2)], - answer=answer, - explanation=f"Diese Aufgabe trainiert die Beobachtung von {theme.value}.", - node_type=NodeType.QUESTION, - points=10, - approved=False, - )) - - return nodes - - async def get_nodes_for_aoi( - self, aoi_id: str, theme: Optional[LearningTheme] = None - ) -> Optional[list[LearningNode]]: - """Get all learning nodes for an AOI.""" - nodes = _learning_nodes.get(aoi_id) - - if nodes is None: - return None - - if theme is not None: - nodes = [n for n in nodes if n.theme == theme] - - return nodes - - async def update_node( - self, aoi_id: str, node_id: str, node_update: LearningNode - ) -> bool: - """Update a learning node.""" - nodes = _learning_nodes.get(aoi_id) - if nodes is None: - return False - - for i, node in enumerate(nodes): - if node.id == node_id: - _learning_nodes[aoi_id][i] = node_update - return True - - return False - - async def delete_node(self, aoi_id: str, node_id: str) -> bool: - """Delete a learning node.""" - nodes = _learning_nodes.get(aoi_id) - if nodes is None: - return False - - for i, node in enumerate(nodes): - if node.id == node_id: - del _learning_nodes[aoi_id][i] - return True - - return False - - async def approve_node(self, aoi_id: str, node_id: str) -> bool: - """Approve a learning node for student use.""" - nodes = _learning_nodes.get(aoi_id) - if nodes is None: - return False - - for node in nodes: - if node.id == node_id: - node.approved = True - return True - - return False - - async def get_statistics(self) -> dict: - """Get statistics about learning node usage.""" - total = 0 - by_theme = {} - by_difficulty = {} - - for aoi_nodes in _learning_nodes.values(): - for node in aoi_nodes: - total += 1 - theme = node.theme.value - by_theme[theme] = by_theme.get(theme, 0) + 1 - - return { - "total_nodes": total, - "by_theme": by_theme, - "by_difficulty": by_difficulty, - "avg_per_aoi": total / len(_learning_nodes) if _learning_nodes else 0, - "popular_theme": max(by_theme, key=by_theme.get) if by_theme else "topographie", - } diff --git a/geo-service/services/osm_extractor.py b/geo-service/services/osm_extractor.py deleted file mode 100644 index 1b6f370..0000000 --- a/geo-service/services/osm_extractor.py +++ /dev/null @@ -1,217 +0,0 @@ -""" -OSM Extractor Service -Extracts OpenStreetMap features from PostGIS or vector tiles -""" -import os -import json -from typing import Optional -import structlog -from shapely.geometry import shape, mapping - -from config import settings - -logger = structlog.get_logger(__name__) - - -class OSMExtractorService: - """ - Service for extracting OSM features from a geographic area. - - Can extract from: - - PostGIS database (imported OSM data) - - PMTiles archive - """ - - def __init__(self): - self.database_url = settings.database_url - - async def extract_features(self, polygon: dict) -> dict: - """ - Extract OSM features within a polygon. - - Returns a GeoJSON FeatureCollection with categorized features. - """ - geom = shape(polygon) - bounds = geom.bounds # (minx, miny, maxx, maxy) - - # Feature collection structure - features = { - "type": "FeatureCollection", - "features": [], - "metadata": { - "source": "OpenStreetMap", - "license": "ODbL", - "bounds": { - "west": bounds[0], - "south": bounds[1], - "east": bounds[2], - "north": bounds[3], - }, - }, - } - - # Try to extract from database - try: - db_features = await self._extract_from_database(geom) - if db_features: - features["features"].extend(db_features) - return features - except Exception as e: - logger.warning("Database extraction failed", error=str(e)) - - # Fall back to mock data for development - features["features"] = self._generate_mock_features(geom) - features["metadata"]["source"] = "Mock Data (OSM data not imported)" - - return features - - async def _extract_from_database(self, geom) -> list: - """Extract features from PostGIS database.""" - # This would use asyncpg to query the database - # For now, return empty list to trigger mock data - - # Example query structure (not executed): - # SELECT ST_AsGeoJSON(way), name, building, highway, natural, waterway - # FROM planet_osm_polygon - # WHERE ST_Intersects(way, ST_GeomFromGeoJSON($1)) - - return [] - - def _generate_mock_features(self, geom) -> list: - """Generate mock OSM features for development.""" - from shapely.geometry import Point, LineString, Polygon as ShapelyPolygon - import random - - bounds = geom.bounds - features = [] - - # Generate some mock buildings - for i in range(5): - x = random.uniform(bounds[0], bounds[2]) - y = random.uniform(bounds[1], bounds[3]) - - # Small building polygon - size = 0.0002 # ~20m - building = ShapelyPolygon([ - (x, y), - (x + size, y), - (x + size, y + size), - (x, y + size), - (x, y), - ]) - - if geom.contains(building.centroid): - features.append({ - "type": "Feature", - "geometry": mapping(building), - "properties": { - "category": "building", - "building": "yes", - "name": f"Gebäude {i + 1}", - }, - }) - - # Generate some mock roads - for i in range(3): - x1 = random.uniform(bounds[0], bounds[2]) - y1 = random.uniform(bounds[1], bounds[3]) - x2 = random.uniform(bounds[0], bounds[2]) - y2 = random.uniform(bounds[1], bounds[3]) - - road = LineString([(x1, y1), (x2, y2)]) - - features.append({ - "type": "Feature", - "geometry": mapping(road), - "properties": { - "category": "road", - "highway": random.choice(["primary", "secondary", "residential"]), - "name": f"Straße {i + 1}", - }, - }) - - # Generate mock water feature - cx = (bounds[0] + bounds[2]) / 2 - cy = (bounds[1] + bounds[3]) / 2 - size = min(bounds[2] - bounds[0], bounds[3] - bounds[1]) * 0.2 - - water = ShapelyPolygon([ - (cx - size, cy - size / 2), - (cx + size, cy - size / 2), - (cx + size, cy + size / 2), - (cx - size, cy + size / 2), - (cx - size, cy - size / 2), - ]) - - if geom.intersects(water): - features.append({ - "type": "Feature", - "geometry": mapping(water.intersection(geom)), - "properties": { - "category": "water", - "natural": "water", - "name": "See", - }, - }) - - # Generate mock forest - forest_size = size * 1.5 - forest = ShapelyPolygon([ - (bounds[0], bounds[1]), - (bounds[0] + forest_size, bounds[1]), - (bounds[0] + forest_size, bounds[1] + forest_size), - (bounds[0], bounds[1] + forest_size), - (bounds[0], bounds[1]), - ]) - - if geom.intersects(forest): - features.append({ - "type": "Feature", - "geometry": mapping(forest.intersection(geom)), - "properties": { - "category": "vegetation", - "landuse": "forest", - "name": "Wald", - }, - }) - - return features - - async def get_feature_statistics(self, polygon: dict) -> dict: - """Get statistics about features in an area.""" - features = await self.extract_features(polygon) - - categories = {} - for feature in features.get("features", []): - category = feature.get("properties", {}).get("category", "other") - categories[category] = categories.get(category, 0) + 1 - - return { - "total_features": len(features.get("features", [])), - "by_category": categories, - } - - async def search_features( - self, - polygon: dict, - category: str, - name_filter: Optional[str] = None, - ) -> list: - """Search for specific features within an area.""" - all_features = await self.extract_features(polygon) - - filtered = [] - for feature in all_features.get("features", []): - props = feature.get("properties", {}) - - if props.get("category") != category: - continue - - if name_filter: - name = props.get("name", "") - if name_filter.lower() not in name.lower(): - continue - - filtered.append(feature) - - return filtered diff --git a/geo-service/services/tile_server.py b/geo-service/services/tile_server.py deleted file mode 100644 index b517ab4..0000000 --- a/geo-service/services/tile_server.py +++ /dev/null @@ -1,186 +0,0 @@ -""" -Tile Server Service -Serves vector tiles from PMTiles format or generates on-demand from PostGIS -""" -import os -import gzip -from typing import Optional -from datetime import datetime -import structlog -from pmtiles.reader import Reader as PMTilesReader -from pmtiles.tile import TileType - -from config import settings - -logger = structlog.get_logger(__name__) - - -class MMapFileReader: - """Memory-mapped file reader for PMTiles.""" - - def __init__(self, path: str): - self.path = path - self._file = None - self._size = 0 - - def __enter__(self): - self._file = open(self.path, "rb") - self._file.seek(0, 2) # Seek to end - self._size = self._file.tell() - self._file.seek(0) - return self - - def __exit__(self, *args): - if self._file: - self._file.close() - - def read(self, offset: int, length: int) -> bytes: - """Read bytes from file at offset.""" - self._file.seek(offset) - return self._file.read(length) - - def size(self) -> int: - """Get file size.""" - return self._size - - -class TileServerService: - """ - Service for serving vector tiles from PMTiles format. - - PMTiles is a cloud-optimized format for tile archives that allows - random access to individual tiles without extracting the entire archive. - """ - - def __init__(self): - self.pmtiles_path = settings.pmtiles_path - self.cache_dir = settings.tile_cache_dir - self._reader = None - self._metadata_cache = None - - def _get_reader(self) -> Optional[PMTilesReader]: - """Get or create PMTiles reader.""" - if not os.path.exists(self.pmtiles_path): - logger.warning("PMTiles file not found", path=self.pmtiles_path) - return None - - if self._reader is None: - try: - file_reader = MMapFileReader(self.pmtiles_path) - file_reader.__enter__() - self._reader = PMTilesReader(file_reader) - logger.info("PMTiles reader initialized", path=self.pmtiles_path) - except Exception as e: - logger.error("Failed to initialize PMTiles reader", error=str(e)) - return None - - return self._reader - - async def get_tile(self, z: int, x: int, y: int) -> Optional[bytes]: - """ - Get a vector tile at the specified coordinates. - - Args: - z: Zoom level - x: Tile X coordinate - y: Tile Y coordinate - - Returns: - Tile data as gzipped protobuf, or None if not found - """ - # Check cache first - cache_path = os.path.join(self.cache_dir, str(z), str(x), f"{y}.pbf") - if os.path.exists(cache_path): - with open(cache_path, "rb") as f: - return f.read() - - # Try to get from PMTiles - reader = self._get_reader() - if reader is None: - raise FileNotFoundError("PMTiles file not available") - - try: - tile_data = reader.get_tile(z, x, y) - - if tile_data is None: - return None - - # Cache the tile - await self._cache_tile(z, x, y, tile_data) - - return tile_data - - except Exception as e: - logger.error("Error reading tile", z=z, x=x, y=y, error=str(e)) - return None - - async def _cache_tile(self, z: int, x: int, y: int, data: bytes): - """Cache a tile to disk.""" - cache_path = os.path.join(self.cache_dir, str(z), str(x)) - os.makedirs(cache_path, exist_ok=True) - - tile_path = os.path.join(cache_path, f"{y}.pbf") - with open(tile_path, "wb") as f: - f.write(data) - - async def get_metadata(self) -> dict: - """ - Get metadata about the tile archive. - - Returns: - Dictionary with metadata including bounds, zoom levels, etc. - """ - if self._metadata_cache is not None: - return self._metadata_cache - - reader = self._get_reader() - if reader is None: - return { - "data_available": False, - "minzoom": 0, - "maxzoom": 14, - "bounds": [5.87, 47.27, 15.04, 55.06], - "center": [10.45, 51.16, 6], - } - - try: - header = reader.header() - metadata = reader.metadata() - - self._metadata_cache = { - "data_available": True, - "minzoom": header.get("minZoom", 0), - "maxzoom": header.get("maxZoom", 14), - "bounds": header.get("bounds", [5.87, 47.27, 15.04, 55.06]), - "center": header.get("center", [10.45, 51.16, 6]), - "tile_type": "mvt", # Mapbox Vector Tiles - "last_updated": datetime.fromtimestamp( - os.path.getmtime(self.pmtiles_path) - ).isoformat() if os.path.exists(self.pmtiles_path) else None, - **metadata, - } - - return self._metadata_cache - - except Exception as e: - logger.error("Error reading metadata", error=str(e)) - return {"data_available": False} - - def clear_cache(self): - """Clear the tile cache.""" - import shutil - - if os.path.exists(self.cache_dir): - shutil.rmtree(self.cache_dir) - os.makedirs(self.cache_dir) - logger.info("Tile cache cleared") - - def get_cache_size_mb(self) -> float: - """Get the current cache size in MB.""" - total_size = 0 - for dirpath, dirnames, filenames in os.walk(self.cache_dir): - for filename in filenames: - filepath = os.path.join(dirpath, filename) - total_size += os.path.getsize(filepath) - - return total_size / (1024 * 1024) diff --git a/geo-service/tests/__init__.py b/geo-service/tests/__init__.py deleted file mode 100644 index 51131d3..0000000 --- a/geo-service/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -GeoEdu Service Tests -""" diff --git a/geo-service/tests/test_aoi.py b/geo-service/tests/test_aoi.py deleted file mode 100644 index be7b234..0000000 --- a/geo-service/tests/test_aoi.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -Tests for AOI Packager Service -""" -import pytest -from unittest.mock import patch, MagicMock, AsyncMock -import json - -import sys -sys.path.insert(0, '/app') - -from services.aoi_packager import AOIPackagerService - - -class TestAOIPackagerService: - """Tests for AOI Packager Service.""" - - @pytest.fixture - def service(self): - """Create service instance.""" - return AOIPackagerService() - - def test_calculate_area_km2_small_polygon(self, service): - """Test area calculation for small polygon.""" - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.19, 47.70], - [9.20, 47.70], - [9.20, 47.71], - [9.19, 47.71], - [9.19, 47.70], - ] - ], - } - area = service.calculate_area_km2(polygon) - # Should be approximately 1 km² (rough estimate) - assert 0.5 < area < 2.0 - - def test_calculate_area_km2_mainau(self, service): - """Test area calculation for Mainau island polygon.""" - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.1875, 47.7055], - [9.1975, 47.7055], - [9.1975, 47.7115], - [9.1875, 47.7115], - [9.1875, 47.7055], - ] - ], - } - area = service.calculate_area_km2(polygon) - # Mainau template claims ~0.45 km² - assert 0.3 < area < 1.0 - - def test_is_within_germany_valid(self, service): - """Test polygon within Germany.""" - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.19, 47.70], - [9.20, 47.70], - [9.20, 47.71], - [9.19, 47.71], - [9.19, 47.70], - ] - ], - } - assert service.is_within_germany(polygon) == True - - def test_is_within_germany_outside(self, service): - """Test polygon outside Germany.""" - # Paris - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [2.3, 48.8], - [2.4, 48.8], - [2.4, 48.9], - [2.3, 48.9], - [2.3, 48.8], - ] - ], - } - assert service.is_within_germany(polygon) == False - - def test_validate_polygon_valid(self, service): - """Test valid polygon validation.""" - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.19, 47.70], - [9.20, 47.70], - [9.20, 47.71], - [9.19, 47.71], - [9.19, 47.70], - ] - ], - } - is_valid, message = service.validate_polygon(polygon) - assert is_valid == True - assert message == "Valid" - - def test_validate_polygon_not_closed(self, service): - """Test polygon validation with unclosed ring.""" - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.19, 47.70], - [9.20, 47.70], - [9.20, 47.71], - [9.19, 47.71], - # Missing closing point - ] - ], - } - is_valid, message = service.validate_polygon(polygon) - assert is_valid == False - assert "closed" in message.lower() - - def test_validate_polygon_wrong_type(self, service): - """Test polygon validation with wrong geometry type.""" - polygon = { - "type": "Point", - "coordinates": [9.19, 47.70], - } - is_valid, message = service.validate_polygon(polygon) - assert is_valid == False - assert "Polygon" in message - - def test_estimate_bundle_size_low(self, service): - """Test bundle size estimation for low quality.""" - size = service.estimate_bundle_size_mb(1.0, "low") - assert size == 10.0 - - def test_estimate_bundle_size_medium(self, service): - """Test bundle size estimation for medium quality.""" - size = service.estimate_bundle_size_mb(1.0, "medium") - assert size == 25.0 - - def test_estimate_bundle_size_high(self, service): - """Test bundle size estimation for high quality.""" - size = service.estimate_bundle_size_mb(1.0, "high") - assert size == 50.0 - - def test_estimate_bundle_size_scales_with_area(self, service): - """Test bundle size scales with area.""" - size_1km = service.estimate_bundle_size_mb(1.0, "medium") - size_2km = service.estimate_bundle_size_mb(2.0, "medium") - assert size_2km == size_1km * 2 - - -class TestGeoUtils: - """Tests for geo utility functions.""" - - def test_lat_lon_to_tile_berlin(self): - """Test tile conversion for Berlin.""" - from utils.geo_utils import lat_lon_to_tile - - # Berlin: 52.52, 13.405 - x, y = lat_lon_to_tile(52.52, 13.405, 10) - # At zoom 10, Berlin should be around tile 550, 335 - assert 540 < x < 560 - assert 325 < y < 345 - - def test_tile_to_bounds(self): - """Test tile to bounds conversion.""" - from utils.geo_utils import tile_to_bounds - - west, south, east, north = tile_to_bounds(10, 550, 335) - # Should return valid bounds - assert west < east - assert south < north - # Should be somewhere in Germany - assert 5 < west < 20 - assert 45 < south < 60 - - def test_calculate_distance(self): - """Test distance calculation.""" - from utils.geo_utils import calculate_distance - - # Berlin to Munich: ~504 km - dist = calculate_distance(52.52, 13.405, 48.1351, 11.582) - assert 450000 < dist < 550000 # meters - - def test_get_germany_bounds(self): - """Test Germany bounds.""" - from utils.geo_utils import get_germany_bounds - - west, south, east, north = get_germany_bounds() - assert west == 5.87 - assert south == 47.27 - assert east == 15.04 - assert north == 55.06 - - -class TestLicenseChecker: - """Tests for license checker utility.""" - - def test_osm_source_allowed(self): - """Test OSM source is allowed.""" - from utils.license_checker import LicenseChecker, DataSource - - assert LicenseChecker.is_source_allowed(DataSource.OPENSTREETMAP) == True - - def test_copernicus_source_allowed(self): - """Test Copernicus source is allowed.""" - from utils.license_checker import LicenseChecker, DataSource - - assert LicenseChecker.is_source_allowed(DataSource.COPERNICUS_DEM) == True - - def test_google_source_forbidden(self): - """Test Google source is forbidden.""" - from utils.license_checker import LicenseChecker, DataSource - - assert LicenseChecker.is_source_allowed(DataSource.GOOGLE) == False - - def test_validate_osm_url(self): - """Test OSM URL validation.""" - from utils.license_checker import LicenseChecker - - is_allowed, message = LicenseChecker.validate_url( - "https://tile.openstreetmap.org/10/550/335.png" - ) - assert is_allowed == True - assert "ALLOWED" in message - - def test_validate_google_url(self): - """Test Google URL validation.""" - from utils.license_checker import LicenseChecker - - is_allowed, message = LicenseChecker.validate_url( - "https://maps.googleapis.com/maps/api/staticmap" - ) - assert is_allowed == False - assert "FORBIDDEN" in message - - def test_get_attribution_for_sources(self): - """Test getting attribution for sources.""" - from utils.license_checker import LicenseChecker, DataSource - - sources = [DataSource.OPENSTREETMAP, DataSource.COPERNICUS_DEM] - attributions = LicenseChecker.get_attribution_for_sources(sources) - - assert len(attributions) == 2 - assert any("OpenStreetMap" in a["name"] for a in attributions) - assert any("Copernicus" in a["name"] for a in attributions) - - def test_check_commercial_use_allowed(self): - """Test commercial use check for allowed sources.""" - from utils.license_checker import LicenseChecker, DataSource - - sources = [DataSource.OPENSTREETMAP, DataSource.COPERNICUS_DEM] - allowed, issues = LicenseChecker.check_commercial_use(sources) - assert allowed == True - assert len(issues) == 0 - - def test_check_commercial_use_forbidden(self): - """Test commercial use check with forbidden source.""" - from utils.license_checker import LicenseChecker, DataSource - - sources = [DataSource.OPENSTREETMAP, DataSource.GOOGLE] - allowed, issues = LicenseChecker.check_commercial_use(sources) - assert allowed == False - assert len(issues) > 0 diff --git a/geo-service/tests/test_learning.py b/geo-service/tests/test_learning.py deleted file mode 100644 index 661baa2..0000000 --- a/geo-service/tests/test_learning.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Tests for Learning Generator Service -""" -import pytest -from unittest.mock import patch, MagicMock, AsyncMock -import json - -import sys -sys.path.insert(0, '/app') - -from services.learning_generator import LearningGeneratorService -from models.learning_node import LearningTheme, NodeType - - -class TestLearningGeneratorService: - """Tests for Learning Generator Service.""" - - @pytest.fixture - def service(self): - """Create service instance.""" - return LearningGeneratorService() - - def test_build_generation_prompt_topographie(self, service): - """Test prompt building for topography theme.""" - aoi_info = { - "bounds": {"west": 9.18, "south": 47.70, "east": 9.20, "north": 47.72}, - "center": {"latitude": 47.71, "longitude": 9.19}, - "area_km2": 0.5, - } - - prompt = service._build_generation_prompt( - aoi_info=aoi_info, - theme=LearningTheme.TOPOGRAPHIE, - difficulty="mittel", - node_count=5, - grade_level="7-8", - language="de", - ) - - assert "Topographie" in prompt or "topographie" in prompt - assert "47.71" in prompt # center latitude - assert "9.19" in prompt # center longitude - assert "5" in prompt # node count - assert "mittel" in prompt # difficulty - assert "7-8" in prompt # grade level - assert "JSON" in prompt - - def test_build_generation_prompt_landnutzung(self, service): - """Test prompt building for land use theme.""" - aoi_info = { - "bounds": {"west": 9.18, "south": 47.70, "east": 9.20, "north": 47.72}, - "center": {"latitude": 47.71, "longitude": 9.19}, - "area_km2": 0.5, - } - - prompt = service._build_generation_prompt( - aoi_info=aoi_info, - theme=LearningTheme.LANDNUTZUNG, - difficulty="leicht", - node_count=3, - grade_level=None, - language="de", - ) - - assert "Landnutzung" in prompt or "landnutzung" in prompt - - def test_parse_llm_response_valid(self, service): - """Test parsing valid LLM response.""" - response = """ - Here are the learning nodes: - [ - { - "title": "Höhenbestimmung", - "position": {"latitude": 47.71, "longitude": 9.19}, - "question": "Schätze die Höhe dieses Punktes.", - "hints": ["Schau auf die Vegetation", "Vergleiche mit dem See"], - "answer": "Ca. 500m", - "explanation": "Die Höhe lässt sich...", - "node_type": "question", - "points": 10 - } - ] - """ - - nodes = service._parse_llm_response( - response, "test-aoi-id", LearningTheme.TOPOGRAPHIE - ) - - assert len(nodes) == 1 - assert nodes[0].title == "Höhenbestimmung" - assert nodes[0].aoi_id == "test-aoi-id" - assert nodes[0].theme == LearningTheme.TOPOGRAPHIE - assert nodes[0].points == 10 - - def test_parse_llm_response_invalid_json(self, service): - """Test parsing invalid LLM response.""" - response = "This is not valid JSON" - - nodes = service._parse_llm_response( - response, "test-aoi-id", LearningTheme.TOPOGRAPHIE - ) - - assert len(nodes) == 0 - - def test_generate_mock_nodes(self, service): - """Test mock node generation.""" - nodes = service._generate_mock_nodes( - aoi_id="test-aoi-id", - theme=LearningTheme.TOPOGRAPHIE, - difficulty="mittel", - node_count=3, - ) - - assert len(nodes) == 3 - for node in nodes: - assert node.aoi_id == "test-aoi-id" - assert node.theme == LearningTheme.TOPOGRAPHIE - assert node.approved == False - assert len(node.hints) > 0 - - def test_generate_mock_nodes_orientierung(self, service): - """Test mock node generation for orientation theme.""" - nodes = service._generate_mock_nodes( - aoi_id="test-aoi-id", - theme=LearningTheme.ORIENTIERUNG, - difficulty="leicht", - node_count=2, - ) - - assert len(nodes) == 2 - for node in nodes: - assert node.theme == LearningTheme.ORIENTIERUNG - - @pytest.mark.asyncio - async def test_get_nodes_for_aoi_empty(self, service): - """Test getting nodes for non-existent AOI.""" - nodes = await service.get_nodes_for_aoi("nonexistent-aoi") - assert nodes is None - - @pytest.mark.asyncio - async def test_get_statistics_empty(self, service): - """Test statistics with no data.""" - stats = await service.get_statistics() - assert stats["total_nodes"] == 0 - assert stats["by_theme"] == {} - - -class TestLearningNodeModel: - """Tests for Learning Node model.""" - - def test_learning_theme_values(self): - """Test all theme enum values exist.""" - themes = [ - LearningTheme.TOPOGRAPHIE, - LearningTheme.LANDNUTZUNG, - LearningTheme.ORIENTIERUNG, - LearningTheme.GEOLOGIE, - LearningTheme.HYDROLOGIE, - LearningTheme.VEGETATION, - ] - assert len(themes) == 6 - - def test_node_type_values(self): - """Test all node type enum values exist.""" - types = [ - NodeType.QUESTION, - NodeType.OBSERVATION, - NodeType.EXPLORATION, - ] - assert len(types) == 3 - - def test_create_learning_node(self): - """Test creating a learning node.""" - from models.learning_node import LearningNode - - node = LearningNode( - id="test-id", - aoi_id="test-aoi", - title="Test Station", - theme=LearningTheme.TOPOGRAPHIE, - position={"latitude": 47.71, "longitude": 9.19}, - question="Test question?", - hints=["Hint 1", "Hint 2"], - answer="Test answer", - explanation="Test explanation", - node_type=NodeType.QUESTION, - points=10, - approved=False, - ) - - assert node.id == "test-id" - assert node.title == "Test Station" - assert node.points == 10 - assert len(node.hints) == 2 diff --git a/geo-service/tests/test_tiles.py b/geo-service/tests/test_tiles.py deleted file mode 100644 index 25050d5..0000000 --- a/geo-service/tests/test_tiles.py +++ /dev/null @@ -1,283 +0,0 @@ -""" -Tests for Tile Server API -""" -import pytest -from fastapi.testclient import TestClient -from unittest.mock import patch, MagicMock - -# Import app after mocking to avoid import errors -import sys -sys.path.insert(0, '/app') - - -@pytest.fixture -def client(): - """Create test client.""" - from main import app - return TestClient(app) - - -class TestTileEndpoints: - """Tests for tile server endpoints.""" - - def test_health_check(self, client): - """Test health check endpoint returns healthy status.""" - response = client.get("/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "healthy" - assert data["service"] == "geo-service" - assert "data_status" in data - - def test_root_endpoint(self, client): - """Test root endpoint returns service info.""" - response = client.get("/") - assert response.status_code == 200 - data = response.json() - assert data["service"] == "GeoEdu Service" - assert "endpoints" in data - assert "attribution" in data - - def test_tile_metadata(self, client): - """Test tile metadata endpoint.""" - response = client.get("/api/v1/tiles/metadata") - assert response.status_code == 200 - data = response.json() - assert "name" in data - assert "minzoom" in data - assert "maxzoom" in data - assert "bounds" in data - assert data["attribution"] == "© OpenStreetMap contributors (ODbL)" - - def test_tile_bounds(self, client): - """Test tile bounds endpoint returns Germany bounds.""" - response = client.get("/api/v1/tiles/bounds") - assert response.status_code == 200 - data = response.json() - assert "bounds" in data - # Germany approximate bounds - bounds = data["bounds"] - assert bounds[0] < 6 # west - assert bounds[1] > 47 # south - assert bounds[2] > 14 # east - assert bounds[3] < 56 # north - - def test_style_json(self, client): - """Test MapLibre style endpoint.""" - response = client.get("/api/v1/tiles/style.json") - assert response.status_code == 200 - data = response.json() - assert data["version"] == 8 - assert "sources" in data - assert "layers" in data - assert "osm" in data["sources"] - - def test_tile_request_without_data(self, client): - """Test tile request returns 503 when data not available.""" - response = client.get("/api/v1/tiles/0/0/0.pbf") - # Should return 503 (data not available) or 204 (empty tile) - assert response.status_code in [204, 503] - - def test_tile_request_invalid_zoom(self, client): - """Test tile request with invalid zoom level.""" - response = client.get("/api/v1/tiles/25/0/0.pbf") - assert response.status_code == 422 # Validation error - - -class TestTerrainEndpoints: - """Tests for terrain/DEM endpoints.""" - - def test_terrain_metadata(self, client): - """Test terrain metadata endpoint.""" - response = client.get("/api/v1/terrain/metadata") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Copernicus DEM GLO-30" - assert data["resolution_m"] == 30 - assert "bounds" in data - - def test_heightmap_request_without_data(self, client): - """Test heightmap request returns appropriate status when no data.""" - response = client.get("/api/v1/terrain/10/500/500.png") - # Should return 204 (no content) or 503 (data not available) - assert response.status_code in [204, 503] - - def test_elevation_at_point_within_germany(self, client): - """Test elevation endpoint with valid Germany coordinates.""" - # Berlin coordinates - response = client.get("/api/v1/terrain/elevation?lat=52.52&lon=13.405") - # Should return 404 (no data) or 200 (with elevation) or 503 - assert response.status_code in [200, 404, 503] - - def test_elevation_at_point_outside_germany(self, client): - """Test elevation endpoint with coordinates outside Germany.""" - # Paris coordinates (outside Germany) - response = client.get("/api/v1/terrain/elevation?lat=48.8566&lon=2.3522") - # Should return 422 (validation error - outside bounds) - assert response.status_code == 422 - - -class TestAOIEndpoints: - """Tests for AOI (Area of Interest) endpoints.""" - - def test_validate_polygon_valid(self, client): - """Test polygon validation with valid polygon.""" - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.19, 47.71], - [9.20, 47.71], - [9.20, 47.70], - [9.19, 47.70], - [9.19, 47.71], - ] - ], - } - response = client.post("/api/v1/aoi/validate", json=polygon) - assert response.status_code == 200 - data = response.json() - assert "valid" in data - assert "area_km2" in data - assert "within_germany" in data - - def test_validate_polygon_too_large(self, client): - """Test polygon validation with too large area.""" - # Large polygon (> 4 km²) - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [9.0, 47.5], - [9.5, 47.5], - [9.5, 48.0], - [9.0, 48.0], - [9.0, 47.5], - ] - ], - } - response = client.post("/api/v1/aoi/validate", json=polygon) - assert response.status_code == 200 - data = response.json() - assert data["within_size_limit"] == False - - def test_validate_polygon_outside_germany(self, client): - """Test polygon validation outside Germany.""" - # Paris area - polygon = { - "type": "Polygon", - "coordinates": [ - [ - [2.3, 48.8], - [2.4, 48.8], - [2.4, 48.9], - [2.3, 48.9], - [2.3, 48.8], - ] - ], - } - response = client.post("/api/v1/aoi/validate", json=polygon) - assert response.status_code == 200 - data = response.json() - assert data["within_germany"] == False - - def test_mainau_template(self, client): - """Test Mainau demo template endpoint.""" - response = client.get("/api/v1/aoi/templates/mainau") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Insel Mainau" - assert "polygon" in data - assert "center" in data - assert "suggested_themes" in data - - def test_create_aoi(self, client): - """Test AOI creation with valid polygon.""" - request = { - "polygon": { - "type": "Polygon", - "coordinates": [ - [ - [9.1875, 47.7055], - [9.1975, 47.7055], - [9.1975, 47.7115], - [9.1875, 47.7115], - [9.1875, 47.7055], - ] - ], - }, - "theme": "topographie", - "quality": "medium", - } - response = client.post("/api/v1/aoi", json=request) - assert response.status_code == 200 - data = response.json() - assert "aoi_id" in data - assert data["status"] == "queued" - assert "area_km2" in data - - def test_create_aoi_too_large(self, client): - """Test AOI creation fails with too large area.""" - request = { - "polygon": { - "type": "Polygon", - "coordinates": [ - [ - [9.0, 47.5], - [9.5, 47.5], - [9.5, 48.0], - [9.0, 48.0], - [9.0, 47.5], - ] - ], - }, - "theme": "topographie", - "quality": "medium", - } - response = client.post("/api/v1/aoi", json=request) - assert response.status_code == 400 - - def test_get_nonexistent_aoi(self, client): - """Test getting non-existent AOI returns 404.""" - response = client.get("/api/v1/aoi/nonexistent-id") - assert response.status_code == 404 - - -class TestLearningEndpoints: - """Tests for Learning Nodes endpoints.""" - - def test_learning_templates(self, client): - """Test learning templates endpoint.""" - response = client.get("/api/v1/learning/templates") - assert response.status_code == 200 - data = response.json() - assert "themes" in data - assert "difficulties" in data - assert len(data["themes"]) == 6 # 6 themes - - # Check theme structure - theme = data["themes"][0] - assert "id" in theme - assert "name" in theme - assert "description" in theme - assert "example_questions" in theme - - def test_learning_statistics(self, client): - """Test learning statistics endpoint.""" - response = client.get("/api/v1/learning/statistics") - assert response.status_code == 200 - data = response.json() - assert "total_nodes_generated" in data - assert "nodes_by_theme" in data - - def test_generate_nodes_without_aoi(self, client): - """Test node generation fails without valid AOI.""" - request = { - "aoi_id": "nonexistent-aoi", - "theme": "topographie", - "difficulty": "mittel", - "node_count": 5, - } - response = client.post("/api/v1/learning/generate", json=request) - # Should return 404 (AOI not found) or 503 (Ollama not available) - assert response.status_code in [404, 503] diff --git a/geo-service/utils/__init__.py b/geo-service/utils/__init__.py deleted file mode 100644 index c392f9f..0000000 --- a/geo-service/utils/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -GeoEdu Service - Utility Functions -""" -from .geo_utils import ( - lat_lon_to_tile, - tile_to_bounds, - calculate_distance, - transform_coordinates, -) -from .minio_client import MinioClient -from .license_checker import LicenseChecker - -__all__ = [ - "lat_lon_to_tile", - "tile_to_bounds", - "calculate_distance", - "transform_coordinates", - "MinioClient", - "LicenseChecker", -] diff --git a/geo-service/utils/geo_utils.py b/geo-service/utils/geo_utils.py deleted file mode 100644 index 392892e..0000000 --- a/geo-service/utils/geo_utils.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -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 diff --git a/geo-service/utils/license_checker.py b/geo-service/utils/license_checker.py deleted file mode 100644 index e47720f..0000000 --- a/geo-service/utils/license_checker.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -License Checker Utility -Validates data source licenses and generates attribution -""" -from typing import Optional -from enum import Enum -import structlog - -logger = structlog.get_logger(__name__) - - -class LicenseType(Enum): - """Supported data source license types.""" - ODBL = "odbl" # OpenStreetMap - COPERNICUS = "copernicus" # Copernicus DEM - CC_BY = "cc-by" # Creative Commons Attribution - CC_BY_SA = "cc-by-sa" # Creative Commons Attribution-ShareAlike - CC0 = "cc0" # Public Domain - PROPRIETARY = "proprietary" # Not allowed - - -class DataSource(Enum): - """Known data sources.""" - OPENSTREETMAP = "openstreetmap" - COPERNICUS_DEM = "copernicus_dem" - OPENAERIAL = "openaerial" - WIKIMEDIA = "wikimedia" - GOOGLE = "google" # FORBIDDEN - BING = "bing" # FORBIDDEN - APPLE = "apple" # FORBIDDEN - HERE = "here" # FORBIDDEN - - -# License information for allowed sources -ALLOWED_SOURCES = { - DataSource.OPENSTREETMAP: { - "license": LicenseType.ODBL, - "attribution": "© OpenStreetMap contributors", - "url": "https://www.openstreetmap.org/copyright", - "commercial": True, - "derivative_allowed": True, - }, - DataSource.COPERNICUS_DEM: { - "license": LicenseType.COPERNICUS, - "attribution": "© Copernicus Service Information", - "url": "https://spacedata.copernicus.eu/", - "commercial": True, - "derivative_allowed": True, - }, - DataSource.OPENAERIAL: { - "license": LicenseType.CC_BY, - "attribution": "© OpenAerialMap contributors", - "url": "https://openaerialmap.org/", - "commercial": True, - "derivative_allowed": True, - }, - DataSource.WIKIMEDIA: { - "license": LicenseType.CC_BY_SA, - "attribution": "Wikimedia Commons", - "url": "https://commons.wikimedia.org/", - "commercial": True, - "derivative_allowed": True, - }, -} - -# Forbidden sources -FORBIDDEN_SOURCES = { - DataSource.GOOGLE: "Google Maps ToS prohibit derivatives and offline use", - DataSource.BING: "Bing Maps has restrictive licensing", - DataSource.APPLE: "Apple Maps prohibits commercial use", - DataSource.HERE: "HERE requires paid licensing", -} - - -class LicenseChecker: - """ - Utility for validating data source licenses and generating attributions. - - Ensures DSGVO/GDPR compliance and proper licensing for educational use. - """ - - @staticmethod - def is_source_allowed(source: DataSource) -> bool: - """Check if a data source is allowed for use.""" - return source in ALLOWED_SOURCES - - @staticmethod - def get_forbidden_reason(source: DataSource) -> Optional[str]: - """Get the reason why a source is forbidden.""" - return FORBIDDEN_SOURCES.get(source) - - @staticmethod - def validate_url(url: str) -> tuple[bool, str]: - """ - Validate if a URL is from an allowed source. - - Returns: - Tuple of (is_allowed, message) - """ - url_lower = url.lower() - - # Check for forbidden sources - forbidden_patterns = { - "google": DataSource.GOOGLE, - "googleapis": DataSource.GOOGLE, - "gstatic": DataSource.GOOGLE, - "bing.com": DataSource.BING, - "virtualearth": DataSource.BING, - "apple.com/maps": DataSource.APPLE, - "here.com": DataSource.HERE, - } - - for pattern, source in forbidden_patterns.items(): - if pattern in url_lower: - reason = FORBIDDEN_SOURCES.get(source, "Not allowed") - return False, f"FORBIDDEN: {source.value} - {reason}" - - # Check for allowed sources - allowed_patterns = { - "openstreetmap": DataSource.OPENSTREETMAP, - "tile.osm": DataSource.OPENSTREETMAP, - "copernicus": DataSource.COPERNICUS_DEM, - "openaerialmap": DataSource.OPENAERIAL, - "wikimedia": DataSource.WIKIMEDIA, - } - - for pattern, source in allowed_patterns.items(): - if pattern in url_lower: - info = ALLOWED_SOURCES[source] - return True, f"ALLOWED: {source.value} ({info['license'].value})" - - # Unknown source - warn but allow with custom attribution - return True, "UNKNOWN: Verify license manually" - - @staticmethod - def get_attribution_for_sources( - sources: list[DataSource], - ) -> list[dict]: - """ - Get attribution information for a list of data sources. - - Args: - sources: List of data sources used - - Returns: - List of attribution dictionaries - """ - attributions = [] - - for source in sources: - if source in ALLOWED_SOURCES: - info = ALLOWED_SOURCES[source] - attributions.append({ - "name": source.value.replace("_", " ").title(), - "license": info["license"].value.upper(), - "attribution": info["attribution"], - "url": info["url"], - "required": True, - }) - - return attributions - - @staticmethod - def generate_attribution_html(sources: list[DataSource]) -> str: - """ - Generate HTML attribution footer for web display. - - Args: - sources: List of data sources used - - Returns: - HTML string with attribution - """ - attributions = LicenseChecker.get_attribution_for_sources(sources) - - if not attributions: - return "" - - parts = [] - for attr in attributions: - parts.append( - f'' - f'{attr["attribution"]}' - ) - - return " | ".join(parts) - - @staticmethod - def generate_attribution_text(sources: list[DataSource]) -> str: - """ - Generate plain text attribution. - - Args: - sources: List of data sources used - - Returns: - Plain text attribution string - """ - attributions = LicenseChecker.get_attribution_for_sources(sources) - - if not attributions: - return "" - - return " | ".join(attr["attribution"] for attr in attributions) - - @staticmethod - def check_commercial_use(sources: list[DataSource]) -> tuple[bool, list[str]]: - """ - Check if all sources allow commercial use. - - Returns: - Tuple of (all_allowed, list_of_issues) - """ - issues = [] - - for source in sources: - if source in FORBIDDEN_SOURCES: - issues.append(f"{source.value}: {FORBIDDEN_SOURCES[source]}") - elif source in ALLOWED_SOURCES: - if not ALLOWED_SOURCES[source]["commercial"]: - issues.append(f"{source.value}: Commercial use not allowed") - - return len(issues) == 0, issues diff --git a/geo-service/utils/minio_client.py b/geo-service/utils/minio_client.py deleted file mode 100644 index 035750c..0000000 --- a/geo-service/utils/minio_client.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -MinIO Client Utility -S3-compatible storage operations for AOI bundles -""" -import os -from typing import Optional, BinaryIO -import structlog -from minio import Minio -from minio.error import S3Error - -from config import settings - -logger = structlog.get_logger(__name__) - - -class MinioClient: - """ - Client for MinIO S3-compatible storage. - - Used for storing generated AOI bundles and assets. - """ - - def __init__(self): - self.endpoint = settings.minio_endpoint - self.access_key = settings.minio_access_key - self.secret_key = settings.minio_secret_key - self.bucket = settings.minio_bucket - self.secure = settings.minio_secure - self._client: Optional[Minio] = None - - @property - def client(self) -> Minio: - """Get or create MinIO client instance.""" - if self._client is None: - self._client = Minio( - self.endpoint, - access_key=self.access_key, - secret_key=self.secret_key, - secure=self.secure, - ) - self._ensure_bucket_exists() - return self._client - - def _ensure_bucket_exists(self): - """Create the bucket if it doesn't exist.""" - try: - if not self._client.bucket_exists(self.bucket): - self._client.make_bucket(self.bucket) - logger.info("Created MinIO bucket", bucket=self.bucket) - except S3Error as e: - logger.error("Error creating bucket", error=str(e)) - - async def upload_file( - self, - local_path: str, - object_name: str, - content_type: str = "application/octet-stream", - ) -> Optional[str]: - """ - Upload a file to MinIO. - - Args: - local_path: Path to local file - object_name: Name in MinIO (can include path) - content_type: MIME type of the file - - Returns: - Object URL or None on failure - """ - try: - self.client.fput_object( - self.bucket, - object_name, - local_path, - content_type=content_type, - ) - logger.info("Uploaded file to MinIO", object_name=object_name) - return f"{self.endpoint}/{self.bucket}/{object_name}" - except S3Error as e: - logger.error("Error uploading file", error=str(e)) - return None - - async def upload_bytes( - self, - data: bytes, - object_name: str, - content_type: str = "application/octet-stream", - ) -> Optional[str]: - """ - Upload bytes to MinIO. - - Args: - data: Bytes to upload - object_name: Name in MinIO - content_type: MIME type - - Returns: - Object URL or None on failure - """ - from io import BytesIO - - try: - stream = BytesIO(data) - self.client.put_object( - self.bucket, - object_name, - stream, - length=len(data), - content_type=content_type, - ) - logger.info("Uploaded bytes to MinIO", object_name=object_name, size=len(data)) - return f"{self.endpoint}/{self.bucket}/{object_name}" - except S3Error as e: - logger.error("Error uploading bytes", error=str(e)) - return None - - async def download_file( - self, - object_name: str, - local_path: str, - ) -> bool: - """ - Download a file from MinIO. - - Args: - object_name: Name in MinIO - local_path: Destination path - - Returns: - True on success - """ - try: - self.client.fget_object(self.bucket, object_name, local_path) - logger.info("Downloaded file from MinIO", object_name=object_name) - return True - except S3Error as e: - logger.error("Error downloading file", error=str(e)) - return False - - async def get_bytes(self, object_name: str) -> Optional[bytes]: - """ - Get object content as bytes. - - Args: - object_name: Name in MinIO - - Returns: - File content or None - """ - try: - response = self.client.get_object(self.bucket, object_name) - data = response.read() - response.close() - response.release_conn() - return data - except S3Error as e: - logger.error("Error getting bytes", error=str(e)) - return None - - async def delete_object(self, object_name: str) -> bool: - """ - Delete an object from MinIO. - - Args: - object_name: Name in MinIO - - Returns: - True on success - """ - try: - self.client.remove_object(self.bucket, object_name) - logger.info("Deleted object from MinIO", object_name=object_name) - return True - except S3Error as e: - logger.error("Error deleting object", error=str(e)) - return False - - async def list_objects(self, prefix: str = "") -> list[str]: - """ - List objects in the bucket. - - Args: - prefix: Filter by prefix - - Returns: - List of object names - """ - try: - objects = self.client.list_objects(self.bucket, prefix=prefix) - return [obj.object_name for obj in objects] - except S3Error as e: - logger.error("Error listing objects", error=str(e)) - return [] - - async def get_presigned_url( - self, - object_name: str, - expiry_hours: int = 24, - ) -> Optional[str]: - """ - Get a presigned URL for downloading an object. - - Args: - object_name: Name in MinIO - expiry_hours: URL expiry time in hours - - Returns: - Presigned URL or None - """ - from datetime import timedelta - - try: - url = self.client.presigned_get_object( - self.bucket, - object_name, - expires=timedelta(hours=expiry_hours), - ) - return url - except S3Error as e: - logger.error("Error generating presigned URL", error=str(e)) - return None - - async def object_exists(self, object_name: str) -> bool: - """Check if an object exists.""" - try: - self.client.stat_object(self.bucket, object_name) - return True - except S3Error: - return False - - async def get_object_size(self, object_name: str) -> Optional[int]: - """Get the size of an object in bytes.""" - try: - stat = self.client.stat_object(self.bucket, object_name) - return stat.size - except S3Error: - return None