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
+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)