199 lines
9.6 KiB
Python
199 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Rebuild data/geo/SE/*.tsv from SCB (CC0), GeoNames (CC BY 4.0) and Trafikverket NVDB (CC0).
|
|
|
|
TRAFIKVERKET_API_KEY=… data-import/geo-se.py [--key-file FILE] [--cache DIR] [--streets-per-locality N] [--out DIR]
|
|
"""
|
|
import collections
|
|
import csv
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import geo
|
|
import source
|
|
import tsv
|
|
import xlsx
|
|
|
|
CODES = "https://www.scb.se/contentassets/7a89e48960f741e08918e489ea36354a/kommunlankod-2026.xlsx"
|
|
POPULATION = "https://api.scb.se/OV0104/v1/doris/sv/ssd/START/BE/BE0101/BE0101A/BefolkningNy"
|
|
POPULATION_QUERY = {
|
|
"query": [
|
|
{"code": "Region", "selection": {"filter": "all", "values": ["*"]}},
|
|
{"code": "ContentsCode", "selection": {"filter": "item", "values": ["BE0101N1"]}},
|
|
{"code": "Tid", "selection": {"filter": "top", "values": ["1"]}},
|
|
],
|
|
"response": {"format": "json"},
|
|
}
|
|
TATORTER = "https://geodata.scb.se/geoserver/stat/wfs?service=WFS&version=2.0.0&request=GetFeature&typeNames=stat:Tatorter_2023&outputFormat=csv&propertyName=tatort,kommun,bef"
|
|
POSTAL_CODES = "https://download.geonames.org/export/zip/SE.zip"
|
|
NVDB = "https://api.trafikinfo.trafikverket.se/v2/data.json"
|
|
NVDB_PAGE = 50000
|
|
TIMEZONE = "Europe/Stockholm"
|
|
# This script's reading of the "exceptions in big cities" in docs/research/research-geo-se.md, Postal codes → Structure.
|
|
ONE_POSITION = {"Stockholm", "Göteborg", "Malmö"}
|
|
# A tätort's floor: "minst 200 invånare", docs/research/research-geo-se.md, Lantmäteriet → Ortnamn.
|
|
UNMATCHED_POPULATION = 200
|
|
|
|
|
|
def scb_codes(cache):
|
|
regions, municipalities = {}, {}
|
|
for cells in xlsx.rows(source.fetch(CODES, cache, "kommunlankod.xlsx", magic=b"PK")):
|
|
if len(cells) < 2 or not re.fullmatch(r"\d{2}|\d{4}", cells[0]):
|
|
continue
|
|
(regions if len(cells[0]) == 2 else municipalities)[cells[0]] = cells[1].strip()
|
|
return regions, municipalities
|
|
|
|
|
|
def scb_population(cache):
|
|
body = json.dumps(POPULATION_QUERY).encode()
|
|
data = source.fetch(POPULATION, cache, "befolkning.json", data=body, headers={"Content-Type": "application/json"})
|
|
return {row["key"][0]: row["values"][0] for row in json.loads(data.decode("utf-8-sig"))["data"]}
|
|
|
|
|
|
def scb_tatorter(cache):
|
|
text = source.fetch(TATORTER, cache, "tatorter.csv").decode("utf-8")
|
|
by_name = collections.defaultdict(list)
|
|
for r in csv.DictReader(io.StringIO(text)):
|
|
by_name[r["tatort"]].append((r["kommun"], int(r["bef"])))
|
|
return by_name
|
|
|
|
|
|
def geonames(cache):
|
|
z = zipfile.ZipFile(io.BytesIO(source.fetch(POSTAL_CODES, cache, "SE.zip", magic=b"PK")))
|
|
rows = []
|
|
for line in z.read("SE.txt").decode("utf-8").splitlines():
|
|
f = line.split("\t")
|
|
lat, lon = (float(f[9]), float(f[10])) if f[9] and f[10] else (None, None)
|
|
rows.append({"code": f[1], "locality": f[2], "municipality": f[6], "lat": lat, "lon": lon})
|
|
return rows
|
|
|
|
|
|
def nvdb_segments(cache, key):
|
|
path = cache / "nvdb-gatunamn.tsv"
|
|
if not path.exists():
|
|
with open(path.with_suffix(".part"), "w", encoding="utf-8") as out:
|
|
change = "0"
|
|
while True:
|
|
query = (
|
|
f'<REQUEST><LOGIN authenticationkey="{key}"/>'
|
|
f'<QUERY objecttype="Gatunamn" namespace="vägdata.nvdb_dk_o" schemaversion="1.0" limit="{NVDB_PAGE}" changeid="{change}">'
|
|
"<FILTER><EQ name=\"Deleted\" value=\"false\"/></FILTER>"
|
|
"<INCLUDE>Namn</INCLUDE><INCLUDE>Geometry.WKT-WGS84-3D</INCLUDE></QUERY></REQUEST>"
|
|
)
|
|
req = urllib.request.Request(NVDB, data=query.encode(), headers={"Content-Type": "text/xml"})
|
|
with urllib.request.urlopen(req, timeout=600) as r:
|
|
result = json.load(r)["RESPONSE"]["RESULT"][0]
|
|
rows = result.get("Gatunamn", [])
|
|
for row in rows:
|
|
m = re.match(r"LINESTRING Z \(([-\d.]+) ([-\d.]+) ", row.get("Geometry", {}).get("WKT-WGS84-3D", ""))
|
|
name = " ".join(row.get("Namn", "").split())
|
|
if m and name:
|
|
out.write(f"{name}\t{m.group(1)}\t{m.group(2)}\n")
|
|
change = result["INFO"]["LASTCHANGEID"]
|
|
if len(rows) < NVDB_PAGE:
|
|
break
|
|
path.with_suffix(".part").rename(path)
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
name, lon, lat = line.split("\t")
|
|
yield name, float(lat), float(lon)
|
|
|
|
|
|
def street_delivery(name, codes):
|
|
"""The codes delivered to a street. A postort's own prefix is three digits where one three-digit prefix starts at
|
|
least half its codes, else two. Outside ONE_POSITION's cities, the digit after that prefix is 0 or 1 for boxes and 8
|
|
for reply mail; inside them, a second digit 0 marks boxes. The box and reply-mail digits are per
|
|
docs/research/research-geo-se.md, Postal codes → Structure."""
|
|
if name in ONE_POSITION:
|
|
return [c for c in codes if c[1] != "0"]
|
|
largest = collections.Counter(c[:3] for c in codes).most_common(1)[0][1]
|
|
length = 3 if largest * 2 >= len(codes) else 2
|
|
return [c for c in codes if c[length] not in "018"]
|
|
|
|
|
|
def municipality_of(name, rows, tatorter, municipalities):
|
|
named = [code for code, n in municipalities.items() if n == name]
|
|
if named:
|
|
return named[0], "kommun"
|
|
voted = collections.Counter(r["municipality"] for r in rows if r["municipality"] in municipalities)
|
|
matches = tatorter.get(name, [])
|
|
if matches:
|
|
in_vote = [m for m in matches if voted and m[0] == voted.most_common(1)[0][0]]
|
|
return max(in_vote or matches, key=lambda m: m[1])[0], "tatort"
|
|
if voted:
|
|
return voted.most_common(1)[0][0], "codes"
|
|
return None, "unplaced"
|
|
|
|
|
|
def population_of(name, municipality, tatorter, municipalities, population):
|
|
matches = [m for m in tatorter.get(name, []) if m[0] == municipality]
|
|
if matches:
|
|
return str(max(m[1] for m in matches))
|
|
return population[municipality] if municipalities[municipality] == name else str(UNMATCHED_POPULATION)
|
|
|
|
|
|
def well_cased(name):
|
|
"""Whether each word of name is capitalised: GeoNames also spells some postorter, such as STOCKHOLM and örebro, in
|
|
capitals or lower case."""
|
|
return all(part[:1].isupper() and (len(part) == 1 or not part.isupper()) for part in re.split(r"[ -]", name))
|
|
|
|
|
|
def localities(codes, tatorter, municipalities, population):
|
|
"""Each postort with its municipality, weight, centroid and street-delivery codes."""
|
|
by_locality = collections.defaultdict(list)
|
|
for r in codes:
|
|
by_locality[r["locality"]].append(r)
|
|
out, how = {}, collections.Counter()
|
|
for name, rows in by_locality.items():
|
|
municipality, method = municipality_of(name, rows, tatorter, municipalities)
|
|
how[method] += 1
|
|
kept = street_delivery(name, [r["code"].replace(" ", "") for r in rows])
|
|
point = geo.centroid(rows)
|
|
if municipality is None or not kept or point is None or not well_cased(name):
|
|
continue
|
|
lat, lon = point
|
|
out[name] = {"name": name, "municipality": municipality, "population": population_of(name, municipality, tatorter, municipalities, population), "lat": f"{lat:.4f}", "lon": f"{lon:.4f}", "codes": kept}
|
|
print(f"municipality by {dict(how)}; {len(by_locality) - len(out)} postorter dropped before streets", file=sys.stderr)
|
|
return out
|
|
|
|
|
|
def streets(segments, codes, localities, per_locality):
|
|
"""The names with most segments per locality, each segment at its nearest code centroid."""
|
|
nearest = geo.Nearest((r["lat"], r["lon"], r["locality"]) for r in codes if r["lat"] is not None and r["locality"] in localities)
|
|
count = collections.Counter()
|
|
for name, lat, lon in segments:
|
|
if name[0].isalpha():
|
|
count[(nearest.find(lat, lon), name)] += 1
|
|
return geo.top_streets(count, per_locality, "segments")
|
|
|
|
|
|
def main():
|
|
a = geo.parser(__doc__, "SE", key_file={"help": "file holding the Trafikverket API key; TRAFIKVERKET_API_KEY otherwise"}).parse_args()
|
|
key = Path(a.key_file).read_text().strip() if a.key_file else os.environ.get("TRAFIKVERKET_API_KEY")
|
|
if not key:
|
|
sys.exit("set TRAFIKVERKET_API_KEY or pass --key-file")
|
|
cache, out = geo.directories(a)
|
|
|
|
regions, municipalities = scb_codes(cache)
|
|
population = scb_population(cache)
|
|
codes = geonames(cache)
|
|
places = localities(codes, scb_tatorter(cache), municipalities, population)
|
|
named = streets(nvdb_segments(cache, key), codes, places, a.streets_per_locality)
|
|
regions = {c: {"code": c, "name": n, "population": population[c], "timezone": TIMEZONE} for c, n in regions.items()}
|
|
municipalities = {c: {"code": c, "name": n, "region": c[:2], "population": population[c]} for c, n in municipalities.items()}
|
|
regions, municipalities, places = geo.fence(regions, municipalities, places, named)
|
|
|
|
tsv.write(out / "region.tsv", ["code", "name", "population", "timezone"], [r for _, r in sorted(regions.items())])
|
|
tsv.write(out / "municipality.tsv", ["code", "name", "region", "population"], [m for _, m in sorted(municipalities.items())])
|
|
tsv.write(out / "locality.tsv", ["name", "municipality", "population", "lat", "lon"], [l for _, l in sorted(places.items())])
|
|
tsv.write(out / "postal-code.tsv", ["code", "locality"], sorted(({"code": f"{c[:3]} {c[3:]}", "locality": l["name"]} for l in places.values() for c in l["codes"]), key=lambda r: r["code"]))
|
|
tsv.write(out / "street.tsv", ["name", "locality", "segments"], [s for locality in sorted(named) for s in named[locality]])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|