#!/usr/bin/env python3 """Adresanalyse voor koopwoningen: bereikbaarheid, gezin, buurt, leefomgeving en klimaat.""" import html import json import os import re import sys import urllib.parse import urllib.request from datetime import datetime from pathlib import Path from voorzieningen import ( analyse_gezinsvoorzieningen, analyse_voorzieningen, format_voorzieningen_markdown, ) 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" 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): 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 format_oppervlakte(m2): return f"{m2 / 1_000_000:.1f} km²" if m2 >= 1_000_000 else f"{m2 / 10_000:.1f} ha" def hoogste_rapportnummer(): MKDOCS_DIR.mkdir(parents=True, exist_ok=True) numbers = [int(m.group(1)) for path in MKDOCS_DIR.glob("*.md") if (m := re.match(r"^(\d+)-", path.name))] return max(numbers, default=0) + 1 def schrijf_naar_mkdocs(content, adres, nummer, ext): safe = re.sub(r"[^a-zA-Z0-9]+", "-", adres).strip("-") filename = f"{nummer}-{safe}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" path = MKDOCS_DIR / filename path.write_text(content, encoding="utf-8") return path, filename def print_isochroon_sectie(titel, icoon, iso_data): 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_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'''