# Väderdata med Boost

15 oktober 2026 | https://www.ribomation.se/blog/2026/vaderdata-med-boost/

Varje dag sedan 1929 har väderstationer runt om i världen noterat temperatur, nederbörd och vind. Den amerikanska myndigheten NOAA delar generöst med sig av allt detta: ett arkiv per år, med en fil för var och en av drygt tolv tusen stationer. För 2024 blir det nästan 900 MB data och fyra miljoner observationer. Frågan är enkel: vilka stationer hade årets varmaste respektive kallaste medeltemperatur? Att svara på den är lite svårare, i alla fall om man inte vill ladda ned, packa upp och läsa in allt i minnet först.

I min förra artikel beskrev jag det populära ramverket Boost, som skapades för att vara _plantskola_ för framtida datatyper och funktioner i standardbiblioteket för C++.

Den här artikeln visar hur Boost löser problemet ovan i ett enda flöde. Vi hämtar arkivet över HTTPS med Beast, packar upp GZIP-strömmen med Iostreams, tolkar CSV-raderna med Tokenizer och räknar fram statistiken med Accumulators, medan datan strömmar förbi. Programmet byggs i fyra steg, och på vägen stöter vi både på Boost-bibliotek som gör jobbet åt oss och på luckor som tvingar oss att skriva egna komponenter. Demokoden finner du på GitHub i vanlig ordning, se länkarna sist i denna artikel.

* * *

Vad programmet ska göra
-----------------------

Det färdiga programmet ska ladda ned en komprimerad TAR-fil innehållande klimatdata över ett år insamlat från många olika väderstationer. Arkivfilen består av CSV-filer, en per väderstation. Varje stationsfil innehåller väderdata för ett helt år, en rad per dag.

Väderdatat tillhandahålls av **NCEI** vilket står för _National Centers for Environmental Information_, som utgör en del av **NOAA** vilket står för _National Oceanic and Atmospheric Administration_. Det är USA:s federala myndighet för väder, hav och klimat och ligger under handelsdepartementet.

  ![Archive files, one for each year](/blog/2026/vaderdata-med-boost/year-archive-files-602w.png "Archive files, one for each year")

Här finns insamlad väderdata från 1929 fram till 2025. Först från och med 1950-talet så finns det mer fullständig data. Tidigare år har data från betydligt färre väderstationer och somligt är ofullständigt. Datasetet heter **GSOD**, vilket står för _Global Summary of the Day_, och innehåller en daglig sammanfattning per station, med bl.a. medel-, min- och maxtemperatur, nederbörd, vind och lufttryck, beräknad ur stationernas timvisa mätningar.

Vissa filer är inte kompletta, utan kan sakna flera dagsrader, och vissa rader kan sakna fältdata, vilket markeras med nior. Antalet nior beror på precisionen för just den kolumnen. GSOD använder `9999.9` för TEMP/MAX/MIN och lufttryck, `999.9` för vind, sikt och snödjup, och `99.99` för nederbörd.

I grova drag ska programmet utföra följande uppgifter:

1.  Läs in konfigurationsdata
2.  Läs in kommandoradsargument, vilka kan ersätta somlig konfigurationsdata
3.  Koppla upp en HTTPS-förbindelse och ladda ned en `*.tar.gz` fil för ett visst år
4.  Dekomprimera GZIP-filen
5.  Packa upp TAR-filen till separata CSV-filer
6.  Läs och tolka respektive CSV-fil med väderdata
7.  Aggregera väderdatat för enklare statistisk behandling
8.  Sammanställ den aggregerade data som ska presenteras
9.  Skriv ut en textrapport med sammanställningen
10.  Skriv ut förfluten exekveringstid

* * *

Version 1 — CMake, bibliotek, konfiguration
-------------------------------------------

I denna första version, fokuserar jag på att få till katalogstruktur, 3:de parts-biblioteken Boost och Catch2, inläsning av konfigurationsdata. I nästa version börjar vi med filhantering.

### CMake

Så här ser CMake-filen i projektkatalogen ut:

```cmake
cmake_minimum_required(VERSION 3.28)
project(boost_weather_data_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 26)
set(CMAKE_CXX_STANDARD_REQUIRED yes)
set(CMAKE_CXX_EXTENSIONS no)
add_compile_options(-Wall -Wextra -Wpedantic)

find_package(Boost 1.92 CONFIG REQUIRED COMPONENTS
        program_options
        property_tree
)
find_package(OpenSSL REQUIRED)
find_package(Catch2 3 CONFIG REQUIRED)
include(Catch)
enable_testing()

add_subdirectory(version-1)
```

Den innehåller språkstandard och generella varningsflaggor för kompilatorn, följt av moduler för respektive bibliotek, samt slutligen inkludering av CMake-fil för version 1.

Jag använder här moduler som länkar till redan installerade bibliotek. Se vidare min första artikel om Boost för olika alternativa vägar att inkorporera biblioteket. Boost består ju av många mindre bibliotek, vilka representeras i CMake av `COMPONENTS`. I denna version, är det bara två vi behöver. Men det blir fler i följande versioner.

Som synes, behövs också `OpenSSL`, vilket måste installeras i förväg. Boost delegerar all TLS och cert-hantering till detta bibliotek, vilket är bra ur säkerhetssynpunkt.

Inställningar för CMake görs via en preset-fil (`CMakePresets.json`), som läses in av både CMake men också min IDE _JetBrains CLion_.

```json
{
  "version": 6,
  "cmakeMinimumRequired": { "major": 3, "minor": 28, "patch": 0 },
  "configurePresets": [
    {
      "name": "base",
      "hidden": true,
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "environment": {
        "LIBS": "$penv{HOME}/Libs"
      },
      "cacheVariables": {
        "CMAKE_CXX_COMPILER": "/opt/gcc-16.1/bin/g++",
        "Boost_ROOT":  "$env{LIBS}/Boost/latest",
        "Catch2_ROOT": "$env{LIBS}/Catch2/latest"
      }
    },
    {
      "name": "debug",
      "displayName": "Debug",
      "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
    },
    {...}
  ],
  "buildPresets": [
    { "name": "debug",   "configurePreset": "debug" },
    { "name": "release", "configurePreset": "release" }
  ],
  "testPresets": [...]
}
```

Den andra CMake-filen är till att börja med tämligen kort

```cmake
add_executable(weather-data-v1
    app.cxx
)
```

Själva huvudprogrammet (`app.cxx`) är inledningsvis befriande kort.

```cpp
#include <print>
int main(int argc, char** argv) {
    std::println("Weather Data Boost Demo App, version 1");
}
```

### Konfigurationsfil

Detta ska vi nu bygga på med hantering av konfigurationsdata från en fil och hantering av kommandorads-argument. Vi börjar med följande INI-fil, som innehåller samtliga konfigurationsvariabler.

```text
[source]
; The archive-file url for a year is /.tar.gz
url = https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive

[query]
; Year to download, 1929-2025
year = 1950
; Number of stations in the hottest and coldest lists
top = 5

[output]
; Temperature unit in the report: C or F or K
unit = C

[download]
; Set to true to enable caching of downloaded files,
; and to check the cache first before downloading a file
cache = true
; Directory for locally cached archives
cache_dir = ./cache
```

Boost har ett bibliotek kallat `property_tree`, som kan hantera konfigurationsfiler i formaten INI, JSON och XML. Property\_tree är i grunden ett generiskt träd där varje nod har ett värde och en ordnad lista av namngivna noder. Det är ett träd av "properties", som i Javas `java.util.Properties`, fast hierarkiskt. Konfiguration är bara det vanligaste användningsfallet.

Biblioteket skrevs av Marcin Kalicinski och kom in i Boost 1.41 (2009). Tanken var en gemensam datamodell för flera textformat:

*   Läsarna och skrivarna för INI, JSON, XML och INFO (_ett eget Boost-format_) är tillägg ovanpå trädet. Alla läser in till samma `ptree`, och man kommer åt data med sökvägar som `tree.get<int>("query.year")`, oavsett vilket filformat som användes.
*   Modellen är därför den minsta gemensamma nämnaren för formaten. Värden lagras som strängar och konverteras när de läses. Från JSON försvinner typinformationen, och arrayer blir barn med tomma nycklar.

Så här ser filerna `config.hxx` och `config.cxx` ut:

```cpp
#pragma once
#include <string>
#include <filesystem>

namespace ribomation::weatherdata {
    namespace fs = std::filesystem;

    enum class TemperatureUnit { Celsius, Fahrenheit, Kelvin };

    struct Config {
        std::string     url = "https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive";
        unsigned        year = 1950;
        unsigned        top = 5;
        TemperatureUnit unit = TemperatureUnit::Celsius;
        bool            cache = true;
        fs::path        cache_dir = "./cache";

        static auto load(fs::path const& cfg_file) -> Config;
        static auto parse_unit(std::string const& value) -> TemperatureUnit;
    };

    auto operator<<(std::ostream& os, Config const& c) -> std::ostream&;
}
```

```cpp
#include "config.hxx"
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/ini_parser.hpp>

namespace ribomation::weatherdata {
    namespace pt = boost::property_tree;
    using namespace std::string_literals;
    using std::string;

    auto Config::parse_unit(string const& value) -> TemperatureUnit {
        if (value == "C") return TemperatureUnit::Celsius;
        ...
    }
    auto to_string(TemperatureUnit u) -> string {
        if (u == TemperatureUnit::Celsius) return "Celsius";
        ...
    }

    auto Config::load(fs::path const& cfg_file) -> Config {
        if (not fs::exists(cfg_file)) { ... }
        auto cfg = pt::ptree{};
        pt::read_ini(cfg_file, cfg);
        auto c      = Config{};
        c.url       = cfg.get<string>("source.url", c.url);
        c.year      = cfg.get<unsigned>("query.year", c.year);
        c.top       = cfg.get<unsigned>("query.top", c.top);
        c.unit      = parse_unit(cfg.get<string>("output.unit", "C"));
        c.cache     = cfg.get<bool>("download.cache", c.cache);
        c.cache_dir = cfg.get<string>("download.cache_dir", c.cache_dir);
        return c;
    }

    auto operator<<(std::ostream& os, Config const& c) -> std::ostream& {
        os << "-- Config --\n"
                << " url=" << c.url << "\n"
                << ...;
        return os;
    }
}
```

I `main` läser vi in konfigurationsfilen och skriver ut innehållet.

```cpp
auto cfg = Config::load("./weather-data.ini");
std::cout << cfg;
```

```shell
Weather Data Boost Demo App, version 1
-- Config --
 url=https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive
 year=1950
 top=5
 unit=Celsius
 cache=true
 dir=./cache
```

### Hantering av kommandoraden (CLI)

För att hantera argument (_options_) på kommandoraden använder jag _Boost Program-Options_. Det är ett kolossalt flexibelt och populärt bibliotek för att hantera dylikt. Det skrevs av Vladimir Prus och kom med i Boost 1.32 i november 2004.

På kommandoraden ska man kunna ange årtal (`year`) och antal (`top`) väderstationer för att i sammanställningen visa kallaste och varmaste under året. Dessutom, ska man kunna ange om programmet bara ska ladda ned (`download`) och spara en väderdatafil, för senare bruk, samt ange en alternativ konfigurationsfil (`config`). Utöver dessa, också få en listning av alla options (`help`) plus utskrift av programversionen (`version`).

Så här ser options.hxx header-filen ut.

```cpp
#pragma once
#include <string>
#include <optional>
namespace ribomation::weatherdata {
    using std::string;
    using std::optional;
    struct Options {
        bool     download_only = false;
        string   config_file   = "weather-data.ini";
        unsigned year          = 1950;
        unsigned top           = 5;
        static auto parse(int argc, char** argv) -> optional<Options>;
    };
    auto operator<<(std::ostream& os, Options const& c) -> std::ostream&;
}
```

Implementationsfilen `options.cxx` utför grovjobbet och parse-funktionen ser ut så här.

```cpp
#include <boost/program_options.hpp>
namespace po = boost::program_options;

auto Options::parse(int argc, char** argv) -> optional<Options> {
    auto opts = Options{};
    auto cli = po::options_description{APP_NAME};
    cli.add_options()
        ("help,h", "show this help and quit")
        ("version,V", "show version and quit")
        ("year,y", po::value<unsigned>(&opts.year)->required(),
          "the year to process")
        ("top,t", po::value<unsigned>(&opts.top)->default_value(opts.top),
          "number of stations to show coldest/hottest")
        ("download,d", po::bool_switch(&opts.download_only),
          "just download and save/cache the file")
        ("config,c", po::value<string>(&opts.config_file)->default_value(opts.config_file),
          "alt. config file")
        ;

    auto parsed = po::parse_command_line(argc, argv, cli);
    auto values = po::variables_map{};
    po::store(parsed, values);

    if (values.contains("help")) {
        std::cout << cli;
        return std::nullopt;
    }
    if (values.contains("version")) {
        std::println("{}, version {}, step {}", APP_NAME, APP_VERSION, APP_STEP);
        return std::nullopt;
    }
    po::notify(values);

    return opts;
}
```

Andra raden i funktionen `po::options_description{...}` skapar ett objekt som sen populeras med alla tillgängliga _CLI options_. Operatorn `operator()` utgör ett intressant sätt att realisera kaskadanrop på. Vanligtvis returnerar man referensen till sitt objekt med `*this` och sen via '.' anropar nästa funktion i kedjan. Här är det i stället funktionsanrops-operatorn på ett hjälpobjekt som returnerar `*this`, så att nästa `(...)` kan följa direkt.

Rent tekniskt så returnerar `cli.add_options()` ett objekt av typen `options_description_easy_init` som innehåller pekaren till cli-objektet (`options_description* owner;`). Denna klass innehåller flera överlagrade `operator()`, vilka vi använder här ovan. Så här ser den enklare av dem ut, så du har en idé om hur det funkar.

```cpp
auto operator()(const char* name, const char* description) -> options_description_easy_init& {
  owner->add_option(name, description);
  return *this;
}
```

De två första options är fristående, med enbart namn på långt (`--help`) respektive kort variant (`-h`), samt en kort beskrivning som visas då man begär hjälptexten.

Den tredje är årtalet (`year`) och är också obligatorisk (`required()`). Uttrycket `po::value<unsigned>(&opts.year)` skapar ett `typed_value<unsigned>*`. Pekaren anger var värdet ska hamna när `po::notify(values)` anropas. Denna tar argument. Med den långa varianten kan man skriva `--year 1950` eller `--year=1950`. Och med den korta `-y 1950` eller `-y1950`.

Den fjärde är antalet stationer (`top`) och har ett skönsvärde `default_value(opts.top)`. Sen fortsätter det på liknande sätt, tills det är dags att processa `argv`.

De tre raderna som nu följer tolkar först innehållet i `argv`, skapar ett tabell-objekt (mycket likt `std::map`) och populerar den senare med data från den förstnämnda.

Sedan vidtar kontroll huruvida hjälp eller programversion ska skrivas ut. När funktionen returnerar en tom `std::optional` betyder det att huvudprogrammet ska avslutas.

```cpp
auto opts = Options::parse(argc, argv);
if (not opts) return 0;
```

Det sista som sker är att Options-objektet populeras med data från `values`, samt att kontroll av obligatoriska värden utförs. Om en dylik saknas kastas ett undantag.

### Applikationskonstanter

Koden ovan använder sig av tre CPP konstanter `APP_NAME, APP_VERSION, APP_STEP`, vilka är definierade i CMake-filen.

```cmake
project(boost_weather_data_demo
        LANGUAGES CXX
        VERSION 1.0
        DESCRIPTION "Boost Weather-Data Demo"
)
#...
add_compile_definitions(APP_NAME="${PROJECT_DESCRIPTION}")
add_compile_definitions(APP_VERSION="${PROJECT_VERSION}")
```

I den versions-specifika CMake-filen finns också APP\_STEP, plus vilka Boost komponenter som används

```cmake
add_executable(weather-data-v1
        app.cxx
        config.hxx config.cxx
        options.hxx options.cxx
)
target_link_libraries(weather-data-v1 PRIVATE
        Boost::headers
        Boost::property_tree
        Boost::program_options
)
target_compile_definitions(weather-data-v1 PRIVATE APP_STEP=1)
```

Nu har jag byggt om huvudprogrammet, så att det ser ut på följande vis

```cpp
#include <iostream>
#include "config.hxx"
#include "options.hxx"
using ribomation::weatherdata::Config;
using ribomation::weatherdata::Options;

int main(int argc, char** argv) {
    try {
        auto opts = Options::parse(argc, argv);
        if (not opts) return 0;
        std::cout << *opts;

        auto cfg = Config::load(opts->config_file);
        std::cout << cfg;
    } catch (std::exception const& x) {
        std::cout << "Program error: " << x.what() << "\n";
        std::cout << "Use '--help' for help text\n";
        return 1;
    }
}
```

Kör man programmet kan det se ut så här

```shell
$ ./build/debug/version-1/weather-data-v1
Program error: the option '--year' is required but missing
Use '--help' for help text

$ ./build/debug/version-1/weather-data-v1 --help
Boost Weather-Data Demo:
  -h [ --help ]                         show this help and quit
  -V [ --version ]                      show version and quit
  -y [ --year ] arg                     the year to process
  -t [ --top ] arg (=5)                 number of stations to show
                                        coldest/hottest
  -d [ --download ]                     just download and save/cache the file
  -c [ --config ] arg (=weather-data.ini)
                                        alt. config file

$ ./build/debug/version-1/weather-data-v1 --version
Boost Weather-Data Demo, version 1.0, step 1

$ ./build/debug/version-1/weather-data-v1 --year 1960
-- Options --
 year=1960
 top=5
 config=weather-data.ini
 download=false
-- Config --
 url=https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive
 year=1950
 top=5
 unit=Celsius
 cache=true
 dir=./cache
```

* * *

Version 2 — Anrop med HTTPS
---------------------------

Nu är det dags att hämta själva väderdatat. I denna version kopplar programmet upp sig mot NCEI över HTTPS och laddar ned arkivfilen för ett visst år. Antingen sparas filen på disk (`--download`), eller så strömmar den bara igenom programmet, utan att vi kikar inuti. Det sistnämnda kan verka litet meningslöst, men det är precis det som nästa version bygger vidare på. Då skickar vi bytes vidare till dekomprimeringen, i stället för att slänga dem.

### CMake

Det tillkommer tre Boost-komponenter: `beast` för HTTP, `url` för att hantera webbadresser och `iostreams` för strömmarna. Beast bygger i sin tur på Asio, som följer med automatiskt.

```cmake
find_package(Boost 1.92 CONFIG REQUIRED COMPONENTS
        property_tree
        program_options
        beast
        url
        iostreams
)
```

Jag har också passat på att samla all kod utom `main` i ett statiskt bibliotek, så att enhetstesterna kan länka mot exakt samma kod som programmet.

```cmake
target_link_libraries(weather-data-core-${STEP} PUBLIC
        Boost::headers
        Boost::property_tree
        Boost::program_options
        Boost::beast
        Boost::url
        Boost::iostreams
        OpenSSL::SSL
        OpenSSL::Crypto
)
```

Som synes, kommer nu `OpenSSL` till användning. Asio har ingen egen TLS-implementation, utan är ett tunt C++-lager ovanpå OpenSSL. Eftersom Asio är header-only, är det vårt program som måste länka mot OpenSSL.

### Webbadressen med Boost.URL

Arkivfilen för ett år finns på `<url>/<year>.tar.gz`, där basadressen kommer från konfigurationsfilen. Visst, det hade gått att klistra ihop strängar, men Boost har sedan version 1.81 ett bibliotek för just webbadresser, enligt RFC 3986. Så här ser funktionen ut:

```cpp
auto http_source::archive_url(string const& base_url, unsigned year) -> urls::url {
    auto u = urls::url{urls::parse_uri(base_url).value()}; // throws if malformed

    auto s = u.segments(); //sequence of path segments
    if (not s.empty() and s.back().empty()) {
        s.pop_back(); //drop last, if empty
    }
    s.push_back(std::format("{}.tar.gz", year));

    return u;
}
```

Funktionen `parse_uri()` tolkar adressen och returnerar ett resultatobjekt, där `.value()` kastar ett undantag om adressen är felaktig. Sökvägen hanteras som en sekvens av segment, så vi behöver inte bry oss om snedstreck. Om basadressen slutar med `/`, blir sista segmentet tomt och plockas bort innan filnamnet läggs till. Dessutom sköter biblioteket procentkodning av tecken som inte får förekomma i en adress.

### Klassen http\_source

Nu kommer vi till hjärtat i denna version. Jag hade inledningsvis tänkt gå igenom klassen rad för rad, men det visade sig att det mesta handlar om Asio — uppkoppling, TLS-handskakning, timeouts och dylikt. Asio kommer att få en egen artikel längre fram, så här nöjer jag mig med att beskriva klassen som en svart låda, med ett tydligt kontrakt. Den kompletta koden finner du på GitHub.

Så här ser header-filen ut:

```cpp
class http_source : public io::source {
    struct Connection; //forward decl
    shared_ptr<Connection> conn;

public:
    explicit http_source(urls::url_view url);

    auto read(char* buffer, std::streamsize n) -> std::streamsize;
    auto content_length() const -> std::optional<std::uint64_t>;
    // ...
};
```

Klassen ärver från `io::source` i Boost.Iostreams, vilket gör den till en så kallad _Source_. Det är egentligen bara en klass med en funktion `read(buffer, n)`, som fyller bufferten och returnerar antalet lästa bytes, eller -1 när det inte finns något mer att läsa. Det är allt som `io::copy` behöver känna till, och i nästa version även gzip-dekomprimeringen. Var bytes kommer ifrån bryr de sig inte om.

Alla detaljer kring Asio och Beast ligger gömda i den inre strukturen `Connection`, som bara är deklarerad i header-filen och definierad i implementationsfilen. Det har två fördelar: de tunga header-filerna för Asio och Beast behöver bara kompileras i en enda fil, och användaren av klassen slipper se dem.

Själva läsningen sker i ett antal lager, där vår klass utgör limmet mellan Iostreams och Beast:

  ![HTTP source layers](/blog/2026/vaderdata-med-boost/http-source-layers-1392w.png "HTTP source layers")

När man skapar ett `http_source`\-objekt utför konstruktorn följande steg:

1.  Slår upp värdnamnet i DNS och kopplar upp en TCP-förbindelse mot port 443
2.  Utför TLS-handskakningen, där serverns certifikat verifieras mot systemets certifikatutfärdare och värdnamnet skickas med (SNI)
3.  Skickar en HTTP-förfrågan, `GET /…/1950.tar.gz`
4.  Läser enbart huvudet (_header_) i svaret, och kastar ett undantag om statuskoden inte är 200

Själva innehållet (_body_) lämnas oläst, tills någon anropar `read()`. Då ber vi Beast att läsa nästa portion, direkt in i den buffert vi fått av Iostreams. Ingenting kopieras i onödan, och arkivfilen lagras aldrig i sin helhet i minnet.

Det mesta i klassen är rättframt, när man väl vet vad man ska göra. Men det finns tre saker som överraskade mig, och som är värda att känna till.

**Beast har en storleksgräns.** Som standard vägrar Beast ta emot en body större än 8 MB. Arkivfilerna för senare år är på över 100 MB, så gränsen måste tas bort.

```cpp
parser.body_limit(boost::none); // the default limit is 8 MB, the archive files are larger
```

**Felet need\_buffer är inget fel.** När Beast har fyllt vår buffert, returnerar läsningen felkoden `need_buffer`. Det är så Beast säger till att bufferten är full, och att den gärna vill ha en ny.

```cpp
if (ec and ec != http::error::need_buffer) {
    throw std::runtime_error{c.url + ": " + ec.message()};
}
```

**Iostreams kopierar sina devices.** När man skickar ett `http_source`\-objekt till `io::copy` eller lägger det på en `filtering_stream`, så kopieras det. En nätverksförbindelse går dock inte att kopiera, så därför ligger `Connection` bakom en `shared_ptr` och delas mellan kopiorna.

### Nedladdning och huvudprogram

Funktionen `download()` kopierar från en `http_source` till en `io::file_sink`, som är Iostreams motsvarighet för att skriva till fil. Den skriver först till en temporär fil med ändelsen `.part`, och döper om den när allt har gått bra. På så vis blir det aldrig en halvfärdig fil kvar i cachen om nedladdningen avbryts.

```cpp
auto partial = fs::path{target} += ".part";
try {
    auto sink = io::file_sink{partial.string(), std::ios::binary};
    if (not sink.is_open()) {
        throw std::runtime_error{"cannot write " + partial.string()};
    }
    io::copy(http_source{url}, sink); //slurp
} catch (...) {
    fs::remove(partial);
    throw;
}
fs::rename(partial, target);
```

Notera kontrollen med `is_open()`. Till skillnad från mycket annat i Boost kastar `file_sink` inget undantag om filen inte går att öppna.

I huvudprogrammet väljer vi mellan att spara filen eller bara strömma den. I det senare fallet skickas datat till en `io::null_sink`, som helt enkelt slänger allt den får. Funktionen `io::copy` returnerar antalet kopierade bytes, så vi får ändå veta hur mycket som passerade.

```cpp
int main(int argc, char** argv) {
    try {
        auto elapsed = Elapsed{};
        auto opts    = Options::parse(argc, argv);
        if (not opts) return 0;
        auto cfg     = Config::load(opts->config_file);

        constexpr auto MB = 1024 * 1024.0;
        auto url  = http_source::archive_url(cfg.url, opts->year);
        auto file = http_source::archive_file(cfg.cache_dir, opts->year);

        std::cout << "fetching " << url << " ...\n";
        if (opts->download_only) {
            auto num_bytes = http_source::download(url, file);
            auto mb = static_cast<double>(num_bytes) / MB;
            std::cout << "saved " << file.string() << " (" << mb << " MB)\n";
        } else {
            auto num_bytes = io::copy(http_source{url}, io::null_sink{});
            auto mb = static_cast<double>(num_bytes) / MB;
            std::cout << "streamed " << mb << " MB\n";
        }
        std::cout << "elapsed " << elapsed.time() << " ms\n";
    } catch (std::exception const& x) {
        std::cout << "Program error: " << x.what() << "\n";
        std::cout << "Use '--help' for help text\n";
        return 1;
    }
}
```

Kör man programmet kan det se ut så här:

```shell
$ ./build/debug/version-2/weather-data-v2 -y 1940
fetching https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/1940.tar.gz ...
streamed 2.10314 MB
elapsed 1933 ms

$ ./build/debug/version-2/weather-data-v2 -y 1945 -d
fetching https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/1945.tar.gz ...
saved ./cache/1945.tar.gz (6.00022 MB)
elapsed 1854 ms

$ ./build/debug/version-2/weather-data-v2 -y 1905
fetching https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/1905.tar.gz ...
Program error: HTTP 404 Not Found for https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/1905.tar.gz
Use '--help' for help text
```

Det sista exemplet visar att felhanteringen fungerar: ett år som inte finns ger statuskoden 404, och konstruktorn kastar ett undantag med ett begripligt felmeddelande.

* * *

Version 3 — GZIP/TAR/CSV läsning
--------------------------------

I denna version av programmet ska vi packa upp innehållet i en hämtad fil. Kom ihåg att filen är komprimerad med GZIP (_GNU ZIP_), så vi måste dekomprimera först.

Resultatet är sen en TAR-fil. TAR står för _Tape ARchive_, och är begrepp och filformat som har hängt med sedan UNIX barndom, där man på den tiden gjorde backup till så kallade bandarkiv. Dvs en fysisk bandrulle som sedan lagrades i dedikerade rum. Så en TAR är en fil som innehåller andra filer; sekventiellt ordnade.

Varje sådan fil är i formatet CSV, vilket står för _Character Separated Values_. Separator-tecknet är oftast komma (','), vilket gör att folk oftast kallar det Comma Separated Values. Så här kan början av en dylik fil se ut:

```text
"STATION","DATE","LATITUDE","LONGITUDE","ELEVATION","NAME","TEMP","TEMP_ATTRIBUTES","DEWP","DEWP_ATTRIBUTES","SLP","SLP_ATTRIBUTES","STP","STP_ATTRIBUTES","VISIB","VISIB_ATTRIBUTES","WDSP","WDSP_ATTRIBUTES","MXSPD","GUST","MAX","MAX_ATTRIBUTES","MIN","MIN_ATTRIBUTES","PRCP","PRCP_ATTRIBUTES","SNDP","FRSHTT"
"01488099999","1960-01-01","60.1166666","10.8333333","170.0","HAKADAL, NO","  42.2","24","9999.9"," 0"," 998.9","24","999.9"," 0","  3.7","24","  7.5","24"," 18.1","999.9","  44.1"," ","  39.9","*"," 0.37","F","999.9","010000"
"01488099999","1960-01-02","60.1166666","10.8333333","170.0","HAKADAL, NO","  40.5","24","9999.9"," 0","1013.1","24","999.9"," 0"," 24.9","24","  5.1","24","  8.0","999.9","  43.0"," ","  39.0"," "," 0.01","E","999.9","000000"
"01488099999","1960-01-03","60.1166666","10.8333333","170.0","HAKADAL, NO","  36.8","24","9999.9"," 0","1023.3","24","999.9"," 0"," 19.4","24","  3.3","24","  7.0","999.9","  39.9"," ","  35.1"," "," 0.02","E","999.9","001000"
"01488099999","1960-01-04","60.1166666","10.8333333","170.0","HAKADAL, NO","  38.1","24","9999.9"," 0","1024.8","24","999.9"," 0","  1.6","24","  5.7","24"," 13.0","999.9","  42.1"," ","  34.0"," "," 0.14","F","999.9","010000"
. . .
```

### istream pipeline

Första halvan i denna del utgörs av en byte-pipeline. Det finns en datakälla, som är TCP-dataströmmen eller en cachad fil, följt av ett GZIP-dekomprimeringsfilter. Detta exponeras som en `istream`, via `boost::iostreams::filtering_istream`.

Denna indata-ström kopplas sen till en läsare som hanterar en byte-ström i TAR-format. Dessvärre saknar Boost en komponent som förstår sig på TAR, så det blev till att skriva en egen, kallad `TarReader`. Formatet är relativt enkelt; en sekvens av 512-byte block, där varje file-entry består av ett header-block följt av ett antal content-block. Vid behov paddas sista blocket så att det blir jämt 512 bytes. Ett arkiv avslutas med två block fyllda med nollor. Detaljerna och tillhörande enhetstester finns i källkoden och på GitHub. Kolla länksamlingen sist i denna artikel.

Så här ser det ut rent översiktligt

  ![Översiktsbild av istream pipeline](/blog/2026/vaderdata-med-boost/input-pipeline-1260w.png "Översiktsbild av istream pipeline")

### CSV indata-tolkning och skapandet av Observation-objekt

Andra halvan utgörs av att tolka CSV-data och konvertera varje dylik rad till ett Observation objekt. Ett sådant är kopplat till ett Station objekt. Varje CSV-fil innehåller dagliga sammanställningar av väderdata för en viss väderstation.

```cpp
struct Station {
    string  id{};
    string  name{};
    double  latitude{};
    double  longitude{};
    double  elevation{};
    auto to_string() const -> string;
    inline static unordered_map<string, Station> repo{};
};

struct Observation {
    constexpr static double missing_temp = 9999.9; // TEMP, MAX, MIN
    constexpr static double missing_prcp = 99.99; // PRCP

    string            station_id{};
    Station*          station = nullptr;
    year_month_day    date{};
    optional<double>  temp_avg{};
    optional<double>  temp_min{};
    optional<double>  temp_max{};
    optional<double>  precipitation{};
    TemperatureUnit   unit = TemperatureUnit::Celsius;
    bool              prcp_mm = true;
    auto to_string() const -> string;
};
```

Den nya programkoden i `app.cxx` och `main()` ser ut så här

```cpp
auto url = http_source::archive_url(cfg.url, opts->year);
auto file = http_source::archive_file(cfg.cache_dir, opts->year);
// . . .
if (opts->download_only) {
    if (fs::exists(file)) {
        std::cout << "already cached " << file.string() << "\n";
    } else {
        download();
    }
} else if (cfg.cache) {
    if (fs::exists(file)) {
        std::cout << "using cached " << file.string() << "\n";
    } else {
        download();
    }
    pipeline(io::file_source{file.string(), std::ios::binary}, cfg, print);
} else {
    std::cout << "streaming " << url << " ...\n";
    pipeline(http_source{url}, cfg, print);
}
std::cout << "elapsed " << elapsed.to_string() << "\n";
```

Där jag använder två lambda, eftersom de förekommer på mer än ett ställe i koden.

```cpp
auto download = [&] {
    std::cout << "fetching " << url << " ...\n";
    auto nbytes = http_source::download(url, file);
    auto mb     = static_cast<double>(nbytes) / MB;
    std::cout << std::format("saved {} ({:.2f} MB)\n", file.string(), mb);
};

auto print = [](Observation& obs) {
    std::cout << obs << "\n";
};
```

I allt väsentligt har vi en TLS-ström som hanterar HTTPS-delen i ena änden och en `print` lambda i andra änden där det trillar ut `Observation` objekt, en för varje tolkad CSV rad i indata.

Den intressantaste delen här är funktionen `pipeline`, som skapar en kedja av steg enligt vad jag beskrev här ovan. Kika noga så ser du att `pipeline` tar antingen en HTTP-ström eller en fil-ström.

```cpp
pipeline(http_source{url}, cfg, print);
//...
pipeline(io::file_source{file.string(), std::ios::binary}, cfg, print);
```

Den förstnämnda är min egen klass, medan den sistnämnda finns i Boost (`boost::iostreams::file_source`) och var inspirationen till den förstnämnda. Funktionen `pipeline` behöver alltså känna igen och acceptera olika klasser, som ändå uppfyller samma kriterier om att tillhandahålla en byte-ström. Detta gör vi med ett C++20 `concept`, som ser ut så här

```cpp
template<typename S>
using category_t = typename io::category_of<S>::type;

template<typename S>
concept StreamSource = requires(S src, char* buffer, std::streamsize n)
{
    requires std::copy_constructible<S>;
    requires std::same_as<typename io::char_type_of<S>::type, char>;
    requires std::derived_from<category_t<S>, io::input>;
    requires std::derived_from<category_t<S>, io::device_tag>;
    { src.read(buffer, n) } -> std::same_as<std::streamsize>;
};
static_assert(StreamSource<http_source>);
static_assert(StreamSource<io::file_source>);
```

En Source i `Boost.Iostreams` är ingen subklass i objektorienterad mening. Visserligen ärver `http_source` från `io::source`, men det är en tom bekvämlighetsklass som bara definierar `char_type` och `category`. Den har inga virtuella funktioner, och Boost anropar aldrig `read()` via den. I stället är en Source en _överenskommelse_ om vilka typer och funktioner som ska finnas, precis som iteratorerna i STL. Concept:et ovan gör den överenskommelsen synlig i koden. Rad för rad betyder det:

*   `copy_constructible`: push() lägger en kopia av src i kedjan, så den måste gå att kopiera. Det är också anledningen till att `http_source` har sin anslutning (`conn`) i en `shared_ptr`.
*   `char_type` är char: vi läser bytes och inte t.ex. wchar\_t (_wide char_).
*   Kategorin är `input`: _dvs_ det går att läsa från källan. En file\_sink som bara kan skrivas till faller bort här.
*   Kategorin är `device_tag`: det är en ändpunkt som producerar data, inte ett filter som behöver något att läsa ifrån. En `gzip_decompressor` är också input, men har dessutom `filter_tag` och faller därför bort. Det är rätt, eftersom ett filter inte kan stå sist i en pipeline-kedja.
*   `read(buffer, n)`: är den som utför grovjobbet. Funktionen fyller bufferten med upp till n tecken och returnerar antalet som lästes, eller -1 (_EOF = End Of File_) när det inte finns mer data.

Kategorin är en så kallad _tag_, en tom struct som bara bär information i själva typen. Taggarna ärver av varandra, och io::file\_source har t.ex. kategorin `input_seekable`, som i sin tur ärver från `input`. Därför använder jag `std::derived_from` och inte `std::same_as`. Hjälpfunktionerna `category_of` och `char_type_of` är samma traits som Boost själv använder inne i `push()`, så concept:et följer bibliotekets egen definition.

De två `static_assert`\-raderna kontrollerar redan vid kompileringen att båda våra källor uppfyller concept:et. Vinsten med ett concept märks när något är fel. Utan det får man ett långt felmeddelande från djupt inne i Boosts templates, medan man med ett concept får ett mer precist felmeddelande om vad som är fel.

> En fallgrop med requires-block: varje rad måste vara uppfylld, så raderna bildar tillsammans en implicit konjunktion (_logical AND_). Men en rad som bara består av ett uttryck, t.ex. `std::copy_constructible<S>;`, kontrollerar endast att uttrycket kompilerar, inte att det är sant. Den är alltså alltid uppfylld. Det är ordet `requires` framför som gör att värdet utvärderas.

Funktionen `pipeline` är givetvis en template och begränsar indata-objekt till att uppfylla StreamSource konceptet. Så här ser denna ut

```cpp
template<StreamSource Source>
void pipeline(Source src, Config const& cfg, std::function<void(Observation&)> handle) {
    constexpr auto BUF_SIZE = static_cast<std::streamsize>(64 * 1024);

    auto pipe = io::filtering_istream{};
    pipe.push(io::gzip_decompressor{});
    pipe.push(std::move(src), BUF_SIZE);
    pipe.exceptions(std::ios::badbit);

    auto tar = TarReader{pipe};
    while (auto entry = tar.next()) {
        if (not entry->is_file()) continue;

        auto csv    = CSVReader{tar.content_stream()};
        auto mapper = ObservationMapper{cfg.unit, cfg.precipitation_mm};
        while (csv.next(mapper)) { handle(mapper.obs); }
    }
}
```

Funktionen har tre parametrar. Den första är datakällan (`src`), som tack vare concept:et `StreamSource` kan vara antingen en `http_source` eller en `io::file_source`. Funktionen behöver alltså inte veta om data kommer från nätet eller från cachen. Den andra är konfigurationen (`cfg`), där vi hämtar temperaturenhet och om nederbörden ska anges i millimeter. Den tredje är en funktion (`handle`) som anropas för varje observation. Det är anroparen som bestämmer vad som ska hända med datat, om det ska skrivas ut, räknas eller samlas ihop till statistik. Funktionen `pipeline()` gör bara en sak: den levererar observations-objekt.

#### Kedjan

De fyra första raderna bygger `Boost.Iostreams-kedjan`. En `filtering_istream` är en vanlig `std::istream`, men i stället för att läsa från en fil läser den genom en kedja av länkar som vi lägger till med `push()`. Ordningen är viktig: den första länken hamnar närmast den som läser och den sista längst bort. Den sista måste vara en _device_, alltså själva källan, och när den väl är på plats är kedjan komplett.

Här lägger vi först till `gzip_decompressor` och sedan källan. När någon läser från `pipe` ber strömmen gzip-filtret om data, filtret ber i sin tur källan om komprimerade bytes, packar upp dem och lämnar dem vidare. Data flödar från källan och utåt, medan förfrågningarna går åt andra hållet. Ingenting händer förrän någon längst ut ber om mer. Detta är en så kallad _pull-pipeline_, vilken fungerar på liknande sätt som i Java, där man kan koppla ihop flera objekt som är subklasser till `java.io.InputStream`.

Det andra argumentet till push() är buffertstorleken mellan källan och gzip-filtret. Med 64 KiB hämtas den komprimerade datan i stora block, vilket ger få anrop till nätverket eller disken. Notera också `std::move()`, eftersom push() tar en kopia av källan.

Raden med `exceptions()` är lätt att glömma men viktig. Om gzip-filtret stöter på en trasig fil kastar det ett `io::gzip_error`, men `std::istream` fångar normalt alla undantag från sin buffert och sätter bara `badbit`. Det ser då ut som ett vanligt filslut, och felet visar sig i stället som ett obegripligt fel i tar-läsningen. Med badbit påslaget kastas det ursprungliga undantaget vidare till anroparen.

#### Arkivet och filerna

Därefter tar min egen kod över. `TarReader` läser från kedjan som från vilken `std::istream` som helst och vet inget om gzip eller nätverk. Varje anrop till `next()` läser nästa header-block i arkivet och returnerar en `std::optional<TarEntry>`, som blir tom när arkivet tar slut. Det är därför vi kan skriva `while (auto entry = tar.next())`. Poster som inte är vanliga filer, t.ex. filkataloger, hoppar vi över.

Varje fil i arkivet är en CSV-fil med data för en väderstation. Metoden `content_stream()` ger en `std::istream` som tar slut exakt där filen tar slut, så `CSVReader` tror att den läser en helt vanlig fil. Konstruktorn läser header-raden, så att fälten sedan kan slås upp på namn. Därför skapar vi en ny `CSVReader` för varje fil.

#### Från rad till observation

Den innersta slingan läser en rad i taget. `CSVReader::next()` delar upp raden i fält och lämnar dem till `ObservationMapper`, som översätter dem till en `Observation`. Mappern tolkar datum och tal, byter GSOD:s markörer för saknade värden mot tomma optional, och räknar om temperaturen och nederbörden till de enheter som konfigurationen anger. Till sist anropas `handle()` med den färdiga observationen.

Hela kedjan arbetar alltså en rad i taget, och minnesbehovet är detsamma oavsett om arkivet är på 50 eller 500 MB. Hela arkivet finns aldrig i minnet och behöver inte heller sparas på disk först.

> Notera att `handle()` får en referens till mapperns eget Observation-objekt, som skrivs över (om-populeras) för nästa rad. Vill man spara objektet måste man alltså kopiera det.

#### CSVReader

Klasserna som hanterar CSV ser ut så här

```cpp
using Fields      = vector<string>;
using FieldsIndex = unordered_map<string, unsigned>;

struct CSVHeader {
    string      first{};
    FieldsIndex fields_index{};

    void     init(Fields const& header_fields);
    unsigned getIndex(string const& field_name) const;
    bool     isHeader(Fields const& fields) const { return fields[0] == first; }
};

struct FieldsMapper {
    virtual ~FieldsMapper() = default;
    virtual void operator()(CSVHeader const&, Fields const&) = 0;
};

struct CSVReader {
    istream& input;
    CSVHeader header{};

    explicit    CSVReader(istream& input_);
    bool        next(FieldsMapper& mapper) const;
    static auto parse(string const& csv_line) -> Fields;
};
```

Konstruktorn för `CSVReader` läser första raden, som är headern med alla fältnamn. Sparar och indexerar dessa, så att data-raderna kan processas genom att bara plocka ut en liten delmängd av alla fält.

```cpp
CSVReader::CSVReader(istream& input_): input{input_} {
    auto first_line = string{};
    if (not std::getline(input, first_line)) {
        throw std::runtime_error("missing header line");
    }

    alg::trim(first_line);
    auto header_fields = parse(first_line);
    if (header_fields.empty()) {
        throw std::runtime_error("missing header line");
    }

    header.init(header_fields);
}
//...
void CSVHeader::init(Fields const& header_fields) {
    first = header_fields[0];
    for (auto idx = 0U; idx < header_fields.size(); ++idx) {
        fields_index[ header_fields[idx] ] = idx;
    }
}
//...
bool CSVReader::next(FieldsMapper& mapper) const {
    for (auto csv = string{}; std::getline(input, csv); ) {
        alg::trim(csv);
        if (csv.empty()) continue;

        auto fields = parse(csv);
        if (header.isHeader(fields)) continue;

        mapper(header, fields);
        return true;
    }
    return false;
}
```

Funktionen `CSVReader::next` läser nästa giltiga data-rad och skickar denna till mappern. Så länge som det finns mer data returnerar `next` true, vilket styr den inre loopen i pipeline, som vi såg tidigare (_här återgivet_)

```cpp
while (csv.next(mapper)) { handle(mapper.obs); }
```

Funktionen `parse` sköter tolkningen av en CSV-rad, med hjälp av Boost-klasser

```cpp
auto CSVReader::parse(string const& csv_line) -> Fields {
    using Separator = boost::escaped_list_separator<char>;
    auto tokens     = boost::tokenizer<Separator>(csv_line, Separator("", ",", "\""));
    auto fields     = Fields{};
    for (auto const& token : tokens) {
        fields.push_back(alg::trim_copy(token));
    }
    return fields;
}
```

En rad i GSOD-filerna ser ut ungefär så här:

```text
"02464099999","2024-01-04","59.354","17.94","14.0","BROMMA, SW","  1.2","24",...
```

Varje fält står inom citattecken, och vissa fält, som stationsnamnet, innehåller själva ett kommatecken. Det räcker alltså inte att dela raden vid varje komma. Här kommer två delar av Boost till användning: biblioteket `Tokenizer` för att dela upp raden och `String_algo` för att putsa fälten.

`boost::tokenizer` tar en sträng och en separator, en funktionsobjektstyp som bestämmer var gränserna mellan tokens går. Resultatet är ett område (_range_) som vi kan iterera över med en vanlig for-loop.

Separatorn `escaped_list_separator` är designad just för CSV. Dess konstruktor tar tre strängar:

*   _escape-tecken_: här en tom sträng, alltså inget escape-tecken alls. Standardvärdet är bakstreck, och då skulle ett \\ i ett stationsnamn tolkas som början på en escape-sekvens.
*   _separator_: `,` (dvs ett kommatecken).
*   _citattecken_: `"` (dvs ett citattecken). Text inom citattecken är ett enda fält även om den innehåller kommatecken, och själva citattecknen tas bort.

Till sist rensar vi varje fält från inledande och avslutande blanktecken med `boost::algorithm::trim_copy()`. Talen i GSOD är högerjusterade med mellanslag, t.ex. _"   1.2"_, och `std::from_chars()`, som vi använder för att tolka talen, accepterar inte inledande mellanslag. `trim_copy()` returnerar en ny sträng och låter originalet vara, medan `trim()` ändrar strängen på plats. Den varianten använder vi på hela raden innan den delas upp.

Resultatet blir en `std::vector<std::string>` med ett fält per kolumn, utan citattecken och mellanslag.

#### ObservationMapper

`ObservationMapper` överbryggar hanteringen mellan CSV-delen —som är hyfsat generell— och `Observation` som ju är vår applikationsspecifika datatyp. Klassen ärver från `FielsdMapper`, som jag visade tidigare men återger här för att du ska slippa skrolla upp och ned.

```cpp
struct FieldsMapper {
    virtual ~FieldsMapper() = default;
    virtual void operator()(CSVHeader const&, Fields const&) = 0;
};

struct ObservationMapper : FieldsMapper {
    TemperatureUnit  unit;
    bool             prcp_mm;
    Observation      obs{};

    ObservationMapper(TemperatureUnit unit, bool prcp_mm)
        : unit(unit), prcp_mm(prcp_mm) { }

    void operator()(CSVHeader const& hdr, Fields const& fields) override;
};
```

Funktionsanropsoperatorn, sist i klassen, är den som hanterar konverteringen från textfält från CSV-tolkningen till ett Observation-objekt (`obs`). Den är ganska lång, men i princip hämtar den respektive fält med hjälp av indexeringen, som ju gjordes i samband med första raden lästes in, samt konverterar till rätt datatyp och i förekommande fall konverterar till önskad temperaturenhet.

```cpp
void ObservationMapper::operator()(CSVHeader const& hdr, Fields const& fields) {
    using conversions::parse_optional;
    using conversions::to_measurement;
    using conversions::to_date;
    using conversions::to_unit;
    using conversions::to_mm;
    using conversions::as_string;

    auto station_id = fields.at(hdr.getIndex("STATION"));
    if (not Station::repo.contains(station_id)) {
        auto name = fields.at(hdr.getIndex("NAME"));
        auto lat  = parse_optional(fields.at(hdr.getIndex("LATITUDE")));
        auto lng  = parse_optional(fields.at(hdr.getIndex("LONGITUDE")));
        auto elv  = parse_optional(fields.at(hdr.getIndex("ELEVATION")));
        auto s    = Station{.id = station_id, .name = name,
                            .latitude = lat, .longitude = lng, .elevation = elv};
        Station::repo.emplace(station_id, s);
    }
    obs.station_id = station_id;
    obs.station    = &Station::repo.at(station_id);

    obs.date          = to_date(fields.at(hdr.getIndex("DATE")));
    obs.temp_avg      = to_measurement( fields.at(hdr.getIndex("TEMP")), Observation::missing_temp);
    obs.temp_min      = to_measurement( fields.at(hdr.getIndex("MIN")) , Observation::missing_temp);
    obs.temp_max      = to_measurement( fields.at(hdr.getIndex("MAX")) , Observation::missing_temp);
    obs.precipitation = to_measurement( fields.at(hdr.getIndex("PRCP")), Observation::missing_prcp);

    obs.unit = unit;
    if (obs.temp_avg) obs.temp_avg = to_unit(*obs.temp_avg, obs.unit);
    if (obs.temp_min) obs.temp_min = to_unit(*obs.temp_min, obs.unit);
    if (obs.temp_max) obs.temp_max = to_unit(*obs.temp_max, obs.unit);

    obs.prcp_mm = prcp_mm;
    if (obs.precipitation && obs.prcp_mm) obs.precipitation = to_mm(*obs.precipitation);
}
//...
namespace conversions {
    auto to_date(const std::string& text) -> std::chrono::year_month_day;
    auto to_measurement(string const& field_value, double missing_marker) -> optional<double>;
    auto to_unit(double fahrenheit, TemperatureUnit unit) -> double;
    auto to_mm(double inches) -> double;
    auto parse_double(string const& txt) -> double;
    auto parse_optional(string const& txt) -> optional<double>;
    auto as_string(TemperatureUnit unit) -> string;
}
```

_Tjoho_, det var ju en hel del programkod. Hela programmet hittar du på GitHub, och länken finns i slutet av artikeln. Det som nu återstår är att aggregera våra insamlade observationer från olika väderstationer och presentera en sammanställning, med hjälp av ytterligare klasser från Boost. Följ med till _version 4_, som är sista varianten och också det kompletta programmet.

* * *

Version 4 — Aggregering av statistik
------------------------------------

Det vi ska göra i denna avslutande version av mitt demo-program för Boost, är att sammanställa enklare statistik för inlästa observationer och skriva ut vilka N stationer som hade de högsta respektive lägsta medeltemperaturerna det året. Dessutom ska vi sammanställa antalet lästa bytes både före och efter dekomprimeringen.

Själva statistikhanteringen överlåter vi till en dedikerad Boost komponent _Accumulators_. Byte-räknandet låter vi en egendefinierad komponent hantera, och den kommer att fungera som ett stream-filter som vi infogar före respektive efter GZIP-filtret.

Till sist använder vi en egendefinierad klass för rapporthanteringen. Nu är det ingen avancerad rapport vi pratar om här, men det är ändå bra att hålla sig till _separation of concern_ när man programmerar. Tyvärr saknar Boost någon komponent för text-rapporter. Ett alternativ, skulle kunna vara `inja`, som är en implementation i modern C++ inspirerad av Jinja och Nunjucks. Den senare använder jag flitigt för att rendera denna webbplats, vilket görs med Eleventy (11ty), Nunjucks, Node.js, CSS, HTML. Men `inja` vore overkill för just detta program. Kanske jag återkommer i annat sammanhang där jag använder detta.

### Ny konfigurationsparameter

En komplikation vid aggregeringen är att vissa stationer saknar observationer för hela året. Därför behöver vi kontrollera att en station har tillräckligt många observationer. Jag har lagt till en ny config-parameter (`min_days`) som anger hur många dagar på ett år det ska finnas observationer för som minimum.

```text
[query]
; Year to download, 1929-2025
year = 1950
; Number of stations in the hottest and coldest lists
top = 5
; Min number of days of observations to be considered for aggregation
min_days = 300
```

```cpp
struct Config {
    std::string     url = "...";
    unsigned        year = 1955;
    unsigned        top = 5;
    unsigned        min_days = 300;
    //...
};
```

### Pipeline Counters

För att räkna antalet bytes har jag skrivit ett eget filter för `Boost.Iostreams`. Det finns visserligen ett färdigt, `io::counter`, men det räknar med `int`, och då tar det stopp vid 2 GB. Ett års data är redan nästan 1 GB okomprimerat, så jag valde att skriva ett eget med 64-bitars räknare. Det blev inte många rader:

```cpp
class byte_counter : public io::multichar_input_filter {
    std::uint64_t count_ = 0;
  public:
    template<typename Source>
    auto read(Source& src, char* buffer, std::streamsize n) -> std::streamsize {
      auto result = io::read(src, buffer, n);
      if (result > 0) count_ += static_cast<std::uint64_t>(result);
      return result; // -1 at EOF, passed on unchanged
    }
    [[nodiscard]] auto count() const { return count_; }
};
```

Skillnaden mot vår `http_source` från version 2 är att en device producerar data själv, medan ett filter får sin källa som parameter och läser vidare från den med `io::read()`. Filtret vet alltså inte vad som finns bakom, om det är en annan filterlänk eller själva källan. Basklassen `multichar_input_filter` sätter rätt kategori, `input` plus `filter_tag`. Det betyder att vårt filter inte uppfyller concept:et `StreamSource` och därför inte kan stå sist i kedjan, och det är precis som det ska vara. Filtret lägger vi på ömse sidor om GZIP-filtret, så att vi får både antalet komprimerade och antalet okomprimerade bytes:

```cpp
pipe.push(byte_counter{});              // #0: TAR bytes
pipe.push(io::gzip_decompressor{});     // #1:
pipe.push(byte_counter{});              // #2: GZIP bytes
pipe.push(std::move(src), BUF_SIZE);    // #3:
//...
cnt.uncompress_bytes = pipe.component<byte_counter>(0)->count();
cnt.compress_bytes   = pipe.component<byte_counter>(2)->count();
```

Här finns en liten finess. Eftersom `push()` tar en kopia av filtret är det kedjans egna exemplar som räknar, inte de objekt vi skapade. Därför frågar vi kedjan efter dem med `component<T>(index)`, där index följer push-ordningen. Funktionen returnerar en pekare, som blir `nullptr` om typen inte stämmer med länken på det indexet. Resultatet samlas i en enkel struct, `PipelineCounters`, som funktionen `pipeline()` nu returnerar.

### Aggregator

Statistiken per station hanteras av `Boost.Accumulators`. Biblioteket bygger på en enkel idé: man talar om vilka statistiska mått man vill ha, matar in värdena ett i taget och läser av måtten på slutet. Inget av värdena sparas, och det passar perfekt med vår strömmande inläsning.

```cpp
class StationStats {
  using Stats = acc::accumulator_set<double,
                                     acc::stats<acc::tag::count, acc::tag::mean,
                                     acc::tag::min,   acc::tag::max>>;
  //...
  Stats temp_{};
public:
  void add(Observation const& obs) {
    ++days_;
    if (obs.temp_avg) temp_(*obs.temp_avg);
  }
  auto mean() const -> double { return acc::mean(temp_); }
  auto min()  const -> double { return acc::min(temp_); }
  //...
};
```

Måtten anges som taggar i `acc::stats<...>`, och biblioteket räknar själv ut beroendena mellan dem. `mean` behöver t.ex. både `count` och `sum`, utan att vi behöver ange detta. Ett värde matas in genom att anropa ackumulatorn som en funktion, `temp_(value)`, och måtten läses av med fria funktioner som `acc::mean(temp_)`. Det finns många fler mått att välja bland, som _varians_, _median_, _kvantiler_ och _skevhet_, men för en topplista räcker dessa fyra gott.

Själva klassen `Aggregator` håller en `std::unordered_map` från station-id till `StationStats`, och den skickas direkt till `pipeline()` som den funktion som tar emot varje observation. Eftersom alla rader för en station kommer i följd (en CSV-fil per station) sparar vi en pekare till den aktuella stationen och slår bara upp i map:en när stationen byts:

```cpp
void Aggregator::operator()(Observation const& obs) {
  ++observations_;
  if (current_ == nullptr || current_->id() != obs.station_id) {
    auto [it, inserted] = stations_.try_emplace(obs.station_id, obs.station_id, obs.station);
    current_ = &it->second; // stable, also when the map rehashes
  }
  current_->add(obs);
}
```

Rankningen sker sedan med C++20/23 ranges. Vi väljer ut de stationer som har minst min\_days dagar med temperatur, sorterar fram de N första med `partial_sort` och använder `&StationStats::mean` som projektion.

> Notera att `pipeline()` tar emot sin funktionsparameter som en `std::function_ref`, som är ny i C++26. Med en vanlig `std::function` hade aggregator-objektet kopierats, och all statistik hade då samlats i en kopia som sedan kastas. En `function_ref` refererar bara till objektet och anropar därmed originalet.

### Reporter

För själva utskriften använder vi `std::format`, eftersom Boost som sagt saknar en komponent för textrapporter. Klassen `Reporter` har två funktioner, en för topplistorna och en för sammanfattningen:

```cpp
out << format("{:>3}  {:<11}  {:<32.32}  {:>6.1f}  {:>6.1f}  {:>6.1f}  {:>4}\n",
              rank++, s->id(), s->name(), s->mean(), s->min(), s->max(), s->count());
```

Fältbredd och justering ger kolumnerna, ungefär som de gamla rapportformaten i Perl. Ett trick värt att känna till är `{:<32.32}`: precisionen på en sträng kortar av den till högst 32 tecken, så ett långt stationsnamn förstör inte tabellen. Enheten `°C` skriver vi bara i rubriken, för enkelhetens skull.

### Huvudprogrammet (sista versionen)

Funktionen `main()` innehåller nu både ett aggregator-objekt och ett reporter-objekt, samt tar emot ett pipeline-counters objekt från funktionen `pipeline()`. Så här ser nu main ut i sin helhet

```cpp
int main(int argc, char** argv) {
    auto elapsed = Elapsed{};
    try {
        auto opts = Options::parse(argc, argv);
        if (not opts) return 0;
        auto cfg  = Config::load(opts->config_file);
        auto url  = http_source::archive_url(cfg.url, opts->year);
        auto file = http_source::archive_file(cfg.cache_dir, opts->year);
        constexpr auto MB = 1024 * 1024.0;

        auto download = [&] {
            std::cout << "fetching " << url << " ...\n";
            auto num_bytes = http_source::download(url, file);
            auto mb = static_cast<double>(num_bytes) / MB;
            std::cout << std::format("saved {} ({:.2f} MB)\n", file.string(), mb);
        };

        if (opts->download_only) {
            if (fs::exists(file)) {
                std::cout << "already cached " << file.string() << "\n";
            } else {
                download();
            }
            std::cout << "Elapsed time: " << elapsed.to_string() << "\n";
            return 0;
        }

        auto aggregator = Aggregator{};
        auto cnt        = PipelineCounters{};
        if (cfg.cache) {
            if (fs::exists(file)) {
                std::cout << "using cached " << file.string() << "\n";
            } else {
                download();
            }
            std::cout << "processing file " << file << " ...\n";
            cnt = pipeline(io::file_source{file.string(), std::ios::binary}, cfg, aggregator);
        } else {
            std::cout << "streaming url " << url << " ...\n";
            cnt = pipeline(http_source{url}, cfg, aggregator);
        }

        auto ranking = aggregator.rank(cfg.top, cfg.min_days);
        auto reporter = Reporter{std::cout, cfg};

        auto warmest = std::format("\nThe {} warmest stations in {}",
                                   ranking.warmest.size(), opts->year);
        reporter.ranking(warmest, ranking.warmest);

        auto coldest = std::format("\nThe {} coldest stations in {}",
                                   ranking.coldest.size(), opts->year);
        reporter.ranking(coldest, ranking.coldest);

        reporter.summary(cnt, aggregator, ranking, elapsed);
    } catch (std::exception const& x) {
        std::cout << "Program error: " << x.what() << "\n";
        std::cout << "Use '--help' for help text\n";
        return 1;
    }
}
```

Kör man programmet med sista året det finns data, så ser det ut så här

```shell
$ ./build/debug/version-4/weather-data-v4 -y 2024
streaming url https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/2024.tar.gz ...

The 5 warmest stations in 2024 (°C)
  #  Station      Name                                Mean     Min     Max  Days
  1  64756099999  ABECHE, CD                          32.9    22.8    42.9   348
  2  81833099999  JAGUARIBE, BR                       32.2    25.8    37.1   341
  3  61265099999  AMBODEDJO, ML                       32.1    23.8    41.9   353
  4  78724099999  CHOLUTECA, HO                       31.6    24.2    38.9   312
  5  41280099999  AL AMERAT, MU                       31.6    18.9    42.7   314

The 5 coldest stations in 2024 (°C)
  #  Station      Name                                Mean     Min     Max  Days
  1  89577099999  DOME PLATEAU DOME A, AY            -56.6   -66.2   -41.5   350
  2  89625099999  CONCORDIA, AQ                      -53.2   -76.8   -23.3   337
  3  89828099999  DOME C II, AQ                      -50.9   -76.0   -18.2   355
  4  89009090001  AMUNDSEN SCOTT, AY                 -48.6   -70.1   -23.5   340
  5  89659099999  PRIESTLEY GLACIER, AY              -36.7   -55.4   -14.9   337
12159 stations (10362 with at least 300 days), 3931419 observations

101.4 MB compressed, 880.0 MB uncompressed, 4 min 47 s (3.1 MB/s)
```

Försöker man hämta data från senare år, blir man klart besviken...

```shell
$ ./build/debug/version-4/weather-data-v4 -y 2025
streaming url https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/2025.tar.gz ...

The 0 warmest stations in 2025 (°C)
  #  Station      Name                                Mean     Min     Max  Days

The 0 coldest stations in 2025 (°C)
  #  Station      Name                                Mean     Min     Max  Days
11656 stations (0 with at least 300 days), 2528312 observations

66.8 MB compressed, 571.4 MB uncompressed, 3 min 12 s (3.0 MB/s)

$ ./build/debug/version-4/weather-data-v4 -y 2026
streaming url https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/2026.tar.gz ...
Program error: HTTP 404 Not Found for https://www.ncei.noaa.gov/data/global-summary-of-the-day/archive/2026.tar.gz
Use '--help' for help text
```

Data för år 2025 är som synes ofullständigt. Av 11 656 väderstationer, så fanns det inga som hade data för 300 dagar eller mera, trots att det fanns cirka 2,5 miljoner observationer. Någon datafil för år 2026 och senare saknas helt.

* * *

Sammanfattning
--------------

Vi har byggt ett program i fyra steg som hämtar ett helt års väderdata från NOAA, packar upp och tolkar det i ett enda flöde och till sist presenterar en topplista. Under resans gång har ett tiotal Boost-bibliotek fått göra sitt.

   ![Weather-data pipeline](/blog/2026/vaderdata-med-boost/weather-data-pipeline-dashboard-1280w.jpeg "Weather-data pipeline")

**Konfiguration och kommandorad**. `Property_tree` läser in INI-filen och `Program_options` hanterar kommandoraden. Tillsammans gav de en enkel modell: konfigurationsfilen innehåller alla inställningar, och på kommandoraden kan man skriva över ett urval av dem.

**Nätverk**. `URL` bygger och plockar isär webbadressen, och Beast, ovanpå Asio och OpenSSL, sköter HTTPS-anropet. Beast är kraftfullt men medvetet lågnivå. Det märks på detaljer som standardgränsen på 8 MB för svarskroppen och att `need_buffer` inte är ett fel. Asio förtjänar en egen artikel, och den kommer.

**Strömmar**. Iostreams var den röda tråden genom hela programmet. Med `filtering_istream` och `gzip_decompressor` kunde vi bygga en kedja där _880 MB_ data passerar utan att någonsin ligga i minnet samtidigt. Det var också här vi lärde oss mest. Boosts begrepp, som `Source` och `Filter`, är överenskommelser och inte gränssnitt med virtuella funktioner. Basklasser som `io::source` finns, men är bara en genväg för att få rätt typer och taggar för kategorier. Det gav oss en anledning att skriva ett eget C++20-concept, `StreamSource`, som gör överenskommelsen synlig. Vi lärde oss också att `push()` kopierar, vilket fick konsekvenser både för vår `http_source` och för hur vi läser av räknarna med `component<T>()`.

**Tolkning och statistik**. `Tokenizer` delar upp CSV-raderna, `String_algo` putsar fälten och `Accumulators` räknar ut _medel_, _min_ och _max_ per station, ett värde i taget utan att något sparas.

Lika intressant är det som saknades, så att vi fick skriva det själva:

*   `TarReader`. Boost kan packa upp GZIP, men kan inte läsa ett TAR-arkiv. Formatet är som tur är enkelt, och vår TarReader blev drygt hundra rader. I produktion hade jag valt biblioteket `libarchive`.
*   `http_source`. Det finns ingen färdig HTTP-device för Iostreams, så denna blev limmet mellan Beast och Iostreams.
*   `byte_counter`. Ett 64-bitars räknefilter. Det färdiga `io::counter` räknar med int och slår i taket vid 2 GB.
*   `Reporter`. Boost har ingen mallmotor för rapporter. Här räckte `std::format` gott, men för större behov finns t.ex. `inja` utanför Boost.

Till sist är det tydligt hur standardbiblioteket successivt har tagit över det som Boost en gång introducerade. Datumtolkningen klarade vi med `std::chrono::parse()` i stället för `Date_Time`. `flat_map` finns numera i std, och `std::format` har ersatt `Format`. Funktionsparametern i vår pipeline blev en `std::function_ref` från C++26. Boost fungerar fortfarande som en plantskola, och ett modernt C++-program använder typiskt både det bästa ur standardbiblioteket och det som standarden ännu inte har hunnit med.

Jag hoppas att du tyckte denna artikel var både lärorik och insiktsfull och gav dig en allsidig belysning av vad Boost har att erbjuda, samt på vilka områden Boost har "förbättringspotential".
