commit 2ab650dee769bffc6503287b3c0e355343fc9db2 Author: wouser Date: Sun Jun 21 23:03:38 2026 +0200 Release v2.1 - Obsidian integratie: slaat rapporten op in huizenjacht/ map diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..f503ceb --- /dev/null +++ b/SKILL.md @@ -0,0 +1,133 @@ +--- +name: adres-analyse +description: Genereer uitgebreide adresrapporten met geocodering, isochronen (bereikbaarheidskaarten), en andere locatie-gerelateerde analyses voor Nederlandse adressen. +tags: [geocoding, isochrone, routing, maps, locatie-analyse, pdok, ors] +--- + +# Adres Analyse + +Genereer modulaire rapporten voor Nederlandse adressen. Elk onderdeel wordt als aparte sectie toegevoegd aan het rapport. + +## Rapport generatie + +**Hoofdscript:** `scripts/rapport.py` (ook geïnstalleerd op `/root/adres-analyse/rapport.py`) + +```bash +python3 scripts/rapport.py "Adres Plaats" +``` + +Output is markdown formaat, direct naar stdout (voor Telegram) én wordt opgeslagen in Obsidian vault (map: huizenjacht). + +## Huidige onderdelen + +### 1. Bereikbaarheid (Isochroon) +- **Auto profiel:** `driving-car`, 20 minuten, blauw op de kaart +- **Fiets profiel:** `cycling-regular`, 30 minuten, groen op de kaart +- **Metrics:** Oppervlakte (km²), reach factor, geografisch bereik +- **Data bron:** ORS op `192.168.1.71:9080` + +### 2. Voorzieningen in de buurt +- **Bron:** lokale PostGIS database met geselecteerde POI's uit heel Nederland OSM extract +- **Docker:** `postgis/postgis` op poort `5433`, database `adresanalyse` +- **Import:** `import-osm.sh` downloadt `netherlands-latest.osm.pbf` en `osm-poi-import.py` importeert alleen `shop=supermarket` en `amenity=school` +- **Routeverrijking:** ORS Matrix voor `driving-car` en `cycling-regular` +- **Output:** top 5 supermarkten en top 5 scholen in rapport + markers op Folium kaart + +### 3. (Toekomstige onderdelen) +- OV bereikbaarheid +- Luchtkwaliteit +- Demografie +- Vastgoeddata + +## Workflow (intern) + +### Stap 1: Geocoding (adres → coördinaten) +Gebruik **PDOK Locatieserver** (gratis, geen API key nodig): + +```bash +curl -s "https://api.pdok.nl/bzk/locatieserver/search/v3_1/free?q={adres}&rows=5&fl=centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer" +``` + +**Parsing:** +- Zoek naar `type=adres` voor exacte adres match (niet straat) +- `centroide_ll` is in formaat `POINT(lon lat)` → split op spatie, coords[0]=lon, coords[1]=lat +- Fallback naar eerste resultaat als geen adres match + +### Stap 2: Isochroon ophalen +POST naar ORS: + +```bash +curl -s -X POST "http://192.168.1.71:9080/ors/v2/isochrones/driving-car" \ + -H "Content-Type: application/json" \ + -d '{ + "locations": [[lon, lat]], + "range": [1200], + "attributes": ["area","reachfactor","total_pop"] + }' +``` + +**Parameters:** +- `range` in seconden (20 min = 1200) +- Response bevat GeoJSON FeatureCollection met polygon geometrie +- GeoJSON wordt opgeslagen in `/tmp/isochroon_{timestamp}.geojson` + +### Stap 3: Kaart genereren (optioneel) +Gebruik script: `scripts/generate-map.py` + +```bash +python3 scripts/generate-map.py "Griendvelden 14, Best" /root/adres-analyse/kaart.html +``` + +Voor Telegram delivery: +1. Open HTML in browser: `browser_navigate` naar `file:///root/adres-analyse/kaart.html` +2. Screenshot maken: `browser_vision` met vraag over kaart inhoud +3. Screenshot pad gebruiken in MEDIA: directive voor Telegram + +**Vereisten:** `pip install folium shapely` + +## Pitfalls + +1. **Geocoding exactheid**: PDOK zoekt op straatnaam als huisnummer niet exact matcht - altijd checken of `type=adres` in response. Zoek door eerste 5 resultaten voor adres match. + +2. **ORS cycling profielen niet geactiveerd**: Alleen `driving-car` werkt momenteel. Fietsprofielen geven error "RoutingProfile.getGraphhopper() is null". Zie `references/ors-setup.md` voor activatie-instructies. + +3. **Coördinaten volgorde**: PDOK geeft `POINT(lon lat)` (WGS84), maar ORS verwacht `[[lon, lat]]` in array. Let op volgorde bij parsen. + +4. **Oppervlakte conversie**: ORS geeft area in m², niet km². Deel door 1_000_000 voor km² of 10_000 voor hectare. + +5. **Folium installatie**: `pip install folium shapely` nodig voor kaartgeneratie (niet voor basis rapport). + +6. **Telegram media**: HTML-kaarten werken niet direct in Telegram - maak PNG screenshots via browser tools. + +7. **Rapport zonder kaart**: Het rapport.py script genereert alleen tekst. Voor visuele kaart: gebruik apart folium script + browser screenshot workflow. + +8. **Obsidian API**: Vereist dat Obsidian draait met Local REST API plugin ingeschakeld. Token en host staan in script. Bestandsnaam wordt gegenereerd met timestamp om collisions te voorkomen. + +## Uitbreiding + +Nieuwe onderdelen toevoegen: +1. Voeg functie toe aan `/root/adres-analyse/rapport.py` +2. Roep aan in `genereer_rapport()` na geocoding +3. Update deze skill met nieuwe sectie onder "Huidige onderdelen" +4. Verhoog onderdeel teller in output footer + +## Voorbeeld rapport + +``` +📍 **Adres Analyse Rapport** +Gegenereerd: 19-06-2026 18:42 + +🔍 Geocoding... +**Adres:** Griendvelden 14, 5685JL Best +**Coördinaten:** 51.505547, 5.370553 + +🚗 **Bereikbaarheid (Auto 20 min)** +**Oppervlakte:** 316.8 km² +**Reach factor:** 0.091 +**Bereik:** 51.378°-51.679°N, 5.128°-5.530°E + +💾 Data opgeslagen: `/tmp/isochroon_20260619_184239.geojson` + +--- +_Onderdeel 1/?: Isochroon bereikbaarheid_ +``` diff --git a/references/ors-setup.md b/references/ors-setup.md new file mode 100644 index 0000000..7961d94 --- /dev/null +++ b/references/ors-setup.md @@ -0,0 +1,93 @@ +# OpenRouteService (ORS) Setup Guide + +## Huidige status +- **Locatie**: `192.168.1.71:9080` (Docker container) +- **Versie**: 8.0.0 (build 2024-03-21) +- **Actief profiel**: `driving-car` ✅ +- **Niet actief**: `cycling-regular`, `cycling-electric`, `foot-walking`, `driving-hgv`, `wheelchair` ❌ + +## Cycling profiel activeren + +### Stap 1: SSH naar host +```bash +ssh 192.168.1.71 +``` + +### Stap 2: ORS config aanpassen +Locatie: `/path/to/ors/config/ors-config.yml` (pas aan naar jouw setup) + +Voeg toe onder `ors.engine.profiles`: + +```yaml +ors: + engine: + profiles: + driving-car: + enabled: true + cycling-regular: + enabled: true + graph_path: /home/ors/graphs/cycling-regular + cycling-electric: + enabled: true + graph_path: /home/ors/graphs/cycling-electric +``` + +### Stap 3: Graph data downloaden +ORS heeft pre-built graphs nodig of moet ze bouwen van OSM data: + +```bash +# Download Netherlands OSM extract +wget https://download.geofabrik.de/europe/netherlands-latest.osm.pbf + +# Bouw graph (kan uren duren) +docker exec -it ors-container java -jar ors-engine.jar build +``` + +### Stap 4: Container herstarten +```bash +docker restart ors-container +``` + +### Stap 5: Verifiëren +```bash +curl -s -X POST "http://192.168.1.71:9080/ors/v2/isochrones/cycling-regular" \ + -H "Content-Type: application/json" \ + -d '{ + "locations": [[5.37055345, 51.50554732]], + "range": [1800] + }' | head -c 200 +``` + +Verwacht: GeoJSON FeatureCollection (geen error). + +## Troubleshooting + +### Error: "getRouteProfile(int) is null" +→ Profiel niet geactiveerd in config of graph ontbreekt. + +### Error: "Parameter 'X' has incorrect value" +→ Profielnaam bestaat niet (typo of niet ondersteund). + +### Lange wachttijd na herstart +→ ORS bouwt graphs bij eerste start (kan 30+ min duren voor NL). + +## API Endpoints + +### Geocoding (niet beschikbaar op deze instantie) +ORS heeft optionele Pelias geocoding, maar die draait niet op 192.168.1.71. +Gebruik PDOK Locatieserver als alternatief. + +### Isochrones +``` +POST /ors/v2/isochrones/{profile} +``` + +### Routing +``` +POST /ors/v2/directions/{profile} +``` + +### Matrix +``` +POST /ors/v2/matrix/{profile} +``` diff --git a/scripts/generate-map.py b/scripts/generate-map.py new file mode 100644 index 0000000..4fc0075 --- /dev/null +++ b/scripts/generate-map.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Genereer interactieve kaart voor adres analyse. +Toont in één figuur: +- blauw: 20 minuten autorijden (driving-car) +- groen: 30 minuten fietsen (cycling-regular) +""" + +import json +import sys +import urllib.parse +import urllib.request + +import folium + +ORS_BASE = "http://192.168.1.71:9080/ors/v2" +PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1" + + +def geocode_adres(adres): + params = urllib.parse.urlencode({ + "q": adres, + "rows": 5, + "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer", + }) + url = f"{PDOK_BASE}/free?{params}" + with urllib.request.urlopen(url, timeout=10) as resp: + data = json.loads(resp.read()) + + docs = data.get("response", {}).get("docs", []) + if not docs: + raise ValueError(f"Adres niet gevonden: {adres}") + + doc = next((d for d in docs if d.get("type") == "adres"), docs[0]) + point = doc["centroide_ll"].replace("POINT(", "").replace(")", "").split() + lon, lat = float(point[0]), float(point[1]) + return { + "adres": doc.get("weergavenaam", adres), + "postcode": doc.get("postcode", ""), + "woonplaats": doc.get("woonplaatsnaam", ""), + "lat": lat, + "lon": lon, + } + + +def haal_isochroon(lon, lat, profiel, minuten): + payload = json.dumps({ + "locations": [[lon, lat]], + "range": [minuten * 60], + "attributes": ["area", "reachfactor", "total_pop"], + }).encode() + req = urllib.request.Request( + f"{ORS_BASE}/isochrones/{profiel}", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read()) + + +def polygon_bounds(feature_collection): + bbox = feature_collection.get("bbox", []) + if len(bbox) == 4: + return [[bbox[1], bbox[0]], [bbox[3], bbox[2]]] + bounds = [] + for feature in feature_collection.get("features", []): + coords = feature.get("geometry", {}).get("coordinates", [[]])[0] + bounds.extend([[lat, lon] for lon, lat in coords]) + return bounds + + +def voeg_isochroon_toe(m, iso, label, kleur, tooltip): + for feature in iso.get("features", []): + coords = feature["geometry"]["coordinates"][0] + props = feature.get("properties", {}) + area_km2 = props.get("area", 0) / 1_000_000 + reach = props.get("reachfactor", 0) + folium.Polygon( + locations=[[lat, lon] for lon, lat in coords], + color=kleur, + weight=3, + fill=True, + fill_color=kleur, + fill_opacity=0.18, + popup=( + f"{label}
" + f"Oppervlakte: {area_km2:.1f} km²
" + f"Reach factor: {reach:.3f}" + ), + tooltip=tooltip, + ).add_to(m) + + +def genereer_kaart(adres, output_file="/root/adres-analyse/kaart.html"): + locatie = geocode_adres(adres) + lat, lon = locatie["lat"], locatie["lon"] + + auto_iso = haal_isochroon(lon, lat, "driving-car", 20) + fiets_iso = haal_isochroon(lon, lat, "cycling-regular", 30) + + m = folium.Map(location=[lat, lon], zoom_start=11, tiles="OpenStreetMap") + + folium.Marker( + [lat, lon], + popup=locatie["adres"], + tooltip=locatie["adres"], + icon=folium.Icon(color="red", icon="home"), + ).add_to(m) + + voeg_isochroon_toe(m, auto_iso, "Auto 20 min", "#2196F3", "Bereikbaar in 20 min (auto)") + voeg_isochroon_toe(m, fiets_iso, "Fiets 30 min", "#2E7D32", "Bereikbaar in 30 min (fiets)") + + all_bounds = polygon_bounds(auto_iso) + polygon_bounds(fiets_iso) + if all_bounds: + m.fit_bounds(all_bounds) + + title_html = f''' +
+ 📍 Adres Analyse
+ {locatie["adres"]}
+ ■ 20 min autorijden
+ ■ 30 min fietsen +
+ ''' + m.get_root().html.add_child(folium.Element(title_html)) + + m.save(output_file) + print(f"Kaart gegenereerd: {output_file}") + print(f"Adres: {locatie['adres']}") + print(f"Coördinaten: {lat}, {lon}") + + +if __name__ == "__main__": + adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best" + output = sys.argv[2] if len(sys.argv) > 2 else "/root/adres-analyse/kaart.html" + genereer_kaart(adres, output) diff --git a/scripts/osm-poi-import.py b/scripts/osm-poi-import.py new file mode 100644 index 0000000..a676e63 --- /dev/null +++ b/scripts/osm-poi-import.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Importeer alleen benodigde POI's uit een OSM PBF naar PostGIS. +Veel lichter dan volledige osm2pgsql-import voor heel Nederland. +""" + +import argparse +import os + +import osmium +import psycopg2 +from psycopg2.extras import execute_values + +DB_DSN = os.environ.get( + "ADRES_ANALYSE_DB_DSN", + "host=127.0.0.1 port=5433 dbname=adresanalyse user=adres password=adres", +) + +SCHEMA_SQL = """ +CREATE EXTENSION IF NOT EXISTS postgis; +DROP TABLE IF EXISTS osm_pois; +CREATE TABLE osm_pois ( + id bigserial PRIMARY KEY, + osm_type text NOT NULL, + osm_id bigint NOT NULL, + category text NOT NULL, + name text NOT NULL, + brand text, + operator text, + lon double precision NOT NULL, + lat double precision NOT NULL, + geom geometry(Point, 4326) NOT NULL +); +CREATE INDEX osm_pois_category_geom_gix ON osm_pois USING GIST (geom); +CREATE INDEX osm_pois_category_idx ON osm_pois (category); +CREATE INDEX osm_pois_name_idx ON osm_pois (lower(name)); + +CREATE OR REPLACE VIEW poi_supermarkten AS +SELECT osm_id, name, 'supermarkt'::text AS category, 'supermarket'::text AS shop, NULL::text AS amenity, brand, operator, geom +FROM osm_pois WHERE category = 'supermarkt'; + +CREATE OR REPLACE VIEW poi_scholen AS +SELECT osm_id, name, 'school'::text AS category, NULL::text AS shop, 'school'::text AS amenity, brand, operator, geom +FROM osm_pois WHERE category = 'school'; +""" + + +def poi_from_tags(tags): + if tags.get("shop") == "supermarket": + return "supermarkt", tags.get("name") or tags.get("brand") or "Supermarkt" + if tags.get("amenity") == "school": + return "school", tags.get("name") or "School" + return None, None + + +class PoiHandler(osmium.SimpleHandler): + def __init__(self, conn, batch_size=1000): + super().__init__() + self.conn = conn + self.batch_size = batch_size + self.rows = [] + self.count = 0 + self.factory = osmium.geom.WKBFactory() + + def flush(self): + if not self.rows: + return + with self.conn.cursor() as cur: + execute_values( + cur, + """ + INSERT INTO osm_pois (osm_type, osm_id, category, name, brand, operator, lon, lat, geom) + VALUES %s + """, + self.rows, + ) + self.conn.commit() + self.rows.clear() + + def add_row(self, osm_type, osm_id, tags, lon, lat): + category, name = poi_from_tags(tags) + if not category: + return + self.rows.append(( + osm_type, + int(osm_id), + category, + name, + tags.get("brand"), + tags.get("operator"), + float(lon), + float(lat), + f"SRID=4326;POINT({float(lon)} {float(lat)})", + )) + self.count += 1 + if len(self.rows) >= self.batch_size: + self.flush() + + def node(self, n): + if not n.location.valid(): + return + self.add_row("node", n.id, n.tags, n.location.lon, n.location.lat) + + def area(self, a): + category, _ = poi_from_tags(a.tags) + if not category: + return + try: + wkb = self.factory.create_multipolygon(a) + with self.conn.cursor() as cur: + cur.execute("SELECT ST_X(p), ST_Y(p) FROM (SELECT ST_PointOnSurface(ST_GeomFromWKB(%s, 4326)) p) s", (psycopg2.Binary(wkb),)) + lon, lat = cur.fetchone() + self.add_row("area", a.orig_id(), a.tags, lon, lat) + except Exception: + return + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("pbf") + args = parser.parse_args() + + with psycopg2.connect(DB_DSN) as conn: + with conn.cursor() as cur: + cur.execute(SCHEMA_SQL) + conn.commit() + handler = PoiHandler(conn) + handler.apply_file(args.pbf, locations=True) + handler.flush() + with conn.cursor() as cur: + cur.execute("ANALYZE osm_pois") + conn.commit() + print(f"Imported {handler.count} POIs into osm_pois") + + +if __name__ == "__main__": + main() diff --git a/scripts/rapport.py b/scripts/rapport.py new file mode 100644 index 0000000..7def870 --- /dev/null +++ b/scripts/rapport.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +Adres Analyse - Rapport generator +Gebruikt PDOK voor geocoding en ORS voor isochronen +Slaat rapport ook op in Obsidian vault (map: huizenjacht) +""" + +import json +import re +import sys +import urllib.request +import urllib.parse +from datetime import datetime + +# Config +ORS_BASE = "http://192.168.1.71:9080/ors/v2" +PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1" +OBSIDIAN_BASE = "http://192.168.1.50:27123" +OBSIDIAN_TOKEN = "90ec6de948dc8ef7832fda3516fd900c7a3071dd526edf852ade145aa9daf637" + +def geocode_adres(adres): + """Geocode adres naar coördinaten via PDOK Locatieserver""" + # Gebruik suggest endpoint voor betere adres matching + params = urllib.parse.urlencode({ + 'q': adres, + 'rows': 5, + 'fl': 'centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer' + }) + + url = f"{PDOK_BASE}/free?{params}" + with urllib.request.urlopen(url, timeout=10) as resp: + data = json.loads(resp.read()) + + if not data['response']['docs']: + raise ValueError(f"Adres niet gevonden: {adres}") + + # Zoek naar exact adres match (type=adres) + doc = None + for d in data['response']['docs']: + if d.get('type') == 'adres': + doc = d + break + + if not doc: + doc = data['response']['docs'][0] # Fallback naar eerste resultaat + + # Parse POINT(lon lat) format + point = doc['centroide_ll'] + coords = point.replace('POINT(', '').replace(')', '').split() + lon, lat = float(coords[0]), float(coords[1]) + + return { + 'adres': doc.get('weergavenaam', adres), + 'postcode': doc.get('postcode', ''), + 'woonplaats': doc.get('woonplaatsnaam', ''), + 'lat': lat, + 'lon': lon + } + +def haal_isochroon(lon, lat, profiel='driving-car', minuten=20): + """Haal isochroon op van ORS""" + url = f"{ORS_BASE}/isochrones/{profiel}" + payload = { + "locations": [[lon, lat]], + "range": [minuten * 60], # seconden + "attributes": ["area", "reachfactor", "total_pop"] + } + + req = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={'Content-Type': 'application/json'}, + method='POST' + ) + + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + +def format_oppervlakte(m2): + """Converteer m² naar leesbaar formaat""" + if m2 >= 1_000_000: + return f"{m2/1_000_000:.1f} km²" + elif m2 >= 10_000: + return f"{m2/10_000:.1f} ha" + else: + return f"{m2:.0f} m²" + +def schrijf_naar_obsidian(rapport_text, adres): + """Schrijf rapport naar Obsidian vault in map huizenjacht""" + # Maak veilige bestandsnaam van adres (verwijder spaties, leestekens, speciale karakters) + safe_name = re.sub(r'[^a-zA-Z0-9]+', '-', adres) + safe_name = safe_name.strip('-') + filename = f"huizenjacht/{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md" + + # Obsidian API verwacht ruwe markdown text + req = urllib.request.Request( + f"{OBSIDIAN_BASE}/vault/{filename}", + data=rapport_text.encode('utf-8'), + headers={ + 'Authorization': f'Bearer {OBSIDIAN_TOKEN}', + 'Content-Type': 'text/markdown' + }, + method='POST' + ) + + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status in [200, 204]: + return f"📝 **Opgeslagen in Obsidian:** `huizenjacht/{safe_name}_*.md`" + else: + return f"⚠️ Obsidian upload mislukt: {resp.status}" + +def genereer_rapport(adres_input): + """Genereer volledig adres analyse rapport""" + # Verzamel rapport in string voor Obsidian + rapport_lines = [] + + def print_line(text=""): + print(text) + rapport_lines.append(text) + + print_line(f"📍 **Adres Analyse Rapport**") + print_line(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}") + print_line() + + # Geocoding + print_line("🔍 Geocoding...") + locatie = geocode_adres(adres_input) + print_line(f"**Adres:** {locatie['adres']}") + print_line(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}") + print_line() + + # Isochroon + print_line("🚗 **Bereikbaarheid (Auto 20 min)**") + iso_data = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20) + + if 'features' in iso_data and iso_data['features']: + props = iso_data['features'][0]['properties'] + area_m2 = props.get('area', 0) + reach = props.get('reachfactor', 0) + + print_line(f"**Oppervlakte:** {format_oppervlakte(area_m2)}") + print_line(f"**Reach factor:** {reach:.3f}") + print_line() + + # Bereken bbox + bbox = iso_data.get('bbox', []) + if len(bbox) == 4: + print_line(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E") + print_line() + + # Save GeoJSON voor kaart generatie + output_file = f"/tmp/isochroon_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson" + with open(output_file, 'w') as f: + json.dump(iso_data, f) + print_line(f"💾 Data opgeslagen: `{output_file}`") + else: + print_line("❌ Geen isochroon data ontvangen") + + print_line() + print_line("---") + print_line("_Onderdeel 1/?: Isochroon bereikbaarheid_") + + # Schrijf naar Obsidian + print_line() + obsidian_result = schrijf_naar_obsidian('\n'.join(rapport_lines), locatie['adres']) + print_line(obsidian_result) + +if __name__ == '__main__': + adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best" + genereer_rapport(adres) diff --git a/scripts/voorzieningen.py b/scripts/voorzieningen.py new file mode 100644 index 0000000..db1ba79 --- /dev/null +++ b/scripts/voorzieningen.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Voorzieningenanalyse op basis van lokale PostGIS OSM-data + ORS Matrix.""" + +import json +import os +import urllib.request +from dataclasses import dataclass, asdict + +import psycopg2 +import psycopg2.extras + +DB_DSN = os.environ.get( + "ADRES_ANALYSE_DB_DSN", + "host=127.0.0.1 port=5433 dbname=adresanalyse user=adres password=adres", +) +ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2") + + +@dataclass +class Voorziening: + categorie: str + naam: str + lat: float + lon: float + hemelsbreed_m: float + fiets_afstand_m: float | None = None + fiets_duur_s: float | None = None + auto_afstand_m: float | None = None + auto_duur_s: float | None = None + bron: str = "OSM/PostGIS" + + def to_dict(self): + return asdict(self) + + +def _matrix(lon, lat, pois, profiel): + if not pois: + return [] + locations = [[lon, lat]] + [[p.lon, p.lat] for p in pois] + payload = json.dumps({ + "locations": locations, + "sources": [0], + "destinations": list(range(1, len(locations))), + "metrics": ["distance", "duration"], + }).encode() + req = urllib.request.Request( + f"{ORS_BASE}/matrix/{profiel}", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read()) + distances = data.get("distances", [[]])[0] + durations = data.get("durations", [[]])[0] + return list(zip(distances, durations)) + + +def verrijk_met_routes(lon, lat, pois): + fiets = _matrix(lon, lat, pois, "cycling-regular") + auto = _matrix(lon, lat, pois, "driving-car") + for i, poi in enumerate(pois): + if i < len(fiets): + poi.fiets_afstand_m, poi.fiets_duur_s = fiets[i] + if i < len(auto): + poi.auto_afstand_m, poi.auto_duur_s = auto[i] + return pois + + +def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000): + if categorie == "supermarkt": + view = "poi_supermarkten" + elif categorie == "school": + view = "poi_scholen" + else: + raise ValueError(f"Onbekende categorie: {categorie}") + + sql = f""" + WITH origin AS ( + SELECT ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography AS geog + ) + SELECT DISTINCT ON (lower(name)) + name, + ST_Y(geom) AS lat, + ST_X(geom) AS lon, + ST_Distance(geom::geography, origin.geog) AS hemelsbreed_m + FROM {view}, origin + WHERE geom IS NOT NULL + AND ST_DWithin(geom::geography, origin.geog, %s) + ORDER BY lower(name), hemelsbreed_m ASC + """ + wrapped = f"SELECT * FROM ({sql}) q ORDER BY hemelsbreed_m ASC LIMIT %s" + with psycopg2.connect(DB_DSN) as conn: + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute(wrapped, (lon, lat, radius_m, limit)) + rows = cur.fetchall() + return [ + Voorziening( + categorie=categorie, + naam=row["name"], + lat=float(row["lat"]), + lon=float(row["lon"]), + hemelsbreed_m=float(row["hemelsbreed_m"]), + ) + for row in rows + ] + + +def analyse_voorzieningen(lon, lat, limit=5): + supermarkten = zoek_voorzieningen(lon, lat, "supermarkt", limit=limit, radius_m=10000) + scholen = zoek_voorzieningen(lon, lat, "school", limit=limit, radius_m=15000) + verrijk_met_routes(lon, lat, supermarkten) + verrijk_met_routes(lon, lat, scholen) + return {"supermarkten": supermarkten, "scholen": scholen} + + +def format_afstand(m): + if m is None: + return "n.b." + if m < 1000: + return f"{m:.0f} m" + return f"{m/1000:.1f} km".replace(".", ",") + + +def format_duur(s): + if s is None: + return "n.b." + minutes = max(1, round(s / 60)) + return f"{minutes} min" + + +def format_voorzieningen_markdown(titel, pois): + lines = [titel] + if not pois: + lines.append("Geen voorzieningen gevonden in zoekradius.") + return "\n".join(lines) + for i, p in enumerate(pois, 1): + lines.extend([ + f"{i}. **{p.naam}**", + f" Hemelsbreed: {format_afstand(p.hemelsbreed_m)}", + f" Fiets: {format_afstand(p.fiets_afstand_m)} / {format_duur(p.fiets_duur_s)}", + f" Auto: {format_afstand(p.auto_afstand_m)} / {format_duur(p.auto_duur_s)}", + ]) + return "\n".join(lines)