Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

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");
    }
}