v3.0: add family, neighbourhood and living environment analysis

This commit is contained in:
Hermes Agent
2026-07-20 05:48:46 +02:00
parent 3edea47e88
commit 84f9353a51
2 changed files with 257 additions and 370 deletions
+162 -291
View File
@@ -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'
})
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}
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}")
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"})
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
}
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)
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
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'''<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Isochroon - {adres_naam}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body {{ margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; }}
#map {{ height: 100vh; width: 100vw; }}
.legend {{ background: white; padding: 10px; border-radius: 4px; box-shadow: 0 0 15px rgba(0,0,0,0.2); line-height: 1.5; }}
.legend i {{ width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7; border: 1px solid #333; }}
</style>
</head>
<body>
<div id="map"></div>
<script>
var autoGeojson = {json.dumps(auto_iso, ensure_ascii=False)};
var fietsGeojson = {json.dumps(fiets_iso, ensure_ascii=False)};
var voorzieningen = {json.dumps(poi_json, ensure_ascii=False)};
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
var map = L.map('map').setView([{lat}, {lon}], 11);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}}).addTo(map);
var autoLayer = L.geoJSON(autoGeojson, {{
style: {{ color: '#2563eb', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.2 }},
onEachFeature: function(feature, layer) {{
var props = feature.properties;
layer.bindPopup('<b>Auto 20 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km²<br>Reach factor: ' + props.reachfactor.toFixed(3));
}}
}}).addTo(map);
var fietsLayer = L.geoJSON(fietsGeojson, {{
style: {{ color: '#16a34a', weight: 2, fillColor: '#22c55e', fillOpacity: 0.2 }},
onEachFeature: function(feature, layer) {{
var props = feature.properties;
layer.bindPopup('<b>Fiets 30 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km²<br>Reach factor: ' + props.reachfactor.toFixed(3));
}}
}}).addTo(map);
// Adres marker
L.marker([{lat}, {lon}]).addTo(map)
.bindPopup('<b>{adres_naam}</b>').openPopup();
// POI markers
var supermarktIcon = L.divIcon({{
className: 'poi-marker-supermarkt',
html: '<div style="background:#2563eb;color:white;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3)">S</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -14]
}});
var schoolIcon = L.divIcon({{
className: 'poi-marker-school',
html: '<div style="background:#ea580c;color:white;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:bold;border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3)">S</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -14]
}});
// Supermarkten
if (voorzieningen.supermarkten) {{
voorzieningen.supermarkten.forEach(function(p) {{
L.marker([p.lat, p.lon], {{icon: supermarktIcon}}).addTo(map)
.bindPopup(
'<b>🛒 ' + p.naam + '</b><br>' +
'Hemelsbreed: ' + p.hemelsbreed + ' m<br>' +
'Fiets: ' + p.fiets + '<br>' +
'Auto: ' + p.auto
);
}});
}}
// Scholen
if (voorzieningen.scholen) {{
voorzieningen.scholen.forEach(function(p) {{
L.marker([p.lat, p.lon], {{icon: schoolIcon}}).addTo(map)
.bindPopup(
'<b>🏫 ' + p.naam + '</b><br>' +
'Hemelsbreed: ' + p.hemelsbreed + ' m<br>' +
'Fiets: ' + p.fiets + '<br>' +
'Auto: ' + p.auto
);
}});
}}
var legend = L.control({{position: 'bottomright'}});
legend.onAdd = function(map) {{
var div = L.DomUtil.create('div', 'legend');
div.innerHTML += '<h4 style="margin:0 0 5px 0">Isochronen</h4>';
div.innerHTML += '<i style="background:#3b82f6"></i> Auto 20 min<br>';
div.innerHTML += '<i style="background:#22c55e"></i> Fiets 30 min<br>';
div.innerHTML += '<i style="background:#ef4444"></i> Adres<br>';
div.innerHTML += '<i style="background:#2563eb;border-radius:50%;width:12px;height:12px;border:1px solid #999"></i> Supermarkt<br>';
div.innerHTML += '<i style="background:#ea580c;border-radius:50%;width:12px;height:12px;border:1px solid #999"></i> School';
return div;
}};
legend.addTo(map);
map.fitBounds(autoLayer.getBounds().extend(fietsLayer.getBounds()));
</script>
</body>
</html>'''
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_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'''<!doctype html><html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Kaart – {safe}</title><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"><style>html,body,#map{{height:100%;margin:0}}</style></head><body><div id="map"></div><script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script><script>const map=L.map('map').setView([{lat},{lon}],12);L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{attribution:'© OpenStreetMap'}}).addTo(map);const layers=[];for(const [data,color,label] of [[{auto_json},'#2563eb','Auto 20 min'],[{fiets_json},'#16a34a','Fiets 30 min']]){{if(data.features?.length){{const l=L.geoJSON(data,{{style:{{color,fillColor:color,fillOpacity:.18}}}}).bindPopup(label).addTo(map);layers.push(l)}}}}L.marker([{lat},{lon}]).addTo(map).bindPopup('<b>{safe}</b>').openPopup();if(layers.length)map.fitBounds(L.featureGroup(layers).getBounds());</script></body></html>'''
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")
nummer, waarschuwingen = hoogste_rapportnummer(), []
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 = 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
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)
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
+90 -74
View File
@@ -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))