Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Sensorjournalistikk

Visualisering i notebooks 🗺️

Medieklyngen

Når vi nå har brukt litt tid på å hente ut og analysere data med DuckDB, er neste steg å jobbe visuelt med materialet gjennom visualisering. En god og oversiktlig måte å gjøre dette på, er å jobbe i såkalte notebooks – digitale notatbøker som lar oss dokumentere hele arbeidsprosessen med både tekst og kode. Du kan kjøre notebooks lokalt, for eksempel med Jupyter Notebook, men for enkelhetens skyld bruker vi Google Colab – en nettbasert løsning som sikrer at alle har samme utgangspunkt.

Hva er notebooks?

En notebook er et interaktivt verktøy der du skriver og kjører kode direkte i nettleseren. Tenk på det som en blanding av en tekstbehandler og en kodeeditor, hvor du kan kombinere forklarende tekst, bilder og kode i én og samme arbeidsflate. Det er et populært verktøy innen datavitenskap, maskinlæring, undervisning – og ikke minst datajournalistikk.

De viktigste funksjonene

Kjøring av kode i små deler (celler)
Du kan kjøre koden stykkevis, noe som gjør det enklere å teste, feilsøke og jobbe iterativt.

Kombinasjon av tekst og kode
Du kan forklare hva du gjør underveis, noe som gjør prosessen mer forståelig – både for deg selv og andre.

Visualiseringer
Grafer, kart og diagrammer kan vises direkte i notatboken.

Dokumentasjon
Hele arbeidsprosessen lagres, slik at du enkelt kan reprodusere resultatene. Perfekt hvis du for eksempel skal sende inn en SKUP-rapport.

I de neste øvelsene skal vi bruke notebooks, DuckDB, litt enkel Python, grafverktøyet Plotly, dataverktøyet Polars og visualiseringsverktøyet Lonboard. Dette er flere nye ting på en gang, men vi holder det på et overkommelig nivå. Har du ikke programmert før, trenger du ikke bekymre deg – mange av kommandoene vil ligne på SQL-syntaksen du allerede har brukt i DuckDB.


Opprette en notebook

Vi starter med å opprette en tom notebook i Google Colab:

  1. Gå til Google Colab.

  2. Velg Fil → Ny notatbok. Du får opp en tom notatbok som ser slik ut:


Figur 1: Tom notebook.

Legg til tekst- og kodeceller

En notebook består av to hovedtyper celler: tekst og kode.

Legg til en tekstcelle

  1. Klikk på Tekst-knappen i menylinjen.


Figur 2: Legg til ny tekstlinje.

  1. Dobbeltklikk i cellen (eller trykk på Enter) for å redigere

  2. Skriv følgende:

# Installer nødvendige biblioteker

Legg til en kodecelle

  1. Klikk på Kode-knappen, og skriv inn:

!pip install lonboard==v0.13.0 duckdb==v1.4.2 plotly.express
  1. Trykk på play-ikonet til venstre for cellen for å kjøre den. Du ser nå at Colab laster ned og installerer verktøyene.

Dette installerer Lonboard, DuckDB og Plotly i notebooken.


Figur 3: Første kodecelle installerer avhengigheter.


Last inn DuckDB og nødvendige moduler

  1. Lag en ny tekstcelle med følgende innhold:

# Last inn DuckDB og nødvendige tilleggsmoduler
  1. Deretter legger du inn følgende kode i en ny kodecelle:

import duckdb
con = duckdb.connect()
con.sql('INSTALL httpfs;')
con.sql('INSTALL postgres;')
con.sql('INSTALL ducklake;')
con.sql('INSTALL spatial;')
con.sql('INSTALL h3 FROM community;')
con.sql('LOAD httpfs;')
con.sql('LOAD postgres;')
con.sql('LOAD ducklake;')
con.sql('LOAD spatial;')
con.sql('LOAD h3;')

Du vil kanskje kjenne igjen noen av kommandoene, selv om syntaksen nå er litt annerledes. Kort fortalt er dette kode skrevet i Python som sendes videre til DuckDB-biblioteket for å kjøres.

  1. Kjør cellen med play-knappen. Når alt er ferdig, vises et grønt avhukingssymbol til venstre som bekrefter at alt fungerte som det skulle.


Logg på Medieklyngens datasjø

Vi er snart klare til å hente data fra Medieklyngens datasjø. Men først må vi logge på, på samme måte som tidligere.

  1. Start med en ny tekstcelle:

# Legg inn nøkler og påloggingsinformasjon til DuckLake
  1. Og så en ny kodecelle med dette innholdet:

con.sql("CREATE SECRET (TYPE R2, KEY_ID '9030e0f90a86af08b08b6e2a1222a778', SECRET '2fe64ae1c22869400f577bb9421602f0f81a83a2f658cea6bdd556f4fc65064b', ACCOUNT_ID 'bca3475a0f4afeb0640daafc17ec2b18');")
con.sql("ATTACH 'ducklake:postgres:dbname=mcn_ducklake host=ep-fragrant-unit-a20fur0h.eu-central-1.aws.neon.tech user=fra_signaler_til_skup_h2_2025 password=npg_TLH6IjXdAW9q sslmode=require' AS mcn (DATA_PATH 'r2://mcn-ducklake', READ_ONLY);")
  1. Trykk på play. Hvis alt gikk bra, vil du se en svart linje under kodevinduet.

Gratulerer! Du er nå logget på DuckLake og klar til å hente og visualisere data.


Last inn og undersøk data

Nå skal vi hente inn et datasett fra 11. juli 2025. Start med å legge til en ny tekstcelle med følgende tittel:

# Last inn data fra 11. juli 2025

Deretter legger du inn denne kodecellen:

con.sql("CREATE OR REPLACE TABLE FLIGHTS_11_07_2025 AS SELECT timestamp, icao, registration, squawk, flight, latitude, longitude, altitude_barometric, on_ground, ground_speed, flydenity_nation, h3_15, is_military, is_interesting, is_pia, is_ladd FROM mcn.airtraces WHERE timestamp >= '2025-07-11 00:00:00' and timestamp < '2025-07-12 00:00:00';")

Dette kan ta litt tid. Du ser fremdriften via den horisontale linjen under cellen. Når den grønne avkrysningsboksen vises, er operasjonen fullført og du kan gå videre.

Tell antall datapunkter

Legg til en ny tekstcelle:

# Tell antall datapunkter

Og deretter en kodecelle:

con.sql("SELECT count(*) FROM FLIGHTS_11_07_2025")

Du får et svar som dette:

┌──────────────┐
│ count_star() │
│    int64     │
├──────────────┤
│      7132121 │
└──────────────┘

Over 7,1 millioner rader – et solid utgangspunkt!


Se på datastrukturen

Legg inn en ny tekstcelle:

# Beskriv datastrukturen

Og legg deretter inn denne kodecellen:

con.sql("DESCRIBE FLIGHTS_11_07_2025").show(max_width=100)

Resultatet ser omtrent slik ut – og mye bør nå være kjent stoff:

┌─────────────────────┬──────────────────────────┬─────────┬─────────┬─────────┬─────────┐
│     column_name     │       column_type        │  null   │   key   │ default │  extra  │
│       varchar       │         varchar          │ varchar │ varchar │ varchar │ varchar │
├─────────────────────┼──────────────────────────┼─────────┼─────────┼─────────┼─────────┤
│ timestamp           │ TIMESTAMP WITH TIME ZONE │ YES     │ NULL    │ NULL    │ NULL    │
│ icao                │ VARCHAR                  │ YES     │ NULL    │ NULL    │ NULL    │
│ registration        │ VARCHAR                  │ YES     │ NULL    │ NULL    │ NULL    │
│ squawk              │ VARCHAR                  │ YES     │ NULL    │ NULL    │ NULL    │
│ flight              │ VARCHAR                  │ YES     │ NULL    │ NULL    │ NULL    │
│ latitude            │ DECIMAL(9,6)             │ YES     │ NULL    │ NULL    │ NULL    │
│ longitude           │ DECIMAL(10,6)            │ YES     │ NULL    │ NULL    │ NULL    │
│ altitude_barometric │ INTEGER                  │ YES     │ NULL    │ NULL    │ NULL    │
│ on_ground           │ BOOLEAN                  │ YES     │ NULL    │ NULL    │ NULL    │
│ ground_speed        │ DECIMAL(6,1)             │ YES     │ NULL    │ NULL    │ NULL    │
│ flydenity_nation    │ VARCHAR                  │ YES     │ NULL    │ NULL    │ NULL    │
│ h3_15               │ VARCHAR                  │ YES     │ NULL    │ NULL    │ NULL    │
│ is_military         │ BOOLEAN                  │ YES     │ NULL    │ NULL    │ NULL    │
│ is_interesting      │ BOOLEAN                  │ YES     │ NULL    │ NULL    │ NULL    │
│ is_pia              │ BOOLEAN                  │ YES     │ NULL    │ NULL    │ NULL    │
│ is_ladd             │ BOOLEAN                  │ YES     │ NULL    │ NULL    │ NULL    │
├─────────────────────┴──────────────────────────┴─────────┴─────────┴─────────┴─────────┤
│ 16 rows                                                                      6 columns │
└────────────────────────────────────────────────────────────────────────────────────────┘

Inspiser dataene

Neste steg er å ta en nærmere titt på selve innholdet i tabellen. Legg først inn en tekstcelle:

# Inspiser dataene

Deretter denne kodecellen:

con.sql("SELECT * FROM FLIGHTS_11_07_2025").show(max_width=300)

Du får nå en tabell med flydata som inneholder alle de 16 kolonnene vi valgte ut.

┌────────────────────────────┬─────────┬──────────────┬─────────┬─────────┬──────────────┬───────────────┬─────────────────────┬───────────┬──────────────┬──────────────────┬─────────────────┬─────────────┬────────────────┬─────────┬─────────┐
│         timestamp          │  icao   │ registration │ squawk  │ flight  │   latitude   │   longitude   │ altitude_barometric │ on_ground │ ground_speed │ flydenity_nation │      h3_15      │ is_military │ is_interesting │ is_pia  │ is_ladd │
│  timestamp with time zone  │ varchar │   varchar    │ varchar │ varchar │ decimal(9,6) │ decimal(10,6) │        int32        │  boolean  │ decimal(6,1) │     varchar      │     varchar     │   boolean   │    boolean     │ boolean │ boolean │
├────────────────────────────┼─────────┼──────────────┼─────────┼─────────┼──────────────┼───────────────┼─────────────────────┼───────────┼──────────────┼──────────────────┼─────────────────┼─────────────┼────────────────┼─────────┼─────────┤
│ 2025-07-11 10:34:43.931+00 │ 4ACA73  │ SE-RSS       │ 6022    │ SAS4112 │    64.378188 │     12.515735 │               35000 │ false     │        452.9 │ Sweden           │ 8f08022d34b1665 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.931+00 │ 47839C  │ LN-OOB       │ 5672    │ DOC79   │    64.031348 │     10.984241 │                4325 │ false     │        147.3 │ Norway           │ 8f0802d69246693 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.931+00 │ 4ACA72  │ SE-RSR       │ 6031    │ SAS71A  │    62.313664 │     11.196864 │               36000 │ false     │        457.0 │ Sweden           │ 8f0816add48121b │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.939+00 │ 4007DA  │ G-SAJL       │ 2201    │ LOG44L  │    58.416092 │      4.262695 │               21650 │ false     │        337.8 │ United Kingdom   │ 8f09864302908c1 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.964+00 │ 4406DE  │ OE-LPE       │ 0603    │ AUA81   │    61.292072 │     -0.466207 │               34000 │ false     │        471.9 │ Austria          │ 8f09a56621a6b75 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.967+00 │ 478ECD  │ LN-WDO       │ 0233    │ WIF8KT  │    63.346664 │     10.466485 │                9500 │ false     │        234.9 │ Norway           │ 8f0815100d86442 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.979+00 │ 478D83  │ LN-WDR       │ 2563    │ WIF569  │    62.702592 │      7.292074 │               18675 │ false     │        232.4 │ Norway           │ 8f08328a5a58961 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.979+00 │ 478DF2  │ LN-WEB       │ 0402    │ WIF2GB  │    62.297600 │      8.238267 │               40000 │ false     │        431.6 │ Norway           │ 8f083240b48d749 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.997+00 │ 485871  │ PH-EXV       │ 0514    │ KLM94Q  │    56.403576 │      4.843254 │               38000 │ false     │        473.8 │ Netherlands      │ 8f0996803d43c1c │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:34:43.997+00 │ 46B8AB  │ SX-NEK       │ 6024    │ AEE757  │    58.051248 │     13.013580 │               37000 │ false     │        457.9 │ Greece           │ 8f08b6b0485d4d9 │ false       │ false          │ false   │ false   │
│             ·              │   ·     │   ·          │  ·      │   ·     │        ·     │          ·    │                 ·   │   ·       │          ·   │   ·              │        ·        │   ·         │   ·            │   ·     │   ·     │
│             ·              │   ·     │   ·          │  ·      │   ·     │        ·     │          ·    │                 ·   │   ·       │          ·   │   ·              │        ·        │   ·         │   ·            │   ·     │   ·     │
│             ·              │   ·     │   ·          │  ·      │   ·     │        ·     │          ·    │                 ·   │   ·       │          ·   │   ·              │        ·        │   ·         │   ·            │   ·     │   ·     │
│ 2025-07-11 10:36:06.677+00 │ 4007DA  │ G-SAJL       │ 2201    │ LOG44L  │    58.461556 │      4.488373 │               18950 │ false     │        327.0 │ United Kingdom   │ 8f09864e39148c5 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.747+00 │ 406544  │ G-EZUK       │ 1402    │ EZY53ZV │    59.333632 │      8.241577 │               34025 │ false     │        460.0 │ United Kingdom   │ 8f099dcd294e6f1 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.747+00 │ 4787AA  │ LN-WDL       │ 1207    │ WIF4AH  │    59.810920 │      8.250638 │               24000 │ false     │        306.5 │ Norway           │ 8f099db997160e2 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.749+00 │ 45AB4D  │ OY-JZM       │ 6637    │ JTD410  │    53.032124 │     13.081107 │               38000 │ false     │        437.9 │ Denmark          │ 8f1f1dda82dc6dd │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.749+00 │ 461F9E  │ OH-LZI       │ 6440    │ FIN1DF  │    51.973800 │     14.137268 │               35000 │ false     │        422.2 │ Finland          │ 8f1f195332c1b23 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.758+00 │ 484C52  │ PH-EZN       │ 1000    │ KLM1921 │    52.296660 │      5.160904 │               13450 │ false     │        380.9 │ Netherlands      │ 8f1969ccd5638b2 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.763+00 │ 479E29  │ LN-OQT       │ 4177    │ HKS125  │    58.878752 │      5.625092 │                -250 │ false     │          3.7 │ Norway           │ 8f0982166752bb3 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.771+00 │ 4BA95A  │ TC-JJZ       │ 7673    │ THY76Y  │    59.895492 │      6.365387 │               33000 │ false     │        486.8 │ Turkey           │ 8f0983a2266c925 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.771+00 │ 4D24AE  │ 9H-WDQ       │ 6516    │ WZZ47HA │    60.044848 │      6.288398 │               10500 │ false     │        318.3 │ Malta            │ 8f0983b6a655973 │ false       │ false          │ false   │ false   │
│ 2025-07-11 10:36:06.772+00 │ 478DF2  │ LN-WEB       │ 0402    │ WIF2GB  │    62.164904 │      8.031413 │               40000 │ false     │        431.1 │ Norway           │ 8f0832582303583 │ false       │ false          │ false   │ false   │
├────────────────────────────┴─────────┴──────────────┴─────────┴─────────┴──────────────┴───────────────┴─────────────────────┴───────────┴──────────────┴──────────────────┴─────────────────┴─────────────┴────────────────┴─────────┴─────────┤
│ ? rows (>9999 rows, 20 shown)                                                                                                                                                                                                        16 columns │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Antall fly per land

La oss gjøre en øvelse vi også har gjort tidligere i DuckDB: Vi teller antall fly per land.

Start med å legge inn følgende tekstcelle:

# Tell antall fly per land

Deretter denne kodecellen:

sql = """
SELECT   flydenity_nation as land,
         COUNT(DISTINCT icao) AS fly
FROM     FLIGHTS_11_07_2025
WHERE    LENGTH(flydenity_nation) > 0
GROUP BY 1
ORDER BY 2 DESC
"""
con.sql(sql).show(max_rows=30)

Denne spørringen gir oss en oversikt over hvor mange ulike fly som er registrert i hvert land den dagen. Utvalget vises med maksimum 30 rader.

┌──────────────────────┬───────┐
│         land         │  fly  │
│       varchar        │ int64 │
├──────────────────────┼───────┤
│ Germany              │   409 │
│ United Kingdom       │   390 │
│ Netherlands          │   289 │
│ Norway               │   206 │
│ Ireland              │   201 │
│ Turkey               │   200 │
│ Malta                │   190 │
│ United States        │   169 │
│ Sweden               │   132 │
│ Austria              │   107 │
│ Poland               │    92 │
│ China                │    84 │
│ Denmark              │    70 │
│ United Arab Emirates │    64 │
│ Qatar                │    62 │
│   ·                  │     · │
│   ·                  │     · │
│   ·                  │     · │
│ Malaysia             │     2 │
│ Bangladesh           │     1 │
│ Georgia              │     1 │
│ Aruba                │     1 │
│ Belarus              │     1 │
│ Sri Lanka            │     1 │
│ Kenya                │     1 │
│ Mauritius            │     1 │
│ Brunei               │     1 │
│ Oman                 │     1 │
│ Slovenia             │     1 │
│ Guernsey             │     1 │
│ Indonesia            │     1 │
│ Algeria              │     1 │
│ Luxembourg           │     1 │
├──────────────────────┴───────┤
│ 76 rows (30 shown) 2 columns │
└──────────────────────────────┘

Kanskje du tenker at dette minner litt vel mye om det vi gjorde direkte i DuckDB. Helt enig! Nå er det på tide å ta visualiseringen et hakk videre.

Visualiser fly per land i penere tabell

Vi starter enkelt, med å konvertere dataene til et format som er mer egnet for moderne nettleservisning. Dette gjør vi ved hjelp av Polars – et raskt og fleksibelt Python-verktøy for databehandling.

Legg til en tekstcelle:

# Visualiser fly per land i penere tabell

Deretter denne kodecellen:

import polars as pl

pl.Config(set_tbl_rows=100)
flyperland = con.sql(sql).pl()
flyperland

Resultatet er en langt mer leservennlig tabell enn den rå tekstutskriften fra DuckDB:

landfly
“Germany”409
“United Kingdom”390
“Netherlands”289
“Norway”206
“Ireland”201
“Turkey”200
“Malta”190
“United States”169
“Sweden”132
“Austria”107
“Poland”92
“China”84
“Denmark”70
“United Arab Emirates”64
“Qatar”62
“Finland”61
“Spain”58
“France”55
“Russia”52
“Portugal”46
“Belgium”39
“Liechtenstein”38
“Hungary”37
“Latvia”33
“Iceland”27
“Canada”27
“Bulgaria”21
“Czech Republic”16
“Greece”16
“Lithuania”15
“Israel”14
“India”14
“Romania”13
“Singapore”13
“Egypt”12
“Jordan”8
“Slovakia”8
“Ethiopia”7
“Estonia”7
“Serbia”7
“Saudi Arabia”6
“Croatia”6
“Japan”5
“Uzbekistan”5
“Bahrain”5
“San Marino”5
“Thailand”5
“Morocco”4
“Australia”4
“Isle of Man”4
“Italy”4
“Taiwan”3
“Ukraine”3
“South Korea”3
“Azerbaijan”3
“Kuwait”3
“Zimbabwe”2
“Lebanon”2
“Pakistan”2
“Tunisia”2
“Malaysia”2
“Brazil”2
“Slovenia”1
“Guernsey”1
“Mauritius”1
“Oman”1
“Kenya”1
“Brunei”1
“Algeria”1
“Luxembourg”1
“Indonesia”1
“Belarus”1
“Bangladesh”1
“Aruba”1
“Georgia”1
“Sri Lanka”1

Her skjer det tre ting:

  1. Vi importerer Polars

  2. Vi setter opp konfigurasjonen slik at tabellen kan vise opptil 100 rader

  3. Vi bruker .pl() til å hente resultatet fra DuckDB-spørringen som en Polars-tabell, og viser den ved å referere til variabelen flyperland

Lagre tabellen til CSV

En av fordelene med Polars er hvor enkelt det er å eksportere data til ulike formater. Når du har hentet ut et datasett du vil ta vare på eller bruke videre, er det for eksempel veldig lett å lagre det som en CSV-fil.

Legg til en tekstcelle:

# Lagre til CSV

Og deretter denne kodecellen:

flyperland.write_csv("flyperland.csv")

Hvor finner jeg filen?

Et godt spørsmål! Til venstre i Google Colab-vinduet finner du en vertikal meny. Klikk på mappesymbolet nederst for å åpne filvisningen. Om du ikke ser filen med en gang, kan du klikke på last inn på nytt-knappen (de to sirklene som danner en pil) for å oppdatere visningen.


Figur 4: Filoversikten i Google Colab – klikk på mappen nederst, og last inn på nytt om nødvendig.


Stolpediagram

Nok tall og tabeller – nå er det på tide med litt visuell magi! Og da passer det perfekt å introdusere et nytt verktøy: Plotly. Dette visualiseringsbiblioteket lar deg lage stilrene og interaktive grafer med minimale mengder kode.

La oss starte med å lage et stolpediagram over fly per land.

Legg til en tekstcelle:

# Visualiser som stolpediagram

Deretter denne kodecellen:

import plotly.express as px

stolpe = px.bar(flyperland, x='land', y='fly', title="Fly per land")
stolpe


Figur 5: Fly per land visualisert som stolpediagram.

Så enkelt – og så fint! Diagrammet er interaktivt, og du kan blant annet:

Lagre som HTML

Vil du lagre hele grafen med all interaktiviteten intakt? Utviklerne bak Plotly har tenkt på det også. Du kan enkelt lagre visualiseringen som en HTML-fil.

Legg til en tekstcelle:

# Lagre til HTML

Deretter denne kodecellen:

stolpe.write_html("stolpe.html")

Filen dukker opp i den samme filutforskeren som vi brukte da vi lagret CSV. Klikk på mappesymbolet til venstre, og husk å trykke last inn på nytt hvis du ikke ser filen med én gang.


Kakediagram

Siden flyobservasjonene våre utgjør en absolutt mengde, egner de seg også godt for visualisering som kakediagram. Det krever bare en liten endring fra stolpediagrammet, og vips – dataene blir til kake.

Legg til en tekstcelle:

# Visualiser som kakediagram

Deretter denne kodecellen:

kake = px.pie(flyperland, names='land', values='fly', title="Fly per land")
kake


Figur 6: Fly per land visualisert som (et litt sprengt) kakediagram.

Tada! Kakediagram, rett ut av ovnen. Men – dette ser kanskje litt sprengt ut?

Når vi presser inn 75 land med ulikt antall fly i én og samme sirkel, blir resultatet både visuelt rotete og vanskelig å lese. Mange land har bare noen få fly, og det gir lite mening å gi dem hver sin tynne kakebit.

Begrens detaljnivået – grupper små land

Løsningen? Vi samler alle land med færre enn 100 observerte fly i en felles gruppe: Other countries. Det gjør vi ved å legge inn litt enkel logikk i SQL-spørringen.

Legg til en tekstcelle:

# Kakediagram – grupper små land

Deretter denne kodecellen:

sql = """
WITH flight_counts AS (
    SELECT
        flydenity_nation as land,
        COUNT(DISTINCT icao) AS fly
    FROM
        FLIGHTS_11_07_2025
    WHERE
        LENGTH(flydenity_nation) > 0
    GROUP BY
        flydenity_nation
)

SELECT
    CASE
        WHEN fly < 100 THEN 'Other countries'
        ELSE land
    END AS land,
    CAST(SUM(fly) AS BIGINT) AS fly
FROM
    flight_counts
GROUP BY
    1
ORDER BY
    2 DESC;
"""

flyperland = con.sql(sql).pl()

kake = px.pie(flyperland, names='land', values='fly', title="Fly per land")
kake

Her har vi bakt litt enkel programmeringslogikk inn i spørringen. Kort fortalt finner vi land som har under 100 observerte fly den aktuelle tidsperioden, og legger disse til i kategorien Other countries. Da ser vi umiddelbart at kakediagrammet både blir penere og mer lesbart.


Figur 7: Fly per land visualisert som (et penere) kakediagram.

Ved hjelp av CASE WHEN-logikken over slår vi sammen alle smånasjonene i én kategori. Resultatet? Et diagram som både ser bedre ut og er langt lettere å lese.


Widerøe-flyet som nødlandet i Bergen

La oss vende tilbake til et eksempel vi har sett på tidligere. Fredag 11. juli 2025 nødlandet et Widerøe-fly i Bergen. Nå skal vi jobbe oss gjennom hvordan vi kan hente ut, analysere og visualisere flydata knyttet til en slik hendelse ved hjelp av notebooks – på måter som kunne vært relevante i lokal nyhetsdekning.

Hent ut data for aktuell dag

Vi har «jukset litt» for å spare tid, og har allerede hentet ut de nødvendige dataene for den aktuelle dagen gjennom spørringen vi kjørte tidligere i kapittelet. For å repetere:

Legg til en tekstcelle:

# Last inn data fra 11. juli 2025

Deretter denne kodecellen:

con.sql("CREATE OR REPLACE TABLE FLIGHTS_11_07_2025 AS SELECT timestamp, icao, registration, squawk, flight, latitude, longitude, altitude_barometric, on_ground, ground_speed, flydenity_nation, h3_15, is_military, is_interesting, is_pia, is_ladd FROM mcn.airtraces WHERE timestamp >= '2025-07-11 00:00:00' and timestamp < '2025-07-12 00:00:00';")

Finn ICAO-kode

Vi vet at flyet som nødlandet hadde registreringsnummer LN-WDI. For å få flest mulig datapunkter hentet ut, trenger vi som tidligere sett å finne ICAO-koden til dette flyet. Det gjør vi med denne spørringen.

Legg til en tekstcelle:

# Finn ICAO-kode for LN-WDI

Deretter denne kodecellen:

sql = """
SELECT timestamp, icao, registration 
FROM FLIGHTS_11_07_2025 
WHERE registration = 'LN-WDI' 
LIMIT 1;
"""

con.sql(sql)

Dette gir oss:

┌────────────────────────────┬─────────┬──────────────┐
│         timestamp          │  icao   │ registration │
│  timestamp with time zone  │ varchar │   varchar    │
├────────────────────────────┼─────────┼──────────────┤
│ 2025-07-11 05:48:55.648+00 │ 4785BF  │ LN-WDI       │
└────────────────────────────┴─────────┴──────────────┘

Vi ser at flyet har ICAO-koden 4785BF.

Filtrer på aktuell hendelse

Deretter filtrerer vi datasettet på ICAO-koden 4785BF i aktuell tidsperiode.

Legg til en tekstcelle:

# Filtrer på ICAO-kode 4785BF i aktuell tidsperiode

Deretter denne kodecellen:

sql = """
SELECT 
    icao, 
    registration, 
    CAST(longitude AS DOUBLE) as longitude, 
    CAST(latitude AS DOUBLE) as latitude, 
    altitude_barometric, 
    CAST(ground_speed AS DOUBLE) as ground_speed, 
    timestamp
FROM FLIGHTS_11_07_2025
WHERE icao = '4785BF' 
    AND timestamp >= '2025-07-11 05:40:00' 
    AND timestamp < '2025-07-11 06:09:00' 
    AND latitude IS NOT NULL 
    AND longitude IS NOT NULL 
    AND altitude_barometric IS NOT NULL
ORDER BY timestamp ASC;
"""

nodlanding = con.sql(sql).pl()
pl.Config(set_tbl_rows=10)
nodlanding

Resultatet viser alle datapunktene vi har om denne flyvningen, filtrert på ICAO-kode og tidsintervall:

icaoregistrationlongitudelatitudealtitude_barometricground_speedtimestamp
“4785BF”“LN-WDI”5.22056660.286568-75153.82025-07-11 05:42:24.781 UTC
“4785BF”“LN-WDI”5.22056660.286568-75153.82025-07-11 05:42:24.813 UTC
“4785BF”“LN-WDI”5.21452860.303408300154.92025-07-11 05:42:25.543 UTC
“4785BF”“LN-WDI”5.21424460.304136350155.52025-07-11 05:42:26.527 UTC
“4785BF”“LN-WDI”5.21409260.304516375155.52025-07-11 05:42:27.069 UTC
“4785BF”“LN-WDI”5.21149760.313064150141.92025-07-11 06:05:56.868 UTC
“4785BF”“LN-WDI”5.21183660.312104125141.92025-07-11 06:05:58.348 UTC
“4785BF”“LN-WDI”5.21291860.30876075140.92025-07-11 06:06:03.678 UTC
“4785BF”“LN-WDI”5.21330760.30744850140.92025-07-11 06:06:05.660 UTC
“4785BF”“LN-WDI”5.21350360.30712050140.92025-07-11 06:06:06.154 UTC

Ved å bruke pl.Config(set_tbl_rows=10) får vi automatisk vist de første og siste 5 radene i tabellen – en praktisk måte å få et raskt inntrykk av datasettet på.


Høyde og fart over tid

La oss se nærmere på hvordan flyet beveget seg gjennom luftrommet, både i høyde og fart.

Visualiser høyde

Legg til en tekstcelle:

# Flyets høyde over tid

Deretter denne kodecellen:

linje = px.line(nodlanding, x='timestamp', y='altitude_barometric', title="Flyets høyde over tid")
linje


Figur 8: Flyets høyde over tid.

Visualiser fart

Legg til en tekstcelle:

# Flyets fart over tid

Deretter denne kodecellen:

linje = px.line(nodlanding, x='timestamp', y='ground_speed', title="Flyets fart over tid")
linje


Figur 9: Flyets fart over tid.

Enkelt, oversiktlig og nyttig. Her får vi raskt et bilde av flyets bevegelser og endringer underveis. Og som vi ser – til tross for nødlandingen ser dataene så langt ganske udramatiske.

Visualisering på kart

Grafene gir oss innsikt, men i hendelsesjournalistikk er det ofte kartet som virkelig skaper forståelse. Heldigvis kan vi enkelt plassere flyets rute direkte på et interaktivt kart med Plotly.

Legg til en tekstcelle:

# Plott Widerøe-flighten på et kart

Deretter denne kodecellen:

kart = px.line_map(nodlanding, lat='latitude', lon='longitude', hover_data=['timestamp', 'altitude_barometric', 'ground_speed'])
kart.update_layout(height=540, width=960, map_zoom=8.4, map_center_lat=60.42, map_center_lon=5.2)
kart


Figur 10: Widerøe-flyets rute plottet på kart.

Og der har vi det! Flyets ferd fra Flesland, via Norhordland, og tilbake igjen til Flesland.

Selv om det krever litt mer kode enn de tidligere grafene, er det fortsatt overkommelig. Her bruker vi line_map for å tegne ruten basert på geografiske koordinater. Ved å legge til hover_data får vi i tillegg opp info om tidspunkt, høyde og fart når vi beveger musepekeren over ruten.

Når du er fornøyd med visualiseringen, kan du eksportere kartet som en PNG eller lagre det som HTML med full interaktivitet – perfekt for bruk i en nyhetsartikkel på nett.

Heatmaps med Lonboard

Vi var så vidt innom heatmaps da vi jobbet med sanntidsdata tidligere i kurset. Som du kanskje husker, er dette en utmerket måte å kartlegge flyaktivitet på: Hvor er det mest trafikk, og hvordan fordeler bevegelsene seg i rom og tid?

Visualisering av slike mønstre krever ofte store datamengder – og da kommer Lonboard til sin rett. Verktøyet er spesiallaget for å håndtere store geografiske datasett, og genererer heatmaps på en både rask og imponerende måte.

La oss bruke Lonboard til å lage et heatmap av all tilgjengelig flytrafikk 11. juli 2025.

Forbered dataene

Nok en gang bruker vi de samme dataene som tidligere. Vi har allerede hentet dem ut, men for å repetere:

Legg til en tekstcelle:

# Last inn data fra 11. juli 2025

Deretter denne kodecellen:

con.sql("CREATE OR REPLACE TABLE FLIGHTS_11_07_2025 AS SELECT timestamp, icao, registration, squawk, flight, latitude, longitude, altitude_barometric, on_ground, ground_speed, flydenity_nation, h3_15, is_military, is_interesting, is_pia, is_ladd FROM mcn.airtraces WHERE timestamp >= '2025-07-11 00:00:00' and timestamp < '2025-07-12 00:00:00';")

Gjør dataene klare for Lonboard

Vi lager en spørring som henter ut tidspunkt, posisjon og registreringsnummer, og gjør posisjonen om til et geometriobjekt (ST_POINT).

Legg til en tekstcelle:

# Forbered data for Lonboard

Deretter denne kodecellen:

sql = """
SELECT 
  timestamp as time, 
  ST_POINT(longitude, latitude) as geom, 
  registration as regnr 
FROM FLIGHTS_11_07_2025
WHERE longitude IS NOT NULL 
  AND latitude IS NOT NULL
GROUP BY ALL;
"""
query = con.sql(sql)

Generer heatmap

Nå er alt klart! Vi bruker Lonboards HeatmapLayer og tegner det på et interaktivt kart.

Legg til en tekstcelle:

# Generer heatmap med Lonboard

Deretter denne kodecellen:

from lonboard import Map, HeatmapLayer, PathLayer

layer = HeatmapLayer.from_duckdb(query, con)
m = Map(layer)
m

Og voilà – du får opp et kart som viser hvor det var mest aktivitet i luftrommet denne dagen. De varme, røde områdene indikerer høy konsentrasjon av datapunkter, mens kjøligere områder har mindre trafikk.


Figur 11: Heatmap av flytrafikk 11. juli 2025, generert med Lonboard.


Politihelikopternes bevegelser

Det kan være nyttig å se all flytrafikk på ett kart – men det er enda mer spennende når vi snevrer det inn. La oss undersøke politihelikopternes bevegelser 11. juli 2025.

Finn alle observasjoner

Legg til en tekstcelle:

# Observasjoner av politihelikoptre 11. juli 2025

Deretter denne kodecellen:

sql = """
SELECT 
  timestamp as time, 
  ST_POINT(longitude, latitude) as geom, 
  registration as regnr 
FROM FLIGHTS_11_07_2025
WHERE longitude IS NOT NULL 
  AND latitude IS NOT NULL
  AND (registration = 'LN-ORA' OR registration = 'LN-ORB' OR registration = 'LN-ORC')
GROUP BY ALL;
"""
query = con.sql(sql)

Generer heatmap for politihelikoptre

Legg til en tekstcelle:

# Heatmap av politihelikoptre

Deretter denne kodecellen:

from lonboard import Map, HeatmapLayer, PathLayer

layer = HeatmapLayer.from_duckdb(query, con)
m = Map(layer)
m


Figur 12: Heatmap av politihelikoptre 11. juli 2025, generert med Lonboard.

Se der, ja! Vi ser tydelig en konsentrasjon av aktivitet på Østlandet. Men hvilket helikopter var i luften – og når?

Oversikt over aktivitet per time

Legg til en tekstcelle:

# Se hvilket politihelikopter som var i luften når

Deretter denne kodecellen:

sql = """
SELECT 
  registration as regnr,
  EXTRACT(HOUR FROM CAST(timestamp AS TIMESTAMP)) AS time,
  COUNT(*) AS datapunkter
FROM FLIGHTS_11_07_2025 
WHERE (registration = 'LN-ORA' OR registration = 'LN-ORB' OR registration = 'LN-ORC')
GROUP BY registration, time
ORDER BY time;
"""
query = con.sql(sql)
query.pl()

Dette viser antall datapunkter per registreringsnummer og time (time på døgnet):

regnrtimedatapunkter
“LN-ORA”01273
“LN-ORB”04
“LN-ORB”196
“LN-ORB”18533
“LN-ORB”19372
“LN-ORC”19162
“LN-ORB”20162

Her ser vi at alle helikopterne var aktive i løpet av dagen. LN-ORA og LN-ORB var begge i luften rundt midnatt UTC, mens LN-ORB og LN-ORC tok av igjen neste kveld. La oss se på LN-ORA, som har flest punkter på kortest tid.

Visualiser bevegelsene til LN-ORA

Legg til en tekstcelle:

# LN-ORA 11. juli 2025

Deretter denne kodecellen:

sql = """
SELECT 
  timestamp as time, 
  ST_POINT(longitude, latitude) as geom, 
  registration as registration 
FROM FLIGHTS_11_07_2025
WHERE longitude IS NOT NULL 
  AND latitude IS NOT NULL
  AND registration = 'LN-ORA'
GROUP BY ALL;
"""
query = con.sql(sql)

Deretter denne kodecellen:

from lonboard import viz
viz(query)


Figur 13: Bevegelser for politihelikopteret LN-ORA, visualisert med Lonboard.

Nå ser vi alle registrerte bevegelser for LN-ORA denne dagen. Klikker du med musepekeren på brødsmulestien på kartet, får du opp tidspunkter – og du kunne lett lagt til høyde, fart og andre datapunkter om ønskelig.


Visualisere et geografisk område

Tidligere i kurset har vi sett hvordan vi kan filtrere flydata basert på geografiske områder. Nå henter vi frem igjen H3-systemet, og bruker det til å lage en visualisering av flytrafikken i en gitt sektor.

Ved å velge en H3-sektor med oppløsning 2 midt i Sør-Norge, kan vi hente ut all flytrafikk i dette området for datoen vår, 11. juli 2025.

Legg til en tekstcelle:

# Visualisere et geografisk område

Deretter denne kodecellen:

sql = """
SELECT 
  registration as regnr,
  EXTRACT(HOUR FROM CAST(timestamp AS TIMESTAMP)) AS time,
  COUNT(*) AS datapunkter, 
  ST_POINT(longitude, latitude) as geom, 
  timestamp
FROM FLIGHTS_11_07_2025 
WHERE h3_cell_to_parent(h3_15, 2) = '82098ffffffffff'
GROUP BY ALL
ORDER BY time;
"""
query = con.sql(sql)

Heatmap over all trafikk i en gitt sektor

Vi fortsetter med å lage en Lonboard-heatmap av sektoren vår.

Legg til en tekstcelle:

# Lage heatmap av geografisk område

Deretter denne kodecellen:

layer = HeatmapLayer.from_duckdb(query, con)
m = Map(layer)
m


Figur 14: Heatmap av flyaktivitet i en H3-sektor.

Interessant! Vi kan nesten ane heksagonens konturer. Men vi gjør det enda tydeligere:

Viz-kart over all trafikk i en gitt sektor

Legg til en tekstcelle:

# Lage Viz-kart av geografisk område

Deretter denne kodecellen:

viz(query)


Figur 15: Viz-kart over geografisk område.

Enkle linjer – og nå ser vi tydelig H3-heksagonens form. Og som alltid med Viz-kart: du kan klikke med musepekeren på brødsmulestiene for å se detaljer som fartøyenes registreringsnummer og tidspunkter.


Visualisere all militær trafikk

Nå gjør vi en liten justering – og bruker samme metode for å vise all militærtrafikk i datasettet. Alt vi trenger er et lite filter til.

Legg til en tekstcelle:

# Visualisere all militær trafikk

Deretter denne kodecellen:

sql = """
SELECT 
  timestamp as time, 
  ST_POINT(longitude, latitude) as geom, 
  registration as regnr 
FROM FLIGHTS_11_07_2025
WHERE longitude IS NOT NULL 
  AND latitude IS NOT NULL
  AND is_military
GROUP BY ALL;
"""
query = con.sql(sql)

Lage heatmap av all militær trafikk

Legg til en tekstcelle:

# Lage heatmap av all militær trafikk

Deretter denne kodecellen:

layer = HeatmapLayer.from_duckdb(query, con)
m = Map(layer)
m


Figur 16: Heatmap av all militær trafikk 11. juli 2025.

Her var det litt å boltre seg i. La oss visualisere de samme dataene på et Viz-kart:

Viz-kart med detaljert info

Legg til en tekstcelle:

# Lage Viz-kart av all militær trafikk

Deretter denne kodecellen:

from lonboard import viz
viz(query)


Figur 17: Viz-kart over militær trafikk.

Her får vi den fulle oversikten – og du kan enkelt undersøke hvilke fartøy som var i luften, hvor og når.


Oppsummering

I dette kapittelet har vi utforsket hvordan notebooks kan brukes til å analysere og visualisere flydata. La oss oppsummere de viktigste punktene:

Hva er notebooks?

Verktøykassen

DuckDB

Polars

Plotly

Lonboard

Praktiske eksempler

Vi analyserte over 7,1 millioner datapunkter fra 11. juli 2025, inkludert:

Viktige læringspunkter

Med denne kunnskapen kan du nå kombinere SQL-analyse med kraftige visualiseringer i en dokumentert arbeidsflyt!


Oppgaver

Nå har du fått en grunnleggende innføring i bruk av notebooks, DuckDB, Polars, Plotly og Lonboard. Tid for litt praktisk trening: