crates/knut-popsim/src/geo.rs
//! **Geo-located population** — gives each simulated person a realistic Swedish
//! living address (lat/lon + kommun), weighted toward real population centres
//! (not a uniform scatter over the country), plus a **cluster pass** that finds
//! geographic hotspots of `ekonomiskt_bistand` (benefit recipients).
//!
//! Pure Rust, no external deps — so it compiles for `wasm32-unknown-unknown` and
//! every consumer (knut-popviz's Arrow load path, the facett demo) reuses the
//! exact same placement and the same seeded hotspots.
//!
//! Determinism: a tiny splitmix64 PRNG seeded by the vertex's absolute id, so the
//! same person always lands at the same place — the load is reproducible and the
//! cluster test can assert on the seeded hotspots.
/// A Swedish population centre: name, kommun, centre `(lon, lat)`, a spread
/// (degrees) the addresses scatter within, and a relative `weight` (≈ share of
/// people who live in/around it). Weights are rough real shares — Stockholm,
/// Göteborg and Malmö dominate, then the mid-size cities, then a long tail.
#[derive(Clone, Copy, Debug)]
pub struct City {
pub name: &'static str,
pub kommun: &'static str,
pub lon: f64,
pub lat: f64,
pub spread: f64,
pub weight: f64,
}
/// The weighted set of Swedish population centres people are placed around.
/// Coordinates are the city centres (WGS-84); spreads are tuned so addresses
/// stay inside the urban area and inside Sweden's bounds.
pub const CITIES: [City; 14] = [
City {
name: "Stockholm",
kommun: "Stockholm",
lon: 18.0686,
lat: 59.3293,
spread: 0.28,
weight: 24.0,
},
City {
name: "Göteborg",
kommun: "Göteborg",
lon: 11.9746,
lat: 57.7089,
spread: 0.22,
weight: 12.0,
},
City {
name: "Malmö",
kommun: "Malmö",
lon: 13.0038,
lat: 55.6050,
spread: 0.16,
weight: 9.0,
},
City {
name: "Uppsala",
kommun: "Uppsala",
lon: 17.6389,
lat: 59.8586,
spread: 0.12,
weight: 5.0,
},
City {
name: "Västerås",
kommun: "Västerås",
lon: 16.5448,
lat: 59.6099,
spread: 0.10,
weight: 3.5,
},
City {
name: "Örebro",
kommun: "Örebro",
lon: 15.2066,
lat: 59.2741,
spread: 0.10,
weight: 3.5,
},
City {
name: "Linköping",
kommun: "Linköping",
lon: 15.6214,
lat: 58.4109,
spread: 0.10,
weight: 3.2,
},
City {
name: "Helsingborg",
kommun: "Helsingborg",
lon: 12.6945,
lat: 56.0465,
spread: 0.09,
weight: 3.0,
},
City {
name: "Jönköping",
kommun: "Jönköping",
lon: 14.1618,
lat: 57.7826,
spread: 0.10,
weight: 2.8,
},
City {
name: "Norrköping",
kommun: "Norrköping",
lon: 16.1924,
lat: 58.5877,
spread: 0.10,
weight: 2.7,
},
City {
name: "Umeå",
kommun: "Umeå",
lon: 20.2630,
lat: 63.8258,
spread: 0.14,
weight: 2.4,
},
City {
name: "Gävle",
kommun: "Gävle",
lon: 17.1413,
lat: 60.6749,
spread: 0.10,
weight: 2.0,
},
City {
name: "Sundsvall",
kommun: "Sundsvall",
lon: 17.3063,
lat: 62.3908,
spread: 0.12,
weight: 1.8,
},
City {
name: "Luleå",
kommun: "Luleå",
lon: 22.1567,
lat: 65.5848,
spread: 0.16,
weight: 1.4,
},
];
/// Sweden's approximate WGS-84 bounding box (lon/lat). Used to clamp generated
/// addresses and to assert (in tests) every person lands inside the country.
pub const SWEDEN_BBOX: (f64, f64, f64, f64) = (10.5, 55.2, 24.2, 69.1); // (min_lon,min_lat,max_lon,max_lat)
/// A generated Swedish address for one person.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Address {
pub lon: f64,
pub lat: f64,
pub kommun: &'static str,
}
/// Deterministic splitmix64 — one mix per call, stable across runs.
#[inline]
fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E3779B97F4A7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
/// Uniform `f64` in `[0,1)` from the PRNG.
#[inline]
fn unit(state: &mut u64) -> f64 {
(splitmix64(state) >> 11) as f64 / (1u64 << 53) as f64
}
/// The index into [`CITIES`] a person of absolute id `id` lands in (weighted by
/// city share). Shared by [`address_for`] and the simulator's community
/// assignment so kommun and community line up.
#[inline]
pub fn city_index_for(id: i64) -> usize {
let mut s = (id as u64)
.wrapping_mul(0xD1B54A32D192ED03)
.wrapping_add(0x1234_5678);
let total: f64 = CITIES.iter().map(|c| c.weight).sum();
let mut pick = unit(&mut s) * total;
for (i, c) in CITIES.iter().enumerate() {
if pick < c.weight {
return i;
}
pick -= c.weight;
}
0
}
/// Pick a city by weight, then jitter within its spread → a clamped Swedish
/// address. Seeded by `id` so the same person always lands in the same place.
pub fn address_for(id: i64) -> Address {
let mut s = (id as u64)
.wrapping_mul(0xD1B54A32D192ED03)
.wrapping_add(0x1234_5678);
let total: f64 = CITIES.iter().map(|c| c.weight).sum();
let mut pick = unit(&mut s) * total;
let mut city = &CITIES[0];
for c in &CITIES {
if pick < c.weight {
city = c;
break;
}
pick -= c.weight;
}
// Gaussian-ish jitter: average two uniforms so addresses cluster near the
// centre (a triangular distribution), then scale by the city spread.
let jx = (unit(&mut s) + unit(&mut s) - 1.0) * city.spread;
let jy = (unit(&mut s) + unit(&mut s) - 1.0) * city.spread;
let (mnx, mny, mxx, mxy) = SWEDEN_BBOX;
Address {
lon: (city.lon + jx).clamp(mnx, mxx),
lat: (city.lat + jy).clamp(mny, mxy),
kommun: city.kommun,
}
}
/// Whether a person of absolute id `id` is a `ekonomiskt_bistand` (benefit
/// recipient). Deterministic; `rate` is the base share (≈ Försäkringskassan +
/// social-assistance recipients), but recipients are **over-represented in a few
/// kommuner** (the seeded hotspots) so the cluster pass has something to find.
pub fn is_ekonomiskt_bistand(id: i64, rate: f64) -> bool {
let addr = address_for(id);
// Hotspot kommuner: benefit uptake is multiplied here (the demo's planted
// signal — a few areas with markedly higher density).
let mult = match addr.kommun {
"Malmö" => 3.2,
"Göteborg" => 2.1,
"Luleå" => 2.4,
_ => 1.0,
};
let mut s = (id as u64)
.wrapping_mul(0x2545F4914F6CDD1D)
.wrapping_add(0xABCD_1234);
unit(&mut s) < (rate * mult).min(0.95)
}
// ── cluster pass ─────────────────────────────────────────────────────────────
/// One geographic cluster of high `ekonomiskt_bistand` density.
#[derive(Clone, Debug, PartialEq)]
pub struct Cluster {
/// Cell centre `(lon, lat)`.
pub lon: f64,
pub lat: f64,
/// Number of ekonomiskt_bistand in the cell.
pub count: usize,
/// Dominant kommun label for the cell (most-common among its points).
pub kommun: String,
}
/// Grid-density cluster pass: bucket the `(lon, lat)` ekonomiskt_bistand points into a
/// `cell`-degree grid, keep cells with `>= min_count` points, and return them
/// sorted by count (densest first), capped to `top`. A simple, deterministic
/// stand-in for DBSCAN that's exact and easy to assert on.
///
/// `labelled_points` are `(lon, lat, kommun)` triples — the kommun is used to
/// name each hotspot. Returns the hotspot cells.
pub fn cluster_hotspots(
labelled_points: &[(f64, f64, &str)],
cell: f64,
min_count: usize,
top: usize,
) -> Vec<Cluster> {
use std::collections::HashMap;
let cell = cell.max(1e-6);
// cell key → (sum_lon, sum_lat, count, kommun→count)
let mut cells: HashMap<(i64, i64), (f64, f64, usize, HashMap<String, usize>)> = HashMap::new();
for &(lon, lat, kommun) in labelled_points {
let key = ((lon / cell).floor() as i64, (lat / cell).floor() as i64);
let e = cells
.entry(key)
.or_insert_with(|| (0.0, 0.0, 0, HashMap::new()));
e.0 += lon;
e.1 += lat;
e.2 += 1;
*e.3.entry(kommun.to_string()).or_insert(0) += 1;
}
let mut out: Vec<Cluster> = cells
.into_values()
.filter(|(_, _, c, _)| *c >= min_count)
.map(|(slon, slat, c, kommuns)| {
let kommun = kommuns
.into_iter()
.max_by_key(|(_, n)| *n)
.map(|(k, _)| k)
.unwrap_or_default();
Cluster {
lon: slon / c as f64,
lat: slat / c as f64,
count: c,
kommun,
}
})
.collect();
// Densest first; tie-break on lon/lat for determinism.
out.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then(
a.lon
.partial_cmp(&b.lon)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(
a.lat
.partial_cmp(&b.lat)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
out.truncate(top);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_address_is_inside_sweden() {
let (mnx, mny, mxx, mxy) = SWEDEN_BBOX;
for id in 0..5_000 {
let a = address_for(id);
assert!(
a.lon >= mnx && a.lon <= mxx,
"lon {} out of Sweden for id {id}",
a.lon
);
assert!(
a.lat >= mny && a.lat <= mxy,
"lat {} out of Sweden for id {id}",
a.lat
);
assert!(!a.kommun.is_empty(), "kommun set for id {id}");
}
}
#[test]
fn addresses_are_deterministic() {
assert_eq!(address_for(42), address_for(42), "same id → same address");
assert_ne!(address_for(1), address_for(2), "different ids differ");
}
#[test]
fn city_index_matches_address_kommun() {
for id in 0..2_000 {
assert_eq!(CITIES[city_index_for(id)].kommun, address_for(id).kommun);
}
}
#[test]
fn population_is_weighted_toward_big_cities() {
// Stockholm (weight 24) should attract many more than tiny Luleå (1.4).
let mut sthlm = 0;
let mut lulea = 0;
for id in 0..20_000 {
match address_for(id).kommun {
"Stockholm" => sthlm += 1,
"Luleå" => lulea += 1,
_ => {}
}
}
assert!(sthlm > lulea * 5, "Stockholm ({sthlm}) ≫ Luleå ({lulea})");
}
#[test]
fn cluster_pass_finds_the_seeded_hotspots() {
// Build a realistic ekonomiskt_bistand point cloud, then assert the densest
// hotspots are the planted kommuner (Malmö/Göteborg/Luleå over-rep).
let mut pts: Vec<(f64, f64, &str)> = Vec::new();
for id in 0..60_000i64 {
if is_ekonomiskt_bistand(id, 0.06) {
let a = address_for(id);
pts.push((a.lon, a.lat, a.kommun));
}
}
assert!(pts.len() > 1_000, "a real benefit cloud, got {}", pts.len());
let hotspots = cluster_hotspots(&pts, 0.1, 20, 8);
assert!(!hotspots.is_empty(), "found hotspot cells");
// densest first
for w in hotspots.windows(2) {
assert!(w[0].count >= w[1].count, "sorted densest-first");
}
// A planted hotspot kommun must appear among the top cells.
let names: Vec<&str> = hotspots.iter().map(|c| c.kommun.as_str()).collect();
assert!(
names
.iter()
.any(|k| matches!(*k, "Malmö" | "Göteborg" | "Luleå")),
"a seeded hotspot kommun is in the top clusters: {names:?}"
);
// hotspot centres are inside Sweden
let (mnx, mny, mxx, mxy) = SWEDEN_BBOX;
for c in &hotspots {
assert!(c.lon >= mnx && c.lon <= mxx && c.lat >= mny && c.lat <= mxy);
}
}
#[test]
fn ekonomiskt_bistand_is_deterministic_and_over_represented_in_hotspots() {
assert_eq!(
is_ekonomiskt_bistand(7, 0.06),
is_ekonomiskt_bistand(7, 0.06)
);
// Malmö should have a markedly higher recipient share than the base rate.
let (mut malmo_n, mut malmo_recip) = (0, 0);
for id in 0..50_000i64 {
if address_for(id).kommun == "Malmö" {
malmo_n += 1;
if is_ekonomiskt_bistand(id, 0.06) {
malmo_recip += 1;
}
}
}
assert!(malmo_n > 0);
let share = malmo_recip as f64 / malmo_n as f64;
assert!(share > 0.10, "Malmö share {share} above the 0.06 base rate");
}
}