Sökresultat för

Väderdata med Boost

35 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Väderdata 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

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_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.

{
  "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

add_executable(weather-data-v1
    app.cxx
)

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

#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.

[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:

#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&;
}
#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.

auto cfg = Config::load("./weather-data.ini");
std::cout << cfg;
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.

#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.

#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.

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.

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.

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

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

#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

$ ./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.

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.

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:

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:

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

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.

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.

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.

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.

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:

$ ./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:

"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

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.

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

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.

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.

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

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

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

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.

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)

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

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

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:

"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.

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.

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.

[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
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:

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:

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.

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:

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:

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

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

$ ./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...

$ ./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

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".