Kolme eri lukua samasta asiasta

Latausverkko jakaumana, osa 1: montako sähköauton latausasemaa Suomessa on?

Sähköautojen latauspisteiden lukumäärä toistetaan uutisissa yhtenä lukuna. Kun kaksi rekisteriä avataan ja katsotaan sisään, selviää ettei yksikään niistä ole edes itsensä kanssa yksimielinen. Rakennamme asemamäärästä jakauman ja kerromme matkalla, mitä rekistereistä löytyi.

avoin data
bayes
latausverkko
rekisterit
datan laatu
Author

Kristian Vepsäläinen

Published

1.9.2026

Luku, jota kukaan ei ole tarkistanut

Kun latausinfrasta puhutaan, keskustelussa kelluu aina yksi luku. Latauspisteitä on tuhansia. Tavoite on kymmeniä tuhansia. EU:n AFIR-asetus edellyttää tiettyä tehoa tietyin välein. Luvut esitetään ilman epävarmuutta, ikään kuin joku olisi käynyt laskemassa ne.

Kukaan ei ole käynyt laskemassa niitä. Ja mikä olennaisempaa: kysymykseen ei ole yhtä oikeaa vastausta, ennen kuin kysyjä kertoo mitä hän laskee.

Tämä ei ole saivartelua. Kun avaan Fintrafficin ylläpitämän kansallisen latausverkkoaineiston ja lasken rivit kolmella eri tasolla, saan kolme lukua, jotka eroavat toisistaan yli viisinkertaisesti. Kaikki kolme ovat oikein.

Tämä postaus aloittaa kolmentoista osan sarjan, jossa Suomen latausverkosta rakennetaan graafi ja etsitään sen murtumiskohdat. Ennen kuin verkkoa voi analysoida, on tiedettävä mistä se koostuu. Osoittautuu, että kysymys on odotettua hankalampi — ja hankaluus on itsessään tulos.

Aineistot

Käytän kahta lähdettä, jotka ovat syntyneet täysin eri tavoin.

Open Charge Map on maailmanlaajuinen, yhteisön ylläpitämä rekisteri. Osa tiedoista tulee vapaaehtoisilta, osa tuodaan automaattisesti operaattoreiden syötteistä. Rajapinta on avoin ja vaatii ilmaisen API-avaimen.

Fintrafficin AFIR-rajapinta on kansallinen kooste, johon latausoperaattoreilla on lakisääteinen velvollisuus toimittaa tietonsa. Se julkaistaan Digitrafficin kautta OCPI-standardiin pohjautuvassa muodossa.

Ensimmäinen on joukkoistettu, toinen viranomaisvetoinen. Ensimmäisessä havainto syntyy siitä, että joku huomaa aseman. Toisessa siitä, että operaattori täyttää velvollisuutensa. Nämä ovat erilaisia havaintomekanismeja, ja juuri se tekee niiden vertailusta hyödyllistä.

Code
ocm_path <- file.path(data_dir, "ocm_raw.qs")

hae_ocm <- function(maxresults = 50000) {
  avain <- Sys.getenv("OCM_API_KEY")
  stopifnot("OCM_API_KEY puuttuu .Renviron-tiedostosta" = nzchar(avain))

  resp <- request("https://api.openchargemap.io/v3/poi/") |>
    req_headers(`X-API-Key` = avain,
                `User-Agent` = "kristianvepsalainen.com / latausverkko") |>
    req_url_query(countrycode = "FI", output = "json", compact = "false",
                  verbose = "false", maxresults = maxresults) |>
    req_retry(max_tries = 3) |>
    req_perform()

  stopifnot("OCM palautti muun kuin 200" = resp_status(resp) == 200)
  poi <- resp_body_json(resp)

  # Hiljainen katkaisu näyttäisi täysin normaalilta aineistolta,
  # joten se on tarkistettava erikseen.
  stopifnot(
    "OCM-vastaus on katkaistu: kasvata maxresults" = length(poi) < maxresults,
    "OCM palautti tyhjän vastauksen" = length(poi) > 0
  )
  poi
}

if (!file.exists(ocm_path)) {
  ocm_raw <- hae_ocm()
  qs2::qs_save(ocm_raw, ocm_path)
} else {
  ocm_raw <- qs2::qs_read(ocm_path)
}
Code
afir_path <- file.path(data_dir, "afir_raw.qs")

afir <- function(polku, cursor = NULL) {
  req <- request("https://afir.digitraffic.fi") |>
    req_url_path_append(polku) |>
    req_headers(`Digitraffic-User` = "kristianvepsalainen.com/latausverkko") |>
    req_retry(max_tries = 3)
  if (!is.null(cursor)) req <- req_url_query(req, cursor = cursor)
  resp <- req_perform(req)
  stopifnot("AFIR palautti muun kuin 200" = resp_status(resp) == 200)
  resp_body_json(resp)
}

# Sivutus on kursoripohjaista. Käytän sitä koosteen (/all) sijaan, koska
# sivu kerrallaan eteneminen paljastaa jos rajapinta jää silmukkaan.
hae_kaikki <- function(polku, max_sivuja = 200) {
  sivut <- list(); cursor <- NULL; i <- 0
  repeat {
    i <- i + 1
    stopifnot("Sivutus ei pääty – tarkista nextCursor" = i <= max_sivuja)
    v <- afir(polku, cursor)
    sivut[[i]] <- v
    cursor <- purrr::pluck(v, "pagination", "nextCursor", .default = NULL)
    if (is.null(cursor)) break
  }
  sivut
}

if (!file.exists(afir_path)) {
  afir_raw <- list(
    operaattorit = afir("/api/charging-network/v1/operators"),
    sijainnit    = hae_kaikki("/api/charging-network/v1/locations")
  )
  qs2::qs_save(afir_raw, afir_path)
} else {
  afir_raw <- qs2::qs_read(afir_path)
}

Ensimmäinen kerros: mitä yksi rivi tarkoittaa?

AFIR-aineisto on kolmitasoinen. Ylimpänä on sijainti — esimerkiksi kauppakeskuksen parkkihalli. Sen alla on yksi tai useampi latauslaite, standardin kielellä EVSE. Ja laitteen alla on konnektorit, eli fyysiset pistokkeet.

Yksi latauspylväs, jossa on sekä CHAdeMO- että CCS-pistoke, on yksi laite ja kaksi konnektoria. Kymmenen pylvään latauskenttä on yksi sijainti ja kymmenen laitetta.

Code
piirteet <- purrr::list_flatten(
  map(afir_raw$sijainnit, \(s) purrr::pluck(s, "features", .default = list()))
)
stopifnot("Piirteitä ei löytynyt" = length(piirteet) > 0)

nap_sijainnit <- map_dfr(piirteet, \(f) {
  p <- f$properties
  tibble(
    raaka_id  = pluck_chr(p, "id"),
    nimi      = pluck_chr(p, "name"),
    op_id     = pluck_chr(p, "operator", "id"),
    op_nimi   = pluck_chr(p, "operator", "details", "name"),
    kunta     = pluck_chr(p, "address", "city"),
    postinro  = pluck_chr(p, "address", "postalCode"),
    aina_auki = as.logical(purrr::pluck(p, "openingTimes", "twentyFourSeven",
                                        .default = NA)),
    luotu     = pluck_chr(p, "createdAt"),
    muokattu  = pluck_chr(p, "modifiedAt"),
    lon       = pluck_num(f, "geometry", "coordinates", 1),
    lat       = pluck_num(f, "geometry", "coordinates", 2),
    .rows = 1
  )
}) |>
  mutate(avain = paste(op_id, raaka_id, sep = "|"))

nap_kon <- map_dfr(piirteet, \(f) {
  p <- f$properties
  avain <- paste(pluck_chr(p, "operator", "id"), pluck_chr(p, "id"), sep = "|")
  map_dfr(purrr::pluck(p, "evses", .default = list()), \(e) {
    map_dfr(purrr::pluck(e, "connectors", .default = list()), \(k) tibble(
      avain,
      evse_id   = pluck_chr(e, "id"),
      standardi = pluck_chr(k, "standard"),
      virta     = pluck_chr(k, "powerType"),
      muoto     = pluck_chr(k, "format"),
      volttia   = pluck_num(k, "maxVoltage"),
      ampeeria  = pluck_num(k, "maxAmperage"),
      # maxElectricPower on OCPI:ssa watteja, ei kilowatteja
      teho_kw   = pluck_num(k, "maxElectricPower") / 1000,
      tariffi   = pluck_chr(k, "tariffIds", 1)
    ))
  })
})

stopifnot(
  "Sijaintitaulu on tyhjä" = nrow(nap_sijainnit) > 0,
  "Konnektoritaulu on tyhjä" = nrow(nap_kon) > 0,
  "Koordinaatteja puuttuu" = all(!is.na(nap_sijainnit$lat)),
  "Koordinaatit Suomen ulkopuolella" =
    all(between(nap_sijainnit$lat, 59.4, 70.2) &
        between(nap_sijainnit$lon, 18.9, 31.7))
)

kolme_lukua <- c(
  sijainteja  = nrow(nap_sijainnit),
  laitteita   = n_distinct(nap_kon$evse_id),
  konnektorit = nrow(nap_kon)
)

Kolme lukua samasta aineistosta: 3 048 sijaintia, 16 588 latauslaitetta ja 16 675 pistoketta.

Kun uutisessa sanotaan, että latauspisteitä on noin 17 000, se on tosi. Kun sanotaan, että latausasemia on noin 3 000, sekin on tosi. Kumpikaan ei ole väärin. Ne mittaavat eri asiaa, eikä kumpikaan yleensä kerro kumpaa tarkoitetaan.

Päättäjälle tällä on suora seuraus. Jos tavoitteeksi asetetaan “50 000 latauspistettä vuoteen 2030 mennessä”, tavoite on kolmella eri tulkinnalla kolmen eri kokoinen. Ero ei ole prosentteja vaan kertaluokka.

Sama aineisto kolmella eri laskentatasolla.

Toinen kerros: rekisteri ei ole yksimielinen itsensä kanssa

Valitaan taso — sanotaan sijainti, koska verkostoanalyysissä solmu on paikka johon ajetaan, ei pistoke johon kytketään. Nyt pitäisi voida laskea rivit.

Ei pitäisi.

Sama tunniste, kaksi eri asemaa

Code
tormaykset <- nap_sijainnit |> group_by(raaka_id) |> filter(n() > 1) |> ungroup()

n_tormays_ryhmia <- n_distinct(tormaykset$raaka_id)
n_tormays_riveja <- nrow(tormaykset)

# Ovatko rivit saman aseman toistoja vai aidosti eri asemia?
tormays_luonne <- tormaykset |>
  group_by(raaka_id) |>
  summarise(eri_nimia = n_distinct(nimi), eri_op = n_distinct(op_id),
            .groups = "drop")

kaikki_eri <- all(tormays_luonne$eri_nimia > 1 & tormays_luonne$eri_op > 1)

tunnistetyyli <- nap_sijainnit |>
  mutate(numeerinen = str_detect(raaka_id, "^[0-9]+$")) |>
  group_by(op_id, op_nimi) |>
  summarise(n = n(), numeerisia = sum(numeerinen), .groups = "drop")

n_numeerisia <- sum(tunnistetyyli$numeerisia)
n_op_numeerisia <- sum(tunnistetyyli$numeerisia > 0)

Aineistossa on 28 tunnistetta, jotka viittaavat useampaan kuin yhteen sijaintiin, yhteensä 57 riviä. Kaikissa tapauksissa nimi, koordinaatit ja operaattori ovat eri — kyse ei siis ole saman rivin toistumisesta vaan aidosti eri asemista, jotka jakavat tunnisteen.

Tunniste 10 on sekä St1:n asema Pihtiputaalla että Evenlin asema Nokialla. Tunniste 29 on St1 Porvoossa ja Wattery Muhoksella.

Syy näkyy tunnisteiden muodossa. Suurin osa on satunnaisia merkkijonoja, mutta törmäävät ovat pieniä kokonaislukuja. 6 operaattoria — yhteensä 478 sijaintia — käyttää omia juoksevia numeroitaan, jotka on välitetty koosteeseen sellaisenaan. OCPI-standardissa sijainnin yksilöi vasta kolmikko maakoodi, osapuolitunnus ja sijaintitunnus, mutta koosteen tunnistekenttä sisältää vain viimeisen.

Korjaus on rakentaa yhdistelmäavain itse. Se on triviaali — mutta vain jos huomaa, että se tarvitaan. Kuka tahansa, joka lukee aineiston tietokantaan sijaintitunniste avaimena, saa hiljaisesti väärän tuloksen.

Toistuvat konnektorit

Code
n_ennen <- nrow(nap_kon)

# Rajapinta toistaa osan konnektoreista sellaisenaan. Sarakkeet
# luetellaan nimeltä eikä käytetä pelkkää kolmikkoa avain–laite–standardi,
# koska aidossa kaksipistokkeisessa laturissa sama standardi voi
# esiintyä eri teholla.
nap_kon <- nap_kon |>
  distinct(avain, evse_id, standardi, virta, muoto,
           volttia, ampeeria, teho_kw, tariffi, .keep_all = TRUE)

n_toistoja <- n_ennen - nrow(nap_kon)

stopifnot(
  "Yli 1 % konnektoririveistä oli toistoja" = n_toistoja / n_ennen < 0.01,
  "Konnektoririvit eivät ole uniikkeja puhdistuksen jälkeen" =
    !any(duplicated(select(nap_kon, avain, evse_id, standardi, teho_kw))),
  "Yhdistelmäavaimessa duplikaatteja" = !any(duplicated(nap_sijainnit$avain))
)

Rajapinta palautti 4 konnektoririviä kahteen kertaan täysin identtisinä, tariffitunnistetta myöten. Määrä on mitätön, mutta korjaus on tehtävä ennen kuin mitään lasketaan.

Sama laite, kaksi eri asemaa

Code
jaetut_evse <- nap_kon |>
  group_by(evse_id) |>
  filter(n_distinct(avain) > 1) |>
  ungroup()

n_jaettuja <- n_distinct(jaetut_evse$evse_id)
n_paallekkaisia <- n_distinct(jaetut_evse$avain)

6 latauslaitetta on kirjattu kahden eri sijainnin alle. Turun Toriparkissa sama laite on sekä “ylempi krs” että “K1”. Espoossa sama neljä laitetta on sekä “Toyota Espoo” että “Toyota Espoo Julkinen”.

Nämä eivät ole kaksi asemaa vaan yksi asema kahdella kirjauksella. Sijaintien lukumäärä on siis yliarvio, ja ainoa keino havaita se on mennä tasoa alemmas ja katsoa laitetunnisteita.

Sama operaattori, kolme eri nimeä

Code
ops <- map_dfr(afir_raw$operaattorit, \(o) tibble(
  op_id = pluck_chr(o, "id"),
  op_virallinen = pluck_chr(o, "details", "name"),
  maakoodi = pluck_chr(o, "countryCode")
))

nimivariantit <- nap_sijainnit |>
  distinct(op_id, op_nimi) |>
  count(op_id, name = "nimia") |>
  filter(nimia > 1) |>
  left_join(ops, by = "op_id") |>
  arrange(desc(nimia))

stopifnot(
  "Sijainneissa operaattori jota ei ole rekisterissä" =
    length(setdiff(unique(nap_sijainnit$op_id), ops$op_id)) == 0,
  "Rekisterissä operaattori jolla ei ole sijainteja" =
    length(setdiff(ops$op_id, unique(nap_sijainnit$op_id))) == 0
)

Operaattorin nimi on vapaa tekstikenttä, ja se näkyy. Sama tunnus esiintyy kirjoitusasuissa, jotka eroavat yhden kirjaimen kokoisesti. Yksi operaattori on lisäksi eri niminen operaattorirajapinnassa kuin sijaintiaineistossa.

Avain on siis tunnus, ei nimi. Tämä on tavanomainen datankäsittelyn opetus, mutta se kannattaa sanoa ääneen, koska nimen käyttäminen avaimena olisi tässä tuottanut kolme operaattoria siellä missä on yksi.

Ja yksi operaattori ei ole operaattori

Code
alustatunnus <- nimivariantit |> slice_max(nimia, n = 1)

alustan_asiakkaat <- nap_sijainnit |>
  filter(op_id == alustatunnus$op_id[1]) |>
  count(op_nimi, sort = TRUE)

Yhden operaattoritunnuksen alla esiintyy 5 eri toimijan nimeä: suuri latausoperaattori ja sen lisäksi joukko pieniä toimijoita — isännöintiyhtiö, autokauppa, teknologiayritys.

Kyse ei ole virheestä. Kyse on siitä, että alustatoimittaja hoitaa AFIR-ilmoituksen asiakkaidensa puolesta. Pieni isännöintiyhtiö ei ole tehnyt itsenäistä päätöstä ilmoittaa latauspisteensä kansalliseen rekisteriin. Se on valinnut latausoperaattorinsa, ja operaattori on hoitanut loput.

Tällä on suora seuraus siihen, mitä seuraavaksi teen. Jos halutaan arvioida kuinka moni asema puuttuu rekisteristä, ei voi olettaa että jokainen asema päätyy sinne itsenäisesti. Havaitseminen ei tapahdu asema kerrallaan eikä edes toimija kerrallaan, vaan alusta kerrallaan.

Code
# Puretaan päällekkäiskirjaukset: jos kaksi sijaintia jakaa latauslaitteen,
# ne ovat sama asema. Yhdistetään ne ensimmäisen avaimen alle.
evse_ryhmat <- nap_kon |>
  distinct(avain, evse_id) |>
  group_by(evse_id) |>
  mutate(ryhma = min(avain)) |>
  ungroup() |>
  distinct(avain, ryhma) |>
  group_by(avain) |>
  summarise(kanoninen = min(ryhma), .groups = "drop")

nap <- nap_sijainnit |>
  left_join(evse_ryhmat, by = "avain") |>
  mutate(kanoninen = coalesce(kanoninen, avain),
         luotu_apu = coalesce(luotu, muokattu)) |>
  group_by(kanoninen) |>
  slice_min(luotu_apu, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(-luotu_apu)

n_poistettu <- nrow(nap_sijainnit) - nrow(nap)

stopifnot(
  "Kanonisointi poisti epäuskottavan monta riviä" =
    n_poistettu / nrow(nap_sijainnit) < 0.05
)

Tehokenttä ja sen naapurit

Code
# Yli 400 kW on henkilöautolatauksessa epäuskottava, mutta raskaan
# kaluston pikalataus yltää korkeammalle. Erotellaan nämä toisistaan
# pistoketyypin perusteella.
nap_kon <- nap_kon |>
  mutate(
    raskas = teho_kw >= 350 & standardi %in% c("IEC_62196_T2_COMBO", "MCS"),
    teho_epailyttava = teho_kw > 400 &
      standardi %in% c("DOMESTIC_F", "DOMESTIC_H")
  )

n_epailyttavia <- sum(nap_kon$teho_epailyttava, na.rm = TRUE)

stopifnot(
  "Kotitalouspistoke mahdottomalla teholla useammin kuin odotettua" =
    mean(nap_kon$teho_epailyttava, na.rm = TRUE) < 0.005
)

nap_kon <- nap_kon |>
  mutate(teho_kw = if_else(teho_epailyttava, NA_real_, teho_kw))

stopifnot(
  "Tehoja yli 600 kW myös roskan poiston jälkeen" =
    all(nap_kon$teho_kw <= 600 | is.na(nap_kon$teho_kw)),
  "Negatiivisia tehoja" = all(nap_kon$teho_kw >= 0, na.rm = TRUE)
)

Aineistossa on 5 riviä, joissa kotitalouspistorasialle on merkitty yli 400 kilowatin teho — yhdessä tapauksessa 1 200 kilowattia. Kotitalouspistorasiasta saa noin kaksi. Nämä on merkitty puuttuviksi.

Sen sijaan CCS-pistokkeiden 560 kilowatin merkinnät satamissa ja liikenneasemilla ovat uskottavia: ne ovat raskaan kaluston latureita, joita Suomeen on rakennettu. Ne säilytetään mutta merkitään erikseen, koska ne eivät kuulu henkilöautoverkkoon.

OCM:n puolella samat ongelmat, eri muodossa

Code
ocm <- map_dfr(ocm_raw, function(p) {
  liitokset <- purrr::pluck(p, "Connections", .default = list())
  tibble(
    ocm_id      = pluck_num(p, "ID"),
    nimi        = pluck_chr(p, "AddressInfo", "Title"),
    kunta       = pluck_chr(p, "AddressInfo", "Town"),
    postinro    = pluck_chr(p, "AddressInfo", "Postcode"),
    lat         = pluck_num(p, "AddressInfo", "Latitude"),
    lon         = pluck_num(p, "AddressInfo", "Longitude"),
    operaattori = pluck_chr(p, "OperatorInfo", "Title"),
    julkinen    = as.logical(purrr::pluck(p, "UsageType", "IsPublicAccess",
                                          .default = NA)),
    toiminnassa = as.logical(purrr::pluck(p, "StatusType", "IsOperational",
                                          .default = NA)),
    datalahde   = pluck_num(p, "DataProviderID"),
    pisteita    = pluck_num(p, "NumberOfPoints"),
    paivitetty  = pluck_chr(p, "DateLastStatusUpdate"),
    varmistettu = pluck_chr(p, "DateLastVerified"),
    liitokset   = list(map_dfr(liitokset, \(c) tibble(
      pistoketyyppi = pluck_chr(c, "ConnectionType", "Title"),
      teho_kw = pluck_num(c, "PowerKW"),
      lkm = pluck_num(c, "Quantity")
    ))),
    .rows = 1
  )
}) |>
  mutate(
    max_kw = map_dbl(liitokset, \(d)
      if (nrow(d) == 0 || all(is.na(d$teho_kw))) NA_real_
      else max(d$teho_kw, na.rm = TRUE)),
    hpc = !is.na(max_kw) & max_kw >= 150
  )

# Roskatietueet. Sanaraja \\btest\\b on tarkoituksellinen: pelkkä "test"
# osuisi paikannimiin ja poistaisi oikeita asemia hiljaisesti.
ocm_luokiteltu <- ocm |>
  mutate(hylkaysyy = case_when(
    lat == 0 & lon == 0 ~ "nollakoordinaatit",
    !(between(lat, 59.4, 70.2) & between(lon, 18.9, 31.7)) ~ "Suomen ulkopuolella",
    str_detect(str_to_lower(nimi), "\\btest\\b|testi|demo|dummy|sample") ~ "testitietue",
    TRUE ~ NA_character_
  ))

hylatyt <- ocm_luokiteltu |> filter(!is.na(hylkaysyy)) |> count(hylkaysyy)

stopifnot(
  "Yli 2 % tietueista hylättiin – tarkista haun rajaus" =
    sum(hylatyt$n) / nrow(ocm) < 0.02,
  "OCM-tunnisteissa duplikaatteja" = !any(duplicated(ocm$ocm_id))
)

ocm <- ocm_luokiteltu |> filter(is.na(hylkaysyy)) |> select(-hylkaysyy)

Hylättyjä tietueita on 6. Osa on koordinaateiltaan mahdottomia: yksi asema väittää sijaitsevansa Tyynellämerellä, toinen Norjanmeren pohjoisosassa. Yksi on pisteessä nolla-nolla — Guineanlahdella, mikä on käytännössä aina merkki siitä, että sijainti puuttuu ja jokin järjestelmä on täyttänyt tyhjän kentän nollilla.

Loput ovat testitietueita, joita joku on syöttänyt rajapintaa kokeillessaan eikä ole poistanut. Yksi niistä on koordinaateiltaan täysin uskottava ja olisi läpäissyt jokaisen geometrisen tarkistuksen — sen löytämiseen tarvittiin nimihaku.

Kenttä, joka näyttää laadunvarmistukselta

Code
varmistustesti <- ocm |>
  filter(!is.na(varmistettu), !is.na(paivitetty)) |>
  mutate(ero_h = as.numeric(difftime(
    as.POSIXct(varmistettu, tz = "UTC"),
    as.POSIXct(paivitetty, tz = "UTC"), units = "hours"))) |>
  summarise(n = n(), sama_hetki = mean(abs(ero_h) < 1),
            mediaani_ero = median(ero_h))

OCM:ssä on kenttä nimeltä “viimeksi varmistettu”. Se kuulostaa siltä, että joku olisi käynyt tarkistamassa aseman olemassaolon.

Kaikilla 5 750 asemalla varmistuspäivä on täsmälleen sama kuin päivityspäivä. Mediaaniero on nolla tuntia, eikä hajontaa ole lainkaan. Kenttä päivittyy automaattisesti aina kun syöte päivittyy.

Se ei siis mittaa tiedon oikeellisuutta vaan integraation aktiivisuutta. Ero on olennainen: rekisterissä on kenttä, joka näyttää laadunvarmistukselta mutta ei ole sitä, ja jokainen joka lukee sen nimen mukaan päättelee väärin.

Code
lahteet <- ocm |> count(datalahde, sort = TRUE) |> mutate(osuus = n / sum(n))

OCM:n Suomen aineisto tulee kolmesta lähteestä: käsin syötetyistä yhteisömerkinnöistä, automaattisesta OCPI-tuonnista ja pohjoismaisesta NOBIL-rekisteristä. Nämä olisivat periaatteessa erilaisia havaintomekanismeja, mutta OCM yhdistää päällekkäiset tietueet ennen julkaisua — sama asema näkyy kerran, ei kolmesti. Lähteiden välistä peittävyyttä ei siis voi mitata OCM:n sisältä, ja siihen tarvitaan ulkopuolinen vertailukohta.

Kolmas kerros: kuinka moni puuttuu molemmista?

Vasta nyt päästään alkuperäiseen kysymykseen.

Menetelmä: miten hylkeitä lasketaan

Biologi haluaa tietää, montako hyljettä lahdessa on. Hän pyydystää sata, merkitsee ne ja päästää vapaaksi. Viikon päästä hän pyydystää sata uudestaan. Niistä kaksikymmentä on merkittyjä. Jos toisessa pyynnissä viidennes osui merkittyihin, merkityt sata ovat noin viidennes populaatiosta — hylkeitä on siis noin viisisataa.

Menetelmä on nimeltään merkintä-takaisinpyynti. Sitä käytetään ekologian lisäksi epidemiologiassa aliraportoinnin korjaamiseen, ihmisoikeustyössä konfliktien uhrilukujen arviointiin ja ohjelmistotestauksessa löytämättömien virheiden määrän arviointiin.

Latausasemien kohdalla hylkeitä ovat asemat, ensimmäinen pyynti on OCM ja toinen AFIR-kooste. Päällekkäisyys kertoo, kuinka moni asema ei ole kummassakaan.

Miksi naiivi laskenta on tässä väärin

Menetelmä olettaa, että jokainen asema päätyy rekisteriin toisistaan riippumatta. Edellinen luku osoitti, ettei näin ole. Operaattori joko toimittaa tietonsa AFIR-koosteeseen tai ei — ja jos toimittaa, käytännössä kaikki sen asemat tulevat kerralla. Alustatoimittajan tapauksessa päätös tehdään vielä tasoa ylempänä.

Havaintoja ei siis ole tuhansia vaan kymmeniä. Naiivi laskenta olettaa tuhansia riippumattomia kolikonheittoja siellä missä niitä on muutamia kymmeniä, ja tuottaa siksi aivan liian kapean epävarmuusvälin.

Oikea rakenne on kaksitasoinen. Ensin arvioidaan, kuinka moni operaattori puuttuu. Sitten arvioidaan, kuinka monta asemaa puuttuvilla operaattoreilla on, käyttäen havaittua operaattorikokojen jakaumaa.

Operaattorit linkitetään käsin

Code
ocm_operaattorit <- ocm |>
  filter(!is.na(operaattori)) |>
  distinct(operaattori)

# Kahdenkymmenen toimijan linkitys tehdään käsin. Sumea nimivertailu ei
# tunnista, että "VIRTA" ja "Liikennevirta" ovat sama yhtiö, eikä erota
# operaattoria merkinnästä aseman hallinnasta.
vastaavuudet_op <- tibble::tribble(
  ~ocm,                                        ~afir,
  "ABC Lataus (FI)",                           "ABC",
  "Ionity",                                    "IONITY",
  "K Lataus",                                  "K-lataus",
  "Lidl",                                      "Lidl",
  "Neste Lataus",                              "Neste Markkinointi Oy",
  "Plugit",                                    "Plugit Finland oy",
  "Recharge (Formerly Fortum Charge & Drive)", "Recharge",
  "Tesla (Tesla-only charging)",               "Tesla",
  "VIRTA",                                     "Liikennevirta",
  "eparking (Finland)",                        "eParking"
)

# Merkinnät, jotka eivät ole operaattoreita vaan tietoa aseman
# hallinnasta, tai saman toimijan toisia merkintöjä.
ei_operaattoreita <- c(
  "(Business Owner at Location)",
  "(Private Residence/Individual)",
  "Charge & Drive (Fortum - NO)",   # Rechargen entinen nimi
  "Tesla (including non-tesla)"     # sama yhtiö, eri latausverkko
)

ocm_op_puhdas <- ocm_operaattorit |> filter(!operaattori %in% ei_operaattoreita)

stopifnot(
  "Vastaavuustaulussa tuntematon OCM-nimi" =
    all(vastaavuudet_op$ocm %in% ocm_operaattorit$operaattori),
  "Vastaavuustaulussa tuntematon AFIR-nimi" =
    all(vastaavuudet_op$afir %in% ops$op_virallinen),
  "Vastaavuudet eivät ole yksikäsitteisiä" =
    !any(duplicated(vastaavuudet_op$ocm)) && !any(duplicated(vastaavuudet_op$afir)),
  "Poistettu nimi ei löydy aineistosta" =
    all(ei_operaattoreita %in% ocm_operaattorit$operaattori)
)

n1_op <- nrow(ocm_op_puhdas)
n2_op <- nrow(ops)
m_op  <- nrow(vastaavuudet_op)

vain_afir <- setdiff(ops$op_virallinen, vastaavuudet_op$afir)
vain_ocm  <- setdiff(ocm_op_puhdas$operaattori, vastaavuudet_op$ocm)

Linkitys tehdään käsin eikä sumealla nimivertailulla. Kokeilin ensin automaattista vertailua, ja se löysi kymmenestä vastaavuudesta kolme. Kaksikymmentä toimijaa on niin vähän, että käsityö on sekä tarkempaa että läpinäkyvämpää: taulukosta näkee mitä on tehty ja miksi.

OCM tuntee 16 operaattoria, AFIR 18, ja yhteisiä on 10.

Kiinnostavin havainto on siinä, keitä puuttuu. AFIR-rekisterissä on 8 operaattoria, joita OCM ei tunne. Joukossa on merkittäviä toimijoita, ei pelkkiä pieniä. Se kertoo, ettei joukkoistettu rekisteri kata edes suuria latausoperaattoreita — mikä on juuri se peittävyyden puute, jota tässä yritetään mitata.

Vastaavasti OCM tuntee 6 operaattoria, joita AFIR ei tunne. Nämä ovat toimijoita, jotka eivät ole täyttäneet lakisääteistä ilmoitusvelvollisuuttaan — tai jotka ovat lopettaneet toimintansa ilman että OCM:n merkintä on poistettu.

Code
# Bayesläinen posteriori hilalla: hypergeometrinen uskottavuus ja
# Jeffreysin priori. Yksi parametri, joten MCMC:tä ei tarvita.
posteriori_N <- function(n1, n2, m, kerroin = 8, max_kerroin = 200) {
  stopifnot("m ei voi ylittää otoskokoja" = m <= min(n1, n2),
            "m on nolla" = m > 0)
  N_min <- n1 + n2 - m

  repeat {
    N <- N_min:ceiling(N_min * kerroin)
    lp <- lchoose(n2, m) + lchoose(N - n2, n1 - m) - lchoose(N, n1) - log(N)
    lp[!is.finite(lp)] <- -Inf
    p <- exp(lp - max(lp)); p <- p / sum(p)
    hanta <- sum(p[N > max(N) * 0.99])
    if (hanta < 1e-6 || kerroin >= max_kerroin) break
    kerroin <- kerroin * 2
  }

  stopifnot(
    "Posteriorin häntä ei mene nollaan – hila on liian lyhyt" = hanta < 1e-4,
    "Posteriorin huippu on hilan reunassa" = which.max(p) < length(p) * 0.95
  )
  tibble(N = N, tiheys = p)
}

post_op <- posteriori_N(n1_op, n2_op, m_op)
naytteet_op <- sample(post_op$N, 20000, replace = TRUE, prob = post_op$tiheys)

op_arvio <- c(
  mediaani = median(naytteet_op),
  q025 = unname(quantile(naytteet_op, 0.025)),
  q975 = unname(quantile(naytteet_op, 0.975))
)

Operaattoreiden kokonaismäärän arvio on 30 (95 %:n väli 25–44), kun havaittuja on 24.

Code
# Asemien kokonaismäärä: havaitut asemat + puuttuvien operaattoreiden
# asemat. Puuttuville arvotaan koko havaitusta kokojakaumasta.
koot <- nap |> count(op_id) |> pull(n)

havaitut_asemat <- nrow(nap)

naytteet_asemat <- map_dbl(1:8000, function(i) {
  n_op_yht <- sample(naytteet_op, 1)
  n_piilossa_op <- max(n_op_yht - n2_op, 0)
  piilossa <- if (n_piilossa_op > 0)
    sum(sample(koot, n_piilossa_op, replace = TRUE)) else 0
  havaitut_asemat + piilossa
})

asema_arvio <- c(
  mediaani = median(naytteet_asemat),
  q025 = unname(quantile(naytteet_asemat, 0.025)),
  q975 = unname(quantile(naytteet_asemat, 0.975))
)

stopifnot(
  "Arvio on pienempi kuin havaittu" = asema_arvio["mediaani"] >= havaitut_asemat
)

Latausasemien kokonaismäärän posteriorijakauma.

Miten kuvaa luetaan. Molemmissa paneeleissa muodon korkein kohta on todennäköisin arvo ja leveys kertoo epävarmuuden. Alla oleva paksu viiva näyttää välin, jolle arvo osuu puolen todennäköisyydellä, ja ohut viiva 95 prosentin välin. Punainen katkoviiva näyttää havaitun määrän.

Ylempi paneeli koskee operaattoreita, alempi asemia. Jälkimmäinen on leveämpi, koska siihen kertyy epävarmuutta kahdesta lähteestä: siitä montako operaattoria puuttuu ja siitä kuinka suuria ne ovat.

WarningKolme varausta, jotka pitää sanoa ääneen

Rekisterit eivät ole riippumattomia. Molemmat saavat tietonsa osittain samoilta operaattoreilta. Positiivinen riippuvuus johtaa populaatiokoon aliarvioon. Kahdesta lähteestä riippuvuutta ei voi testata — siihen tarvittaisiin kolmas todella riippumaton lähde.

Puuttuvat operaattorit eivät ole satunnainen otos havaituista. Malli arpoo niille asemamäärän havaitusta kokojakaumasta. Todellisuudessa rekisteristä puuttuva toimija on todennäköisemmin pieni kuin suuri, joten arvio on todennäköisesti yläkanttiin.

Otantavirhettä ei ole. Tässä ei ole otosta perusjoukosta. Epävarmuus syntyy havaitsemisprosessista ja linkityksestä, ei otannasta. Siksi perinteinen merkitsevyystestaus on tässä osin väärä työkalu ja painopiste on jakaumissa.

Herkkyysanalyysi

Riippuvuutta ei voi testata, mutta sen vaikutuksen voi laskea. Riippuvuuden voimakkuutta kuvaava kerroin saa arvon yksi, kun lähteet ovat riippumattomia, ja ykköstä suurempia arvoja, kun molemmissa esiintyminen on yleisempää kuin sattuma antaisi olettaa.

Kuinka paljon arvio muuttuu, jos rekisterit eivät ole riippumattomia.

Käyrä on analyysin rehellisin osa. Se sanoo: jos rekisterit ovat riippumattomia, arvio on tämä. Jos ne kopioivat toisiaan, arvio on merkittävästi suurempi. En voi tietää kumpi pitää paikkansa. Voin kertoa, kuinka paljon sillä on väliä.

Sarjan pohja-aineisto

Code
asemat <- nap |>
  transmute(avain = kanoninen, nimi, op_id, op_nimi, kunta, postinro,
            lat, lon, aina_auki, lahde = "AFIR") |>
  bind_rows(
    ocm |> transmute(avain = paste0("OCM|", ocm_id), nimi,
                     op_nimi = operaattori, kunta, postinro, lat, lon,
                     lahde = "OCM")
  ) |>
  mutate(haettu = Sys.Date())

# Konnektoritaulu viittaa kanonisoituun avaimeen
liitokset <- nap_kon |>
  left_join(select(nap, avain, kanoninen), by = "avain") |>
  mutate(avain = coalesce(kanoninen, avain)) |>
  select(-kanoninen)

stopifnot(
  "Asemataulu tyhjä" = nrow(asemat) > 0,
  "Avaimissa duplikaatteja" = !any(duplicated(asemat$avain)),
  "Liitokset eivät täsmää asemiin" =
    mean(liitokset$avain %in% asemat$avain) > 0.95,
  "Tariffisarake puuttuu" = "tariffi" %in% names(liitokset)
)

qs2::qd_save(asemat, file.path(data_dir, "asemat.qs"))
qs2::qd_save(liitokset, file.path(data_dir, "liitokset.qs"))
qs2::qd_save(ops, file.path(data_dir, "operaattorit.qs"))
qs2::qd_save(tibble(N = naytteet_asemat),
             file.path(data_dir, "posteriori_asemamaara.qs"))

Viimeinen tiedosto erottaa tämän sarjan tavanomaisesta verkostoanalyysistä. Kun myöhemmin lasketaan, mitkä asemat ovat verkon kriittisimpiä, tieto siitä että rekisteri on epätäydellinen ei jää tähän postaukseen vaan kulkee mukana.

Takaisin siihen lukuun

Uutisessa toistettu latauspisteiden lukumäärä ei ole väärä. Se on epätäydellinen tavalla, joka on mitattavissa mutta jota ei mitata.

Viisi asiaa jää käteen.

Kysymys pitää esittää tarkemmin. Sijainti, latauslaite ja pistoke ovat eri asioita, ja niiden lukumäärät eroavat kertaluokalla. Tavoite, joka ei kerro tasoa, ei ole tavoite.

Rekisteri on mittalaite, ei todellisuus. Molemmissa tarkastelluissa rekistereissä on rakenteellisia ongelmia: tunnisteita jotka eivät yksilöi, laitteita jotka on kirjattu kahdesti, kenttä joka väittää olevansa laadunvarmistus mutta on aikaleima.

Havaintoyksikkö ei ole se, miltä näyttää. Asemat eivät päädy rekisteriin yksi kerrallaan vaan operaattorin ja viime kädessä alustatoimittajan päätöksellä. Menetelmä, joka olettaa muuta, tuottaa liian kapean epävarmuusvälin — eli näyttää varmemmalta kuin on.

Automaattinen ei aina voita käsityötä. Sumea nimivertailu löysi kymmenestä operaattoriparista kolme. Käsin tehty taulukko löysi kaikki ja on lisäksi tarkistettavissa. Kahdenkymmenen rivin kohdalla automaatio oli väärä työkalu.

Epävarmuus ei ole heikkous. Jos AFIR-tavoitteiden toteutumista seurataan luvulla, jonka virhemarginaalia ei tunneta, seuranta mittaa rekisteröintikäytäntöjen muutosta yhtä paljon kuin infrastruktuurin rakentumista.

Seuraavassa osassa siirrytään asemien lukumäärästä niiden tehoon. Sielläkin julkisuudessa liikkuu yksi luku — keskimääräinen latausteho — ja sekin osoittautuu jakaumaksi, jolla on useampi huippu kuin yksi.


Mitä tästä on hyötyä yrityksellesi

Merkintä-takaisinpyynti kuulostaa ekologiasta lainatulta kuriositeetilta. Se on yksi käyttökelpoisimmista menetelmistä, joita tiedän, ja sitä käytetään liike-elämässä hämmästyttävän vähän. Se vastaa kysymykseen, johon mikään muu menetelmä ei vastaa: kuinka paljon on sitä, mitä et ole havainnut?

Kuinka monta järjestelmää organisaatiollasi oikeasti on? DORA ja NIS2 edellyttävät kriittisten toimintojen ja ICT-omaisuuden tunnistamista. Jokainen laitehallintatietokanta, jonka olen nähnyt, on epätäydellinen. Mutta jos on tietokanta ja erikseen verkkoskannauksen tuottama laitelista, päällekkäisyys kertoo kuinka monta järjestelmää puuttuu molemmista. Se luku on johdolle yleensä epämiellyttävä yllätys — ja juuri siksi arvokas ennen kuin valvoja kysyy.

Kuinka paljon petoksia jää havaitsematta? Jos kaksi toisistaan riippumatonta kontrollia löytävät osittain samoja tapauksia, päällekkäisyydestä voi estimoida havaitsemattomien määrän. Tämä muuttaa raportoinnin luonteen: sen sijaan että kerrottaisiin “löysimme 40 tapausta”, voidaan kertoa “kontrollien peittävyys on noin 60 %, joten tapauksia on todennäköisesti 60–80”. Ensimmäinen luku kertoo kontrollien aktiivisuudesta, jälkimmäinen riskistä.

Tarkista, onko avaimesi avain. Tämän postauksen työläin osuus ei ollut mallinnus vaan sen huomaaminen, ettei tunniste yksilöi. Sama ongelma on lähes jokaisessa integraatiossa, jossa kaksi järjestelmää yhdistetään kentällä, jonka oletetaan olevan uniikki. Virhe ei näy virheilmoituksena. Se näkyy väärinä lukuina, joita kukaan ei kyseenalaista.

Älä automatisoi sitä, mikä on nopeampi tehdä käsin. Kahdenkymmenen toimijan linkitys sumealla haulla oli virhealtis ja läpinäkymätön; käsin tehtynä se vei viisi minuuttia ja on tarkistettavissa. Automaatio kannattaa siellä missä volyymi on suuri tai työ toistuu — ei siellä missä se korvaa harkintaa kahdenkymmenen rivin kohdalla.

Ja kysy, mitä kenttä oikeasti mittaa. Löysin rekisteristä kentän nimeltä “viimeksi varmistettu”, joka ei mittaa varmistusta vaan integraation aktiivisuutta. Vastaavia kenttiä on jokaisessa järjestelmässä: “viimeksi päivitetty”, “aktiivinen”, “vahvistettu”. Nimi kertoo mitä kentän piti mitata, ei sitä mitä se mittaa.

Jos organisaatiossasi on kaksi rekisteriä, joiden pitäisi kuvata samaa asiaa mutta jotka eivät täsmää, siinä on analyysi odottamassa. Teen tätä työtä vuokrattavana Head of Datana ja projektikohtaisesti — hinnasto ja yhteystiedot löytyvät sivustolta.


Aineistot: Open Charge Map API v3 ja Fintraffic AFIR / Digitraffic, molemmat haettu 2026-09-01. Koodi on kokonaisuudessaan tässä postauksessa. Maailma on jakauma.