diff --git a/rapport.py b/rapport.py index 11e0c65..fa5b308 100644 --- a/rapport.py +++ b/rapport.py @@ -1,321 +1,192 @@ #!/usr/bin/env python3 -""" -Adres Analyse - Rapport generator -Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen. -Slaat rapport en HTML-kaarten op in /root/mkdocs/docs/docs/Huizenjacht/ -""" +"""Adresanalyse voor koopwoningen: bereikbaarheid, gezin, buurt, leefomgeving en klimaat.""" +import html import json -import sys import os import re -import urllib.request +import sys import urllib.parse +import urllib.request from datetime import datetime -import base64 -from io import BytesIO +from pathlib import Path -try: - from PIL import Image - HAS_PIL = True -except ImportError: - HAS_PIL = False +from voorzieningen import ( + analyse_gezinsvoorzieningen, analyse_voorzieningen, format_voorzieningen_markdown, +) -from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown, format_afstand, format_duur - -# Config -ORS_BASE = "http://192.168.1.71:9080/ors/v2" +ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2") PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1" -MKDOCS_DIR = "/root/mkdocs/docs/docs/Huizenjacht" +CBS_WFS = "https://service.pdok.nl/cbs/wijkenbuurten/2025/wfs/v1_0" +MKDOCS_DIR = Path("/root/mkdocs/docs/docs/Huizenjacht") + + +def fetch_json(url, timeout=30, data=None, headers=None): + request = urllib.request.Request(url, data=data, headers=headers or {}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read()) + def geocode_adres(adres): - """Geocode adres naar coördinaten via PDOK Locatieserver""" - 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}") - - doc = None - for d in data['response']['docs']: - if d.get('type') == 'adres': - doc = d - break - - if not doc: - doc = data['response']['docs'][0] - - 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 - } + params = urllib.parse.urlencode({"q": adres, "rows": 10, "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer"}) + docs = fetch_json(f"{PDOK_BASE}/free?{params}")["response"]["docs"] + exact = [d for d in docs if d.get("type") == "adres"] + if not exact: + raise ValueError(f"Geen exact adres gevonden voor: {adres}") + doc = exact[0] + match = re.fullmatch(r"POINT\(([-\d.]+) ([-\d.]+)\)", doc["centroide_ll"]) + if not match: + raise ValueError("PDOK gaf onleesbare coördinaten terug") + lon, lat = map(float, match.groups()) + 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): + body = json.dumps({"locations": [[lon, lat]], "range": [minuten * 60], "attributes": ["area", "reachfactor", "total_pop"]}).encode() + return fetch_json(f"{ORS_BASE}/isochrones/{profiel}", timeout=45, data=body, headers={"Content-Type": "application/json"}) -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], - "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²" + return f"{m2 / 1_000_000:.1f} km²" if m2 >= 1_000_000 else f"{m2 / 10_000:.1f} ha" -def schrijf_naar_mkdocs(content, adres, nummer, ext=".md"): - """Schrijf content naar MkDocs docs map als bestand met oplopend nummer""" - os.makedirs(MKDOCS_DIR, exist_ok=True) - safe_name = re.sub(r'[^a-zA-Z0-9]+', '-', adres) - safe_name = safe_name.strip('-') - filename = f"{nummer}-{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" - filepath = os.path.join(MKDOCS_DIR, filename) - - with open(filepath, 'w', encoding='utf-8') as f: - f.write(content) - - return filepath, filename -def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso, voorzieningen=None): - """Genereer een zelf-contained HTML kaart met Leaflet + POI markers""" - # Bouw JSON voor POI markers - poi_json = {"supermarkten": [], "scholen": []} - if voorzieningen: - for s in voorzieningen.get("supermarkten", []): - poi_json["supermarkten"].append({ - "naam": s.naam, "lat": s.lat, "lon": s.lon, - "hemelsbreed": round(s.hemelsbreed_m), - "fiets": f"{format_afstand(s.fiets_afstand_m)} / {format_duur(s.fiets_duur_s)}" if s.fiets_afstand_m else "n.b.", - "auto": f"{format_afstand(s.auto_afstand_m)} / {format_duur(s.auto_duur_s)}" if s.auto_afstand_m else "n.b.", - }) - for s in voorzieningen.get("scholen", []): - poi_json["scholen"].append({ - "naam": s.naam, "lat": s.lat, "lon": s.lon, - "hemelsbreed": round(s.hemelsbreed_m), - "fiets": f"{format_afstand(s.fiets_afstand_m)} / {format_duur(s.fiets_duur_s)}" if s.fiets_afstand_m else "n.b.", - "auto": f"{format_afstand(s.auto_afstand_m)} / {format_duur(s.auto_duur_s)}" if s.auto_afstand_m else "n.b.", - }) - html = f''' - - - - -Isochroon - {adres_naam} - - - - - -
- - -''' - return html def print_isochroon_sectie(titel, icoon, iso_data): - """Genereer isochroon sectie als tekst""" - lines = [] - lines.append(f"{icoon} **{titel}**") - 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) - lines.append(f"**Oppervlakte:** {format_oppervlakte(area_m2)}") - lines.append(f"**Reach factor:** {reach:.3f}") - bbox = iso_data.get('bbox', []) - if len(bbox) == 4: - lines.append(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E") - else: - lines.append("❌ Geen isochroon data ontvangen") - lines.append("") + if not iso_data or not iso_data.get("features"): + return f"{icoon} **{titel}**\nNiet beschikbaar (route-service reageerde niet).\n" + props = iso_data["features"][0].get("properties", {}) + lines = [f"{icoon} **{titel}**", f"**Oppervlakte:** {format_oppervlakte(props.get('area', 0))}", f"**Reach factor:** {props.get('reachfactor', 0):.3f}"] + if len(iso_data.get("bbox", [])) == 4: + b = iso_data["bbox"] + lines.append(f"**Bereik:** {b[1]:.3f}°–{b[3]:.3f} N, {b[0]:.3f}°–{b[2]:.3f} E") + return "\n".join(lines) + "\n" + + +def wgs84_naar_rd(lon, lat): + """WGS84 naar RD New (EPSG:28992), voldoende nauwkeurig voor point-in-buurt-WFS.""" + dphi, dlambda = 0.36 * (lat - 52.15517440), 0.36 * (lon - 5.38720621) + x = 155000 + (190094.945 * dlambda - 11832.228 * dphi * dlambda - 114.221 * dlambda**3 - 32.391 * dphi**2 * dlambda - .705 * dphi * dlambda**3 - 2.34 * dphi**3 * dlambda - .608 * dphi * dlambda**5 - .008 * dphi**2 * dlambda**3) + y = 463000 + (309056.544 * dphi + 3638.893 * dlambda**2 + 73.077 * dphi**2 - 157.984 * dphi * dlambda**2 + 59.788 * dphi**3 + .433 * dlambda**4 - 6.439 * dphi**2 * dlambda**2 - .032 * dphi * dlambda**4 + .092 * dlambda**6 - .054 * dphi**3 * dlambda**2) + return x, y + + +def cbs_buurtprofiel(lon, lat): + """Bepaal de CBS-buurt via een puntselectie in de officiële PDOK WFS.""" + x, y = wgs84_naar_rd(lon, lat) + query = {"service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "wijkenbuurten:buurten", "outputFormat": "json", "bbox": f"{x},{y},{x},{y},EPSG:28992", "count": "1"} + try: + features = fetch_json(f"{CBS_WFS}?{urllib.parse.urlencode(query)}", timeout=45).get("features", []) + return features[0]["properties"] if features else None + except Exception: + return None + + +def format_pct(value): + return "n.b." if value is None or value < 0 else f"{value}%" + + +def buurt_markdown(buurt): + if not buurt: + return "CBS-buurtprofiel kon niet betrouwbaar aan deze postcode worden gekoppeld." + p = buurt + stedelijk = {1: "zeer sterk stedelijk", 2: "sterk stedelijk", 3: "matig stedelijk", 4: "weinig stedelijk", 5: "niet-stedelijk"}.get(p.get("stedelijkheidAdressenPerKm2"), "n.b.") + return "\n".join([ + f"**CBS-buurt:** {p.get('buurtnaam', 'n.b.')} ({p.get('buurtcode', 'n.b.')})", + f"• Inwoners: **{p.get('aantalInwoners', 'n.b.')}** · 0–15 jaar: **{format_pct(p.get('percentagePersonen0Tot15Jaar'))}** · huishoudens met kinderen: **{format_pct(p.get('percentageHuishoudensMetKinderen'))}**", + f"• Woningvoorraad: eengezinswoningen **{format_pct(p.get('percentageEengezinswoning'))}** · koopwoningen **{format_pct(p.get('percentageKoopwoningen'))}** · gemiddelde WOZ **€ {p.get('gemiddeldeWOZWaarde', 'n.b.')}k**", + f"• Dichtheid: **{p.get('omgevingsadressendichtheid', 'n.b.')} adressen/km²** ({stedelijk}).", + "*CBS-cijfers zijn buurtgemiddelden; ze zeggen niets definitiefs over de individuele straat of woning.*", + ]) + + +def leefomgeving_markdown(locatie): + lat, lon = locatie["lat"], locatie["lon"] + atlas = f"https://www.atlasleefomgeving.nl/kaart?x={lon:.5f}&y={lat:.5f}&z=14" + politie = f"https://data.politie.nl/#/Politie/nl/dataset/47013NED" + return "\n".join([ + "De volgende thema's zijn essentieel, maar worden niet als automatische groen/rood-score gepresenteerd: kaartlagen kunnen verschillende peiljaren en resoluties hebben.", + f"• [Geluid, luchtkwaliteit, groen en externe veiligheid – Atlas Leefomgeving]({atlas})", + f"• [Geregistreerde criminaliteit per buurt – Politie/CBS]({politie})", + "• Controleer bij bezichtiging zelf verkeersdrukte, oversteekplaatsen, verlichting en geluid op ochtend-, spits- én avondmomenten.", + ]) + + +def klimaat_markdown(locatie): + link = f"https://www.klimaateffectatlas.nl/nl/kaartviewer?x={locatie['lon']:.5f}&y={locatie['lat']:.5f}&z=14" + return "\n".join([ + f"[Open de Klimaateffectatlas op deze locatie]({link}) en bekijk ten minste wateroverlast bij piekbui, hitte, droogte en overstroming.", + "*Dit is een eerste locatie-indicatie. Vraag bij serieuze koopintentie naar wateroverlastgeschiedenis, kruipruimte, drainage, fundering en verzekering.*", + ]) + + +def gezins_score(gezins, buurt): + checks = [ + ("Kinderopvang", bool(gezins.get("kinderopvang"))), ("Speelplek", bool(gezins.get("speelplekken"))), + ("Basisschool", True), ("Huisarts", bool(gezins.get("huisartsen"))), + ("Supermarkt", True), ("Buurt met kinderen", bool(buurt and buurt.get("percentageHuishoudensMetKinderen", -1) >= 15)), + ] + lines = ["Dit is een transparante startcheck, geen koopadvies. Oranje betekent: nader onderzoeken."] + for label, ok in checks: + lines.append(f"• {'🟢' if ok else '🟠'} **{label}:** {'aanwezig in brondata' if ok else 'niet bevestigd; zelf controleren'}") + lines += ["• 🟠 **Verkeersveiligheid / geluid:** beoordeel ter plekke op meerdere momenten.", "• 🟠 **Klimaat / water:** controleer de kaartlagen en woninghistorie."] return "\n".join(lines) -def genereer_rapport(adres_input): - """Genereer volledig adres analyse rapport""" - lines = [] - - # Tel bestaande genummerde rapporten voor oplopend nummer - # Alleen bestanden die beginnen met cijfer-dash (bv. "1-Griendvelden...") - alle_files = os.listdir(MKDOCS_DIR) if os.path.exists(MKDOCS_DIR) else [] - genummerde_files = [f for f in alle_files if f.endswith('.md') and re.match(r'^\d+-', f)] - nummer = len(genummerde_files) + 1 - - lines.append(f"# {nummer}. {adres_input}") - lines.append(f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*") - lines.append("") - - lines.append("## 🔍 Geocoding") - locatie = geocode_adres(adres_input) - lines.append(f"**Adres:** {locatie['adres']}") - lines.append(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}") - lines.append("") - - auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20) - fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30) - - lines.append("## 🚗 Bereikbaarheid") - lines.append(print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso)) - lines.append(print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso)) - - lines.append("## 🏙️ Voorzieningen dichtbij") - voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5) - lines.append(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten'])) - lines.append("") - lines.append(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen'])) - lines.append("") - - # Genereer interactieve HTML kaart en sla op in MkDocs - html_content = genereer_isochroon_html( - locatie['adres'], locatie['lat'], locatie['lon'], auto_iso, fiets_iso, - voorzieningen=voorzieningen - ) - html_filepath, html_filename = schrijf_naar_mkdocs(html_content, locatie['adres'], nummer, ext=".html") - - # Voeg link toe aan markdown rapport (betrouwbaar via directe link) - lines.append("## 🗺️ Interactieve kaart") - lines.append(f"") - lines.append(f"**[📍 Bekijk interactieve isochroon kaart]({html_filename})**{{:target=\"_blank\"}}") - lines.append("") - lines.append("*Klik op de link om de kaart in een nieuw tabblad te openen. Hier kun je zoomen, pannen en de isochronen bekijken.*") - lines.append("") - - # Ruwe geojson - output_file = f"/tmp/isochronen_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson" - with open(output_file, 'w') as f: - json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f) - lines.append("---") - lines.append("*Onderdelen: geocoding, isochronen, voorzieningen, interactieve kaart*") - - rapport_text = "\n".join(lines) - - # Print naar stdout - print(rapport_text) - - # Sla op in MkDocs - md_filepath, md_filename = schrijf_naar_mkdocs(rapport_text, adres_input, nummer, ext=".md") - print(f"\n📝 Rapport opgeslagen: {md_filepath}") -if __name__ == '__main__': - adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best" - genereer_rapport(adres) +def genereer_isochroon_html(adres, lat, lon, auto_iso, fiets_iso): + safe = html.escape(adres) + auto_json, fiets_json = json.dumps(auto_iso or {}, ensure_ascii=False), json.dumps(fiets_iso or {}, ensure_ascii=False) + return f'''Kaart – {safe}
''' + + +def genereer_rapport(adres_input): + nummer, waarschuwingen = hoogste_rapportnummer(), [] + locatie = geocode_adres(adres_input) + auto_iso = fiets_iso = None + for naam, profiel, minuten in [("auto", "driving-car", 20), ("fiets", "cycling-regular", 30)]: + try: + data = haal_isochroon(locatie["lon"], locatie["lat"], profiel, minuten) + if naam == "auto": auto_iso = data + else: fiets_iso = data + except Exception as exc: + waarschuwingen.append(f"Isochroon {naam} niet beschikbaar: {exc}") + try: + voorzieningen = analyse_voorzieningen(locatie["lon"], locatie["lat"]) + except Exception as exc: + voorzieningen = {"supermarkten": [], "scholen": []}; waarschuwingen.append(f"Basisvoorzieningen niet beschikbaar: {exc}") + gezins = analyse_gezinsvoorzieningen(locatie["lon"], locatie["lat"]) + buurt = cbs_buurtprofiel(locatie["lon"], locatie["lat"]) + lines = [f"# {nummer}. {locatie['adres']}", f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*", "", "## 🔍 Geocoding", f"**Adres:** {locatie['adres']}", f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}", "", "## 🚗 Bereikbaarheid", print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso), print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso), "## 🏙️ Basisvoorzieningen", format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen["supermarkten"]), "", format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen["scholen"]), "", "## 👨‍👩‍👧‍👦 Gezinsvoorzieningen"] + titles = {"kinderopvang": "🧸 **Kinderopvang / BSO**", "speelplekken": "🛝 **Speelplekken**", "huisartsen": "🩺 **Huisartsen**", "apotheken": "💊 **Apotheken**", "bibliotheken": "📚 **Bibliotheken**", "sport": "⚽ **Sportvoorzieningen**"} + for key, title in titles.items(): lines.extend([format_voorzieningen_markdown(title, gezins.get(key, [])), ""]) + lines += ["## 🏫 Schoolprofiel", "De scholen hierboven zijn nabijheidsinformatie uit OSM. Controleer onderwijsconcept, capaciteit, opvang en inspectie-/schoolcijfers rechtstreeks bij de school en DUO; deze veranderen regelmatig.", "", "## 🏘️ Buurt- en gezinsprofiel", buurt_markdown(buurt), "", "## 🛡️ Veiligheid & leefomgeving", leefomgeving_markdown(locatie), "", "## 🌦️ Klimaat & water", klimaat_markdown(locatie), "", "## ✅ Praktische gezinsscorekaart", gezins_score(gezins, buurt), ""] + map_file = None + try: + _, map_file = schrijf_naar_mkdocs(genereer_isochroon_html(locatie["adres"], locatie["lat"], locatie["lon"], auto_iso, fiets_iso), locatie["adres"], nummer, ".html") + lines += ["## 🗺️ Interactieve kaart", f"**[📍 Bekijk de isochroonkaart]({map_file})**{{:target=\"_blank\"}}", ""] + except Exception as exc: waarschuwingen.append(f"Kaart niet opgeslagen: {exc}") + if waarschuwingen: + lines += ["## ⚠️ Datakwaliteit / tijdelijke storingen"] + [f"• {x}" for x in waarschuwingen] + [""] + lines += ["---", "*Bronnen: PDOK, OpenStreetMap, ORS, CBS Wijken en Buurten 2025, Atlas Leefomgeving en Klimaateffectatlas. Geen vervanging voor bezichtiging, bouwkundige keuring of professioneel advies.*"] + text = "\n".join(lines) + path, _ = schrijf_naar_mkdocs(text, locatie["adres"], nummer, ".md") + print(text); print(f"\n📝 Rapport opgeslagen: {path}") + return path + + +if __name__ == "__main__": + try: genereer_rapport(sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best") + except Exception as exc: + print(f"❌ Rapportgeneratie mislukt: {exc}", file=sys.stderr); raise diff --git a/voorzieningen.py b/voorzieningen.py index 1b9a755..96427ad 100644 --- a/voorzieningen.py +++ b/voorzieningen.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Voorzieningenanalyse op basis van lokale PostGIS OSM-data + ORS Matrix.""" +"""Voorzieningenanalyse met lokale PostGIS-OSM-data en ORS Matrix-routes.""" import json import math @@ -45,113 +45,129 @@ def _matrix(lon, lat, pois, profiel): "metrics": ["distance", "duration"], }).encode() req = urllib.request.Request( - f"{ORS_BASE}/matrix/{profiel}", - data=payload, - headers={"Content-Type": "application/json"}, - method="POST", + 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)) + return list(zip(data.get("distances", [[]])[0], data.get("durations", [[]])[0])) 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] + """Verrijk POI's; een defect profiel maakt het hele rapport niet onbruikbaar.""" + for profiel, afstand_attr, duur_attr in [ + ("cycling-regular", "fiets_afstand_m", "fiets_duur_s"), + ("driving-car", "auto_afstand_m", "auto_duur_s"), + ]: + try: + routes = _matrix(lon, lat, pois, profiel) + except Exception: + routes = [] + for poi, route in zip(pois, routes): + setattr(poi, afstand_attr, route[0]) + setattr(poi, duur_attr, route[1]) 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}") +def _haversine_m(lon1, lat1, lon2, lat2): + radius = 6_371_000 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi, dlambda = math.radians(lat2 - lat1), math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + return 2 * radius * math.asin(math.sqrt(a)) + +def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000): + views = {"supermarkt": "poi_supermarkten", "school": "poi_scholen"} + if categorie not in views: + raise ValueError(f"Onbekende lokale 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, + 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) + FROM {views[categorie]}, origin + WHERE geom IS NOT NULL AND ST_DWithin(geom::geography, origin.geog, %s) ORDER BY lower(name), hemelsbreed_m ASC """ - # DISTINCT ON kiest dichtstbijzijnde per naam; buitenquery sorteert weer op afstand. - 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)) + cur.execute(f"SELECT * FROM ({sql}) q ORDER BY hemelsbreed_m ASC LIMIT %s", (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 - ] + return [Voorziening(categorie, row["name"], float(row["lat"]), float(row["lon"]), float(row["hemelsbreed_m"])) for row in rows] + + +def zoek_osm_voorzieningen(lon, lat, categorie, tags, limit=5, radius_m=10000): + """Vraag kleinschalige gezins-POI's live op via Overpass, met een vaste fallback-endpoint.""" + south, west = lat - radius_m / 111_320, lon - radius_m / (111_320 * math.cos(math.radians(lat))) + north, east = lat + radius_m / 111_320, lon + radius_m / (111_320 * math.cos(math.radians(lat))) + tag_filter = "".join(f'["{key}"="{value}"]' for key, value in tags.items()) + query = f"[out:json][timeout:45];(nwr{tag_filter}({south},{west},{north},{east}););out center;" + request = urllib.request.Request( + "https://overpass.kumi.systems/api/interpreter", data=query.encode(), + headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "adres-analyse/3.0"}, + ) + with urllib.request.urlopen(request, timeout=70) as response: + elements = json.loads(response.read()).get("elements", []) + found = [] + for element in elements: + point = element.get("center", element) + if "lat" not in point or "lon" not in point: + continue + name = element.get("tags", {}).get("name") or categorie.title() + found.append(Voorziening(categorie, name, float(point["lat"]), float(point["lon"]), _haversine_m(lon, lat, point["lon"], point["lat"]), bron="OpenStreetMap/Overpass")) + # Een naam komt vaak als node én als vlak voor; behoud de dichtstbijzijnde. + unique = {} + for poi in sorted(found, key=lambda p: p.hemelsbreed_m): + unique.setdefault(poi.naam.lower(), poi) + return list(unique.values())[:limit] 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} + result = { + "supermarkten": zoek_voorzieningen(lon, lat, "supermarkt", limit, 10000), + "scholen": zoek_voorzieningen(lon, lat, "school", limit, 15000), + } + for pois in result.values(): + verrijk_met_routes(lon, lat, pois) + return result + + +def analyse_gezinsvoorzieningen(lon, lat, limit=5): + specs = { + "kinderopvang": ({"amenity": "kindergarten"}, 10000), + "speelplekken": ({"leisure": "playground"}, 5000), + "huisartsen": ({"amenity": "doctors"}, 10000), + "apotheken": ({"amenity": "pharmacy"}, 10000), + "bibliotheken": ({"amenity": "library"}, 15000), + "sport": ({"leisure": "sports_centre"}, 10000), + } + result = {} + for key, (tags, radius) in specs.items(): + try: + result[key] = zoek_osm_voorzieningen(lon, lat, key, tags, limit, radius) + verrijk_met_routes(lon, lat, result[key]) + except Exception as exc: + result[key] = [] + result[f"{key}_fout"] = str(exc) + return result 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(".", ",") + return f"{m:.0f} m" if m < 1000 else 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" + return f"{max(1, round(s / 60))} min" def format_voorzieningen_markdown(titel, pois): lines = [titel] if not pois: - lines.append("Geen voorzieningen gevonden in zoekradius.") - return "\n".join(lines) + return "\n".join(lines + ["Geen voorzieningen gevonden of bron tijdelijk niet beschikbaar."]) for p in pois: - lines.append( - f"• **{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)}" - ) + lines.append(f"• **{p.naam}** — Hemelsbreed: {format_afstand(p.hemelsbreed_m)} | Fiets: {format_afstand(p.fiets_afstand_m)} / {format_duur(p.fiets_duur_s)} | Auto: {format_afstand(p.auto_afstand_m)} / {format_duur(p.auto_duur_s)}") return "\n".join(lines) - - -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser() - parser.add_argument("lon", type=float) - parser.add_argument("lat", type=float) - parser.add_argument("--limit", type=int, default=5) - args = parser.parse_args() - result = analyse_voorzieningen(args.lon, args.lat, args.limit) - print(json.dumps({k: [p.to_dict() for p in v] for k, v in result.items()}, indent=2, ensure_ascii=False))