Release v2.1 - Obsidian integratie: slaat rapporten op in huizenjacht/ map

This commit is contained in:
2026-06-21 23:03:38 +02:00
commit 2ab650dee7
6 changed files with 813 additions and 0 deletions
+133
View File
@@ -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_
```
+93
View File
@@ -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}
```
+137
View File
@@ -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"<b>{label}</b><br>"
f"Oppervlakte: {area_km2:.1f} km²<br>"
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'''
<div style="position:fixed;top:10px;left:60px;z-index:1000;background:white;padding:10px 15px;border-radius:8px;box-shadow:0 2px 6px rgba(0,0,0,0.3);font-family:sans-serif;font-size:14px;line-height:1.35;">
<b>📍 Adres Analyse</b><br>
{locatie["adres"]}<br>
<span style="color:#2196F3">■</span> 20 min autorijden<br>
<span style="color:#2E7D32">■</span> 30 min fietsen
</div>
'''
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)
+136
View File
@@ -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()
+170
View File
@@ -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)
+144
View File
@@ -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)