diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..e7f2b34 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,11 @@ +Checks: > + -*, + bugprone-*, + performance-*, + -performance-enum-size, + portability-*, + modernize-use-nullptr, + modernize-use-override +WarningsAsErrors: '*' +HeaderFilterRegex: '^(include|src)/spc/|/spc-cpp/(include|src)/' +FormatStyle: file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fc6066..9f16e9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: build-linux: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 - name: Install dependencies run: | @@ -20,18 +20,21 @@ jobs: libcurl4-openssl-dev - name: Build - run: make build + run: make build CMAKE_ARGS=-DSPC_WARNINGS_AS_ERRORS=ON - name: Test run: make test + - name: Verify fixture provenance + run: make fixtures-check + - name: Lint run: make lint build-macos: runs-on: macos-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 - name: Install dependencies run: | @@ -39,7 +42,7 @@ jobs: echo "CMAKE_PREFIX_PATH=$(brew --prefix curl)" >> "$GITHUB_ENV" - name: Build - run: make build + run: make build CMAKE_ARGS=-DSPC_WARNINGS_AS_ERRORS=ON - name: Test run: make test @@ -50,5 +53,77 @@ jobs: markdown-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 - uses: DavidAnson/markdownlint-cli2-action@v23 + + sanitizer: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake libcurl4-openssl-dev + - name: Build with address and undefined behavior sanitizers + run: >- + cmake -S . -B build-sanitized + -DCMAKE_BUILD_TYPE=Debug + -DSPC_ENABLE_LTO=OFF + -DSPC_ENABLE_SANITIZERS=ON + -DSPC_BUILD_EXAMPLES=OFF + - run: cmake --build build-sanitized --parallel + - run: ctest --test-dir build-sanitized --output-on-failure + + thread-sanitizer: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake libcurl4-openssl-dev + - name: Build with ThreadSanitizer + run: >- + cmake -S . -B build-thread-sanitized + -DCMAKE_BUILD_TYPE=Debug + -DSPC_ENABLE_LTO=OFF + -DSPC_ENABLE_THREAD_SANITIZER=ON + -DSPC_BUILD_EXAMPLES=OFF + - run: cmake --build build-thread-sanitized --parallel + - run: ctest --test-dir build-thread-sanitized --output-on-failure + + clang-tidy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang-18 clang-tidy-18 cmake \ + libc++-18-dev libc++abi-18-dev libcurl4-openssl-dev + - name: Run clang-tidy while compiling + run: >- + cmake -S . -B build-tidy + -DCMAKE_BUILD_TYPE=Debug + -DCMAKE_CXX_COMPILER=clang++-18 + -DCMAKE_CXX_FLAGS=-stdlib=libc++ + -DSPC_ENABLE_CLANG_TIDY=ON + -DSPC_CLANG_TIDY_EXECUTABLE=/usr/bin/clang-tidy-18 + -DSPC_ENABLE_LTO=OFF + -DSPC_BUILD_TESTS=OFF + -DSPC_BUILD_EXAMPLES=OFF + - run: cmake --build build-tidy --parallel + + consumer-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake libcurl4-openssl-dev + - run: ./tools/test_consumers.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a2309c..2e072da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index edab6fc..8a9dfd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.2.0] - 2026-09-03 + +### Added + +- ArcGIS access for conditional intensity and day 4 through 8 probability. +- An injectable `HttpTransport` for deterministic client tests and custom + networking. +- Installed-package and FetchContent consumer checks, ASan, UBSan, TSan, and + clang-tidy CI. +- A checked NOAA ArcGIS 11.3 layer contract and opt-in live metadata check. + +### Changed + +- Fire-weather queries now merge both feature layers for every day from 1 + through 8. Each feature records its source layer. Days 1 and 2 expose the + categorical severity; days 3 through 8 expose a normalized probability. + Group layers are never used as feature endpoints. +- Static probabilistic feeds now use NOAA's published `day{N}otlk_*` filenames. +- Glaze is now 8.3.0 and GoogleTest is now 1.18.0. +- libcurl global state now initializes once per process instead of once per + client. + +### Fixed + +- Corrected the Day 2 tornado and hail probability layers, Day 3 probability, + and every Day 3 through 8 fire-weather layer. +- Decode NOAA's numeric Day 1 and 2 fire-weather `dn` categories without + confusing outlook and dry-thunderstorm labels. +- Percent-encoded ArcGIS query values and parse ArcGIS error and paging fields + as JSON. +- Invalid product combinations now fail before network access. + +### Deprecated + +- `ArcGISClient::query_active_watches()`. NOAA's WWA polygons do not contain + the SPC fields in `WatchPayload`; use `ArchiveClient::watches()`. + ## [0.1.1] - 2026-06-06 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 37005dc..dee2185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,62 +1,49 @@ -# spc-cpp Development Guide +# spc-cpp development guide -## Build & Test +## Commands ```bash -make build # Release build (CMake + make) -make debug # Debug build -make test # Run unit tests (ctest) -make lint # Check formatting (clang-format --dry-run) + cpp_auto_audit -make format # Format in place -make coverage # lcov coverage report -make clean # Remove build/ -make run- # Build + run an example (see examples/) +make build +make test +make lint +make test-consumers +python3 tools/verify_arcgis_metadata.py # live, opt-in ``` +Use `-DSPC_ENABLE_SANITIZERS=ON` for ASan and UBSan. Use +`-DSPC_ENABLE_THREAD_SANITIZER=ON` in a separate build for TSan. Use +`-DSPC_ENABLE_CLANG_TIDY=ON` for the configured clang-tidy gate. + ## Architecture -- **Layered static libraries**: spc_core -> spc_http -> spc_models -> - spc_api -> spc (INTERFACE). `install(EXPORT)` + `spc::` namespace. -- **C++23**: `std::expected` for all returns, no exceptions. -- **Patterns**: Pimpl (HttpClient, the spc_api clients), non-copyable / - movable clients, `[[nodiscard]]`. -- **JSON**: [Glaze](https://github.com/stephenberry/glaze) v7.6.0 via - FetchContent. This **diverges** from the sibling `nws-cpp` / - `ncei-cpp` SDKs (nlohmann/json) on purpose — SPC's properties block - is shape-loose (case-variant `LABEL`/`label`/`dn`, numeric-as-string) - and geometry is polymorphic (`Polygon`/`MultiPolygon`), so the parser - walks a `glz::generic` AST rather than a static `glz::meta`. The - convective path is lifted verbatim from the internal `spc-data` - service and must stay parity-exact (a downstream byte-identity gate - depends on it). Documented in README + CHANGELOG. -- **Tests**: GoogleTest via FetchContent. Fixture files in - `tests/fixtures/` (live-captured SPC payloads, shared corpus with - `spc-data`). - -## Conventions - -- Code style: `.clang-format` (LLVM base, tabs, 100 cols). -- Namespace: `spc`. -- **No `auto`**: explicit types except iterators, structured bindings, - range-for. `tools/cpp_auto_audit.py`, empty allowlist. -- Verbatim convective parser is frozen behavior. Net-new product - models carry their own severity mappers — never reuse - `severity_from_label` for fire weather / watches / etc. -- Include order: project headers first, then system (clang-format - enforced). - -## Data sources - -- **ArcGIS MapServer** (`mapservices.weather.noaa.gov`) — primary, - contract-stable; `ArcGISClient` + `ArcGISPager` (2000-rec paging). -- **Static `www.spc.noaa.gov` `.nolyr.geojson`** — fallback; - `StaticFeedClient` (the 7 feeds `spc-data` ships + day4-8 / fire). -- **IEM** (`mesonet.agron.iastate.edu`) — best-effort historical - backfill; `ArchiveClient` (conservative rate/retry, off any hot path). - -## CI - -GitHub Actions `.github/workflows/ci.yml`: build + test + lint on -Ubuntu 24.04 and macos-latest, markdown-lint via DavidAnson. Release -workflow auto-creates a GitHub Release on `vX.Y.Z` tag push (notes -from `CHANGELOG.md`; the tag must equal `project(spc-cpp VERSION ...)`). +The static-library chain is `spc_core -> spc_http -> spc_models -> spc_api`. +Consumers normally link `spc::spc`. + +All public failures return `Result`, which aliases `std::expected`. High-level clients use `HttpClient` by default and accept a shared +`HttpTransport` for custom networking and tests. + +Glaze 8.3 parses loose SPC JSON. GoogleTest 1.18 runs the unit suite. + +## Invariants + +- Use C++23 and explicit types. `tools/cpp_auto_audit.py` enforces the narrow + exceptions for `auto`. +- Follow `.clang-format`, use the `spc` namespace, and place project includes + before system includes. +- Keep the convective parser aligned with the internal `spc-data` parser. Its + key-case, number conversion, and Polygon or MultiPolygon behavior are + compatibility requirements. +- Give each new product its own label mapper. Fire weather and watch labels do + not use `severity_from_label`. +- Test client behavior through `HttpTransport`. Unit tests must not require + NOAA or IEM access. +- Treat `tests/fixtures/arcgis_layers_2026-09-03.json` as the ArcGIS routing + contract. Run the live metadata check when changing layer IDs. +- Resolve SPC watch boxes through `ArchiveClient::watches()`. NOAA WWA polygons + have a different schema. + +## Release + +The tag must match `project(spc-cpp VERSION ...)`. A `vX.Y.Z` tag triggers the +release workflow, which reads the matching `CHANGELOG.md` section. diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c7de44..821bab5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(spc-cpp VERSION 0.1.1 LANGUAGES CXX) +project(spc-cpp VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -14,6 +14,30 @@ option(SPC_BUILD_TESTS "Build tests" ON) option(SPC_BUILD_EXAMPLES "Build examples" ON) option(SPC_ENABLE_LTO "Enable Link Time Optimization" ON) option(SPC_NATIVE_ARCH "Use -march=native for CPU-specific tuning" OFF) +option(SPC_ENABLE_CLANG_TIDY "Run clang-tidy while compiling project targets" OFF) +option(SPC_WARNINGS_AS_ERRORS "Treat project warnings as errors" OFF) + +if(SPC_ENABLE_CLANG_TIDY) + find_program(SPC_CLANG_TIDY_EXECUTABLE NAMES clang-tidy REQUIRED) +endif() + +function(spc_configure_target target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4) + if(SPC_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE /WX) + endif() + else() + target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic) + if(SPC_WARNINGS_AS_ERRORS) + target_compile_options(${target} PRIVATE -Werror) + endif() + endif() + if(SPC_ENABLE_CLANG_TIDY) + set_property(TARGET ${target} PROPERTY CXX_CLANG_TIDY + "${SPC_CLANG_TIDY_EXECUTABLE};--warnings-as-errors=*") + endif() +endfunction() # Aggressive optimization flags for Release builds. MSVC has its own # Release defaults; only override for GCC/Clang. @@ -51,11 +75,23 @@ endif() # Sanitizer support option(SPC_ENABLE_SANITIZERS "Enable AddressSanitizer and UBSanitizer" OFF) +option(SPC_ENABLE_THREAD_SANITIZER "Enable ThreadSanitizer" OFF) +if(SPC_ENABLE_SANITIZERS AND SPC_ENABLE_THREAD_SANITIZER) + message(FATAL_ERROR "AddressSanitizer and ThreadSanitizer require separate builds") +endif() if(SPC_ENABLE_SANITIZERS) add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) add_link_options(-fsanitize=address,undefined) message(STATUS "Sanitizers enabled (ASan + UBSan)") endif() +if(SPC_ENABLE_THREAD_SANITIZER) + if(MSVC) + message(FATAL_ERROR "ThreadSanitizer is not supported by this MSVC configuration") + endif() + add_compile_options(-fsanitize=thread -fno-omit-frame-pointer) + add_link_options(-fsanitize=thread) + message(STATUS "ThreadSanitizer enabled") +endif() # Code coverage support option(SPC_ENABLE_COVERAGE "Enable code coverage instrumentation (gcov)" OFF) @@ -76,12 +112,12 @@ include(FetchContent) # polymorphic (Polygon/MultiPolygon). This DIVERGES from the nlohmann-based # nws-cpp / ncei-cpp SDKs on purpose (see README + CHANGELOG). The convective # parse path is lifted verbatim from the internal spc-data service. -# License: MIT. Pinned to the same v7.6.0 the rest of the estate uses so a +# License: MIT. Pinned to the same v8.3.0 the rest of the estate uses so a # combined FetchContent build dedupes to a single glaze::glaze target. FetchContent_Declare( glaze GIT_REPOSITORY https://github.com/stephenberry/glaze.git - GIT_TAG v7.6.0 + GIT_TAG v8.3.0 GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(glaze) @@ -96,10 +132,11 @@ if(SPC_BUILD_TESTS) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.15.2 + GIT_TAG v1.18.0 GIT_SHALLOW TRUE ) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(googletest) enable_testing() add_subdirectory(tests) @@ -120,6 +157,13 @@ install(TARGETS spc_core spc_http spc_models spc_api spc INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) install(DIRECTORY include/spc DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +# Public model headers use Glaze's generic JSON types. Install the pinned +# headers so find_package consumers do not need a second package manager. +install(DIRECTORY ${glaze_SOURCE_DIR}/include/glaze DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(FILES ${glaze_SOURCE_DIR}/LICENSE + DESTINATION ${CMAKE_INSTALL_DATADIR}/licenses/spc-cpp + RENAME glaze-LICENSE +) configure_package_config_file( ${CMAKE_CURRENT_SOURCE_DIR}/cmake/spcConfig.cmake.in diff --git a/Makefile b/Makefile index 27da88c..877d86b 100644 --- a/Makefile +++ b/Makefile @@ -8,17 +8,18 @@ NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.performancecores 2>/dev/null .PHONY: all build debug test lint clean configure configure-debug help format pre-commit \ install-hooks coverage lint-md format-md \ + test-consumers fixtures-check \ run-static_feed run-arcgis run-archive all: build configure: @mkdir -p $(BUILD_DIR) - @cd $(BUILD_DIR) && $(CMAKE) .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + @cd $(BUILD_DIR) && $(CMAKE) .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON $(CMAKE_ARGS) configure-debug: @mkdir -p $(BUILD_DIR) - @cd $(BUILD_DIR) && $(CMAKE) .. -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + @cd $(BUILD_DIR) && $(CMAKE) .. -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON $(CMAKE_ARGS) build: configure @$(CMAKE) --build $(BUILD_DIR) -j$(NPROC) @@ -29,6 +30,12 @@ debug: configure-debug test: build @cd $(BUILD_DIR) && ctest --output-on-failure +test-consumers: + @./tools/test_consumers.sh + +fixtures-check: + @python3 tools/verify_fixture_checksums.py + lint: @if command -v clang-format >/dev/null 2>&1; then \ echo "Checking code formatting..."; \ diff --git a/README.md b/README.md index ef96e94..49dec2b 100644 --- a/README.md +++ b/README.md @@ -3,153 +3,132 @@ [![CI](https://github.com/Reddimus/spc-cpp/actions/workflows/ci.yml/badge.svg)](https://github.com/Reddimus/spc-cpp/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/Reddimus/spc-cpp)](https://github.com/Reddimus/spc-cpp/releases) [![C++23](https://img.shields.io/badge/C%2B%2B-23-blue.svg)](https://en.cppreference.com/w/cpp/23) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -C++23 SDK for [NOAA Storm Prediction Center](https://www.spc.noaa.gov/) -(SPC) severe-weather products: convective outlooks (Day 1-3 categorical & -probabilistic, Day 4-8, conditional intensity), fire-weather outlooks, -active watches, mesoscale discussions, and storm reports. +spc-cpp is a C++23 SDK for NOAA Storm Prediction Center weather products. It +reads SPC's ArcGIS services, static GeoJSON feeds, and selected Iowa +Environmental Mesonet archives. Every fallible SDK operation returns +`std::expected`; no API key is required. -Typed, `std::expected`-based access over three sources: the **ArcGIS -MapServer** (`mapservices.weather.noaa.gov`, primary — contract-stable), -the static **`www.spc.noaa.gov` GeoJSON feeds** (fallback), and the -**IEM archive** (`mesonet.agron.iastate.edu`, historical backfill). No -API key required. +## First run -## Quick Start +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` + +The build requires CMake, a C++23 compiler, and libcurl. + +## Use the SDK ```cpp #include "spc/spc.hpp" -#include - -int main() { - spc::HttpClient http; // absolute-URL pass-through; serves SPC/ArcGIS/IEM - - spc::Result r = http.get( - "https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson"); - if (!r) { - std::cerr << r.error().message << "\n"; - return 1; - } - if (r->status_code == 404) { - // SPC's normal "no active outlook" state — clear, don't error. - return 0; - } - - const spc::CategoricalOutlookPayload p = - spc::parse_categorical(r->body, /*day_offset=*/1); - for (const spc::OutlookFeature& f : p.features) { - std::cout << f.label << " (severity " << static_cast(f.severity) - << ") rings=" << f.rings.size() << "\n"; - } + +spc::ArcGISClient client; +spc::Result outlook = + client.query_probabilistic(1, "tornado"); +if (!outlook) { + // Inspect outlook.error().code and outlook.error().message. } ``` -## Features - -- **C++23** with `Result = std::expected` for all fallible - APIs (no exceptions across the public surface). -- **Convective outlooks**: Day 1-3 categorical + probabilistic - (tornado/hail/wind), Day 4-8, conditional intensity. -- **Fire weather, watches, mesoscale discussions, storm reports** - (the mesoscale-discussion narrative is exposed raw — not parsed). -- **Three sources, one client**: `ArcGISClient` (+ `ArcGISPager` for - the 2000-record transfer limit), `StaticFeedClient`, `ArchiveClient` - (IEM, conservative rate/retry). -- Ray-cast **point-in-polygon** + Polygon/MultiPolygon geometry. -- Battle-tested NOAA-GeoJSON parsing: case-variant keys - (`LABEL`/`label`/`dn`), numeric-as-string labels, polymorphic - geometry — lifted verbatim from a production ingestion service. +The clients validate day and product combinations before making a request. +Supported ArcGIS products include: -## Install +- Day 1 through 3 categorical outlooks. +- Day 1 and 2 tornado, hail, and wind probabilities. +- Day 3 severe probability. +- Day 1 through 3 conditional intensity. +- Day 4 through 8 severe probability. +- Day 1 through 8 fire weather, merging both published layers for each day. + Days 1 and 2 report categorical severity; days 3 through 8 report normalized + probability. +- Active mesoscale discussions. + +`StaticFeedClient` provides categorical, probabilistic, and day 4 through 8 +fallbacks. `ArchiveClient` reads IEM watch and storm-report data. + +### Active watches + +Use `ArchiveClient::watches()` for SPC watch boxes. NOAA's WWA ArcGIS service +publishes CAP and WFO polygons, but it omits the SPC parameters represented by +`WatchPayload`, including PDS status and maximum hail and wind. The old +`ArcGISClient::query_active_watches()` method is deprecated and returns +`InvalidRequest` instead of fabricating incomplete watches. -### CMake `find_package` +### Custom networking + +`HttpClient` is the default GET transport. Implement `HttpTransport` and pass +a `std::shared_ptr` to a high-level client to use another +network stack or deterministic test responses. + +## Install ```bash -cmake -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DSPC_BUILD_TESTS=OFF +cmake --build build --parallel cmake --install build --prefix /your/prefix ``` ```cmake -find_package(spc 0.1.0 REQUIRED) +find_package(spc 0.2 REQUIRED) target_link_libraries(myapp PRIVATE spc::spc) ``` -### FetchContent +FetchContent consumers can pin the release: ```cmake +include(FetchContent) FetchContent_Declare(spc_cpp GIT_REPOSITORY https://github.com/Reddimus/spc-cpp.git - GIT_TAG v0.1.0 # pin a tagged release + GIT_TAG v0.2.0 ) FetchContent_MakeAvailable(spc_cpp) target_link_libraries(myapp PRIVATE spc::spc) ``` -## Architecture - -```mermaid -graph LR - spc_core["spc_core
error, geometry, rate_limit, retry, pagination"] - spc_http["spc_http
libcurl GET client (abs-URL pass-through)"] - spc_models["spc_models
glz::generic parsers
+ GeoJSON/Esri shapes
"] - spc_api["spc_api
StaticFeed / ArcGIS / Archive clients"] - spc["spc
INTERFACE"] - spc_core --> spc_http --> spc_models --> spc_api --> spc -``` - -Layered static libraries with `install(EXPORT)` and a `spc::` namespace. - -## JSON library: Glaze (divergence note) - -**spc-cpp parses JSON with [Glaze](https://github.com/stephenberry/glaze) -v7.6.0, not `nlohmann/json`.** This is a deliberate divergence from the -sibling Reddimus SDKs `nws-cpp` and `ncei-cpp`, which use -`nlohmann/json`. +## Project map -Why: SPC's GeoJSON `properties` block is shape-loose — the same logical -field appears as `LABEL`, `label`, or `dn` depending on the product; a -probabilistic label is sometimes the string `"5"` and sometimes the -number `5`; timestamps are a compact `YYYYMMDDHHMM`; and geometry is -polymorphic (`Polygon` vs `MultiPolygon`). A compile-time -`glz::meta` schema would either reject these or force tagged-variant -gymnastics. Instead the parser walks a `glz::generic` AST with -null-safe extractors (missing/null/type-mismatch → default-constructed -value). This is the exact, audit-clean code path proven in the internal -`spc-data` ingestion service; it is lifted **verbatim** so a downstream -consumer that swaps its in-tree copy for this SDK gets byte-identical -parse output. Net-new product models reuse the same `glz::generic` -helpers but **do not** reuse the convective `severity_from_label` -(different label sets — they carry product-specific severity mappers). +| Path | Contents | +| --- | --- | +| `include/spc/` | Public clients, models, errors, and geometry helpers | +| `src/api/` | Static feed, ArcGIS, and IEM routing | +| `src/models/` | Glaze-backed GeoJSON and Esri parsers | +| `src/http/` | libcurl transport | +| `tests/` | Public client tests and captured parser fixtures | +| `tools/` | Style, consumer, and live metadata checks | -If you vendor multiple Reddimus SDKs via FetchContent, note that -spc-cpp pins the **same** glaze `v7.6.0` tag the rest of the estate -uses, so a combined build dedupes to a single `glaze::glaze` target. +The static libraries form this dependency chain: +`spc_core -> spc_http -> spc_models -> spc_api`. Link `spc::spc` unless you +need one layer directly. -## Build & Test +## Verify changes ```bash -make build # Release -make test # ctest -make lint # clang-format --dry-run + cpp_auto_audit -make coverage # lcov report +make test +make lint +make test-consumers +make fixtures-check +python3 tools/verify_arcgis_metadata.py # requires network access ``` -## Dependencies +The normal unit suite does not depend on NOAA availability. The metadata +command compares the live ArcGIS layer IDs and names with the checked +2026-09-03 contract, then queries all 39 feature layers. -| Dependency | Purpose | Source | -| ---------- | ------- | ------ | -| [Glaze](https://github.com/stephenberry/glaze) v7.6.0 | JSON (generic-AST parse of shape-loose SPC GeoJSON) | `FetchContent` | -| libcurl | HTTP GET | system (`find_package(CURL)`) | -| GoogleTest v1.15.2 | Unit testing | `FetchContent` | +SPC payloads vary in key case, numeric representation, and geometry type. The +parsers use Glaze 8.3's generic JSON tree to handle those shapes. The original +convective parser stays aligned with the downstream `spc-data` service; new +product parsers keep their own label mappings. ## References -- [SPC Products](https://www.spc.noaa.gov/products/) -- [NWS ArcGIS MapServer — SPC outlooks](https://mapservices.weather.noaa.gov/vector/rest/services/outlooks/SPC_wx_outlks/MapServer) +- [SPC products](https://www.spc.noaa.gov/products/) +- [SPC outlook MapServer](https://mapservices.weather.noaa.gov/vector/rest/services/outlooks/SPC_wx_outlks/MapServer) +- [SPC fire-weather MapServer](https://mapservices.weather.noaa.gov/vector/rest/services/fire_weather/SPC_firewx/MapServer) - [IEM SPC archive](https://mesonet.agron.iastate.edu/) ## License -MIT — see [LICENSE](LICENSE). +[MIT](LICENSE) diff --git a/include/spc/api.hpp b/include/spc/api.hpp index 713e78b..6f67f28 100644 --- a/include/spc/api.hpp +++ b/include/spc/api.hpp @@ -30,6 +30,7 @@ namespace spc { class StaticFeedClient { public: explicit StaticFeedClient(ClientConfig config = {}); + explicit StaticFeedClient(std::shared_ptr transport); ~StaticFeedClient(); StaticFeedClient(StaticFeedClient&&) noexcept; StaticFeedClient& operator=(StaticFeedClient&&) noexcept; @@ -40,7 +41,8 @@ class StaticFeedClient { /// normal "no active outlook"; callers clear rows, not error out. [[nodiscard]] Result day_categorical(std::int32_t day); - /// Day-N probabilistic (day 1: hazard tornado|hail|wind; day 2: "any"). + /// Day-N probabilistic. Days 1 and 2 accept tornado, hail, or wind. + /// Day 3 accepts severe. "any" remains an alias for day-3 severe. [[nodiscard]] Result day_probabilistic(std::int32_t day, const std::string& hazard); @@ -65,12 +67,20 @@ struct QueryParams { std::string f{"json"}; ///< "json" (Esri) or "geojson" }; +/// NOAA MapServer used by a raw ArcGIS query. +enum class ArcGISService : std::uint8_t { + Outlooks, + FireWeather, + MesoscaleDiscussions, +}; + /// SPC ArcGIS MapServer client. Layer ids are the documented /// `SPC_wx_outlks` / `SPC_firewx` / `spc_mesoscale_discussion` layout. /// Paginates via `ArcGISPager` (2000-record transfer limit). class ArcGISClient { public: explicit ArcGISClient(ClientConfig config = {}); + explicit ArcGISClient(std::shared_ptr transport); ~ArcGISClient(); ArcGISClient(ArcGISClient&&) noexcept; ArcGISClient& operator=(ArcGISClient&&) noexcept; @@ -80,15 +90,25 @@ class ArcGISClient { [[nodiscard]] Result query_categorical(std::int32_t day); [[nodiscard]] Result query_probabilistic(std::int32_t day, const std::string& hazard); + [[nodiscard]] Result + query_conditional_intensity(std::int32_t day, const std::string& hazard); + [[nodiscard]] Result query_day4_8(std::int32_t day); [[nodiscard]] Result query_fire_weather(std::int32_t day); - [[nodiscard]] Result query_active_watches(); + /// NOAA's WWA polygons do not contain the SPC watch parameters represented + /// by WatchPayload. Use ArchiveClient::watches() for active SPC watches. + [[deprecated( + "use ArchiveClient::watches() for SPC watch data")]] [[nodiscard]] Result + query_active_watches(); [[nodiscard]] Result query_active_md(); [[nodiscard]] Result query_storm_reports(); - /// Escape hatch: raw paged query against an arbitrary layer id; returns - /// the concatenated raw response bodies (one per page). - [[nodiscard]] Result> query_layer(std::int32_t layer_id, - const QueryParams& params); + /// Raw paged query against one of the documented NOAA MapServers. + [[nodiscard]] Result> + query_layer(ArcGISService service, std::int32_t layer_id, const QueryParams& params); + + /// Compatibility overload. Queries the SPC outlook MapServer. + [[deprecated("pass ArcGISService explicitly")]] [[nodiscard]] Result> + query_layer(std::int32_t layer_id, const QueryParams& params); private: struct Impl; @@ -103,6 +123,7 @@ class ArcGISClient { class ArchiveClient { public: explicit ArchiveClient(ClientConfig config = {}); + explicit ArchiveClient(std::shared_ptr transport); ~ArchiveClient(); ArchiveClient(ArchiveClient&&) noexcept; ArchiveClient& operator=(ArchiveClient&&) noexcept; diff --git a/include/spc/error.hpp b/include/spc/error.hpp index e02e75d..e693e46 100644 --- a/include/spc/error.hpp +++ b/include/spc/error.hpp @@ -64,34 +64,34 @@ struct Error { return code == ErrorCode::FeedUnavailable; } - [[nodiscard]] static Error ok() { return {ErrorCode::Ok, ""}; } + [[nodiscard]] static Error ok() { return {ErrorCode::Ok, "", 0, ""}; } [[nodiscard]] static Error network(std::string msg) { - return {ErrorCode::NetworkError, std::move(msg)}; + return {ErrorCode::NetworkError, std::move(msg), 0, ""}; } [[nodiscard]] static Error parse(std::string msg) { - return {ErrorCode::ParseError, std::move(msg)}; + return {ErrorCode::ParseError, std::move(msg), 0, ""}; } [[nodiscard]] static Error not_found(std::string msg) { - return {ErrorCode::NotFound, std::move(msg)}; + return {ErrorCode::NotFound, std::move(msg), 0, ""}; } [[nodiscard]] static Error feed_unavailable(std::string msg) { - return {ErrorCode::FeedUnavailable, std::move(msg)}; + return {ErrorCode::FeedUnavailable, std::move(msg), 0, ""}; } [[nodiscard]] static Error rate_limited(std::string msg) { - return {ErrorCode::RateLimited, std::move(msg)}; + return {ErrorCode::RateLimited, std::move(msg), 0, ""}; } [[nodiscard]] static Error server(std::string msg) { - return {ErrorCode::ServerError, std::move(msg)}; + return {ErrorCode::ServerError, std::move(msg), 0, ""}; } [[nodiscard]] static Error invalid_request(std::string msg) { - return {ErrorCode::InvalidRequest, std::move(msg)}; + return {ErrorCode::InvalidRequest, std::move(msg), 0, ""}; } /// Create an Error from an HTTP response status code and body. SPC's 404 diff --git a/include/spc/http_client.hpp b/include/spc/http_client.hpp index 6ef37b2..3e07998 100644 --- a/include/spc/http_client.hpp +++ b/include/spc/http_client.hpp @@ -26,21 +26,32 @@ struct ClientConfig { /// URLs — one client then serves spc.noaa.gov, the ArcGIS MapServer, /// and the IEM archive interchangeably (spc-data's fetcher behavior). std::string base_url; - std::string user_agent{"PredictionCastAI spc-cpp/0.1.0 (contact@predictioncast.ai)"}; + std::string user_agent{"spc-cpp/0.2.0 (contact@predictioncast.ai)"}; std::chrono::seconds timeout{15}; bool verify_ssl{true}; }; +/// GET transport boundary used by the high-level clients. +/// +/// Applications normally use HttpClient. The interface also lets callers +/// supply their own networking stack and lets tests run without NOAA access. +class HttpTransport { +public: + virtual ~HttpTransport() = default; + + [[nodiscard]] virtual Result get(std::string_view path) const = 0; +}; + /// GET-only HTTP client. Behavior parity with spc-data/src/fetcher.cpp: /// FOLLOWLOCATION on, NOSIGNAL on, empty ACCEPT_ENCODING (advertise all /// supported), and the SPC User-Agent. /// /// @note NOT thread-safe — the CURL handle is shared per instance. Use one /// client per thread or guard with a mutex. -class HttpClient { +class HttpClient final : public HttpTransport { public: explicit HttpClient(ClientConfig config = {}); - ~HttpClient(); + ~HttpClient() override; HttpClient(HttpClient&&) noexcept; HttpClient& operator=(HttpClient&&) noexcept; @@ -49,7 +60,7 @@ class HttpClient { /// GET `path`. If `path` is an absolute URL (starts with http) it is /// used verbatim; otherwise it is appended to `config().base_url`. - [[nodiscard]] Result get(std::string_view path) const; + [[nodiscard]] Result get(std::string_view path) const override; [[nodiscard]] const ClientConfig& config() const noexcept; diff --git a/include/spc/models/common.hpp b/include/spc/models/common.hpp index 6a7f898..58b5034 100644 --- a/include/spc/models/common.hpp +++ b/include/spc/models/common.hpp @@ -38,6 +38,10 @@ std::string json_string(const Json& obj, const char* key); /// Always returns a numeric view; non-numeric / missing yields 0. double json_number_or_numeric_string(const Json& obj, const char* key); +/// Read an SPC probability from LABEL/label/dn and normalize it to [0, 1]. +/// Fractional labels pass through; integer-percent values are divided by 100. +double normalized_probability(const Json& obj); + /// Convert SPC's compact "YYYYMMDDHHMM" timestamp to ISO 8601 /// "YYYY-MM-DDTHH:MM:00Z"; returns the input unchanged on format mismatch. std::string spc_ts_to_iso8601(std::string_view spc_ts); diff --git a/include/spc/models/fire_weather.hpp b/include/spc/models/fire_weather.hpp index db4aa10..45b49d6 100644 --- a/include/spc/models/fire_weather.hpp +++ b/include/spc/models/fire_weather.hpp @@ -16,10 +16,18 @@ namespace spc { +enum class FireWeatherLayer : std::uint8_t { + Outlook, + DryThunderstorm, + WindLowHumidity, +}; + struct FireWeatherFeature { std::int32_t day = 0; + FireWeatherLayer layer{FireWeatherLayer::Outlook}; std::string label; ///< raw, e.g. "ELEV", "CRIT", "EXTM", "IDRT", "SDRT" std::uint8_t severity = 0; ///< product-specific 1..3 (0 if unknown) + double probability = 0.0; ///< day 3..8 probability normalized to [0,1] std::vector rings; std::string issued_at; std::string valid_from; @@ -40,4 +48,10 @@ struct FireWeatherPayload { /// std::runtime_error on malformed JSON. [[nodiscard]] FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day); +/// Parse one known ArcGIS fire-weather layer. Day 1 and 2 use the layer kind +/// to disambiguate numeric `dn` codes shared by categorical and dry-thunder +/// products. Day 3 through 8 decode LABEL/label/dn as probabilities. +[[nodiscard]] FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day, + FireWeatherLayer layer); + } // namespace spc diff --git a/include/spc/models/outlook.hpp b/include/spc/models/outlook.hpp index 766156e..11a5a6f 100644 --- a/include/spc/models/outlook.hpp +++ b/include/spc/models/outlook.hpp @@ -24,7 +24,8 @@ namespace spc { std::int32_t day_offset); /// Parse SPC's day-N probabilistic outlook GeoJSON -/// (day{1,2}probotlk_{torn,hail,wind}.geojson) from a raw JSON body. Throws +/// (day{1,2}otlk_{torn,hail,wind}.geojson or day3otlk_prob.geojson) from a +/// raw JSON body. Throws /// std::runtime_error on malformed JSON. [[nodiscard]] ProbOutlookPayload parse_probabilistic(std::string_view body, std::int32_t day_offset, std::string hazard); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9d372be..a33412c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(spc_core STATIC core/geometry.cpp core/rate_limit.cpp ) +spc_configure_target(spc_core) target_compile_features(spc_core PUBLIC cxx_std_23) target_include_directories(spc_core PUBLIC $ @@ -19,6 +20,7 @@ target_include_directories(spc_core PUBLIC add_library(spc_http STATIC http/client.cpp ) +spc_configure_target(spc_http) target_link_libraries(spc_http PUBLIC spc_core CURL::libcurl) target_include_directories(spc_http PUBLIC $ @@ -34,6 +36,7 @@ add_library(spc_models STATIC models/mesoscale.cpp models/storm_report.cpp ) +spc_configure_target(spc_models) target_link_libraries(spc_models PUBLIC spc_core) target_include_directories(spc_models PUBLIC $ @@ -45,6 +48,7 @@ target_include_directories(spc_models PUBLIC add_library(spc_api STATIC api/client.cpp ) +spc_configure_target(spc_api) target_link_libraries(spc_api PUBLIC spc_core spc_http spc_models) target_include_directories(spc_api PUBLIC $ diff --git a/src/api/client.cpp b/src/api/client.cpp index afeee67..d5155b5 100644 --- a/src/api/client.cpp +++ b/src/api/client.cpp @@ -2,12 +2,17 @@ /// @brief StaticFeed / ArcGIS / Archive client implementations. #include "spc/api.hpp" +#include "spc/models/common.hpp" #include "spc/pagination.hpp" #include "spc/rate_limit.hpp" #include "spc/retry.hpp" +#include +#include #include +#include #include +#include #include namespace spc { @@ -24,6 +29,130 @@ constexpr const char* kArcGisMd = "https://mapservices.weather.noaa.gov/vector/r "outlooks/spc_mesoscale_discussion/MapServer"; constexpr const char* kIemBase = "https://mesonet.agron.iastate.edu/"; +enum class LayerProduct : std::uint8_t { + Categorical, + Probability, + ConditionalIntensity, + FireWeather, +}; + +struct LayerDescriptor { + LayerProduct product; + std::int32_t day; + std::string_view subtype; + std::int32_t id; + std::string_view name; +}; + +constexpr std::array kLayers{{ + {LayerProduct::Categorical, 1, "", 1, "Day 1 Categorical Outlook"}, + {LayerProduct::ConditionalIntensity, 1, "tornado", 2, "Day 1 Tornado Conditional Intensity"}, + {LayerProduct::Probability, 1, "tornado", 3, "Day 1 Probabilistic Tornado Outlook"}, + {LayerProduct::ConditionalIntensity, 1, "hail", 4, "Day 1 Hail Conditional Intensity"}, + {LayerProduct::Probability, 1, "hail", 5, "Day 1 Probabilistic Hail Outlook"}, + {LayerProduct::ConditionalIntensity, 1, "wind", 6, "Day 1 Wind Conditional Intensity"}, + {LayerProduct::Probability, 1, "wind", 7, "Day 1 Probabilistic Wind Outlook"}, + {LayerProduct::Categorical, 2, "", 9, "Day 2 Categorical Outlook"}, + {LayerProduct::ConditionalIntensity, 2, "tornado", 10, "Day 2 Tornado Conditional Intensity"}, + {LayerProduct::Probability, 2, "tornado", 11, "Day 2 Probabilistic Tornado Outlook"}, + {LayerProduct::ConditionalIntensity, 2, "hail", 12, "Day 2 Hail Conditional Intensity"}, + {LayerProduct::Probability, 2, "hail", 13, "Day 2 Probabilistic Hail Outlook"}, + {LayerProduct::ConditionalIntensity, 2, "wind", 14, "Day 2 Wind Conditional Intensity"}, + {LayerProduct::Probability, 2, "wind", 15, "Day 2 Probabilistic Wind Outlook"}, + {LayerProduct::Categorical, 3, "", 17, "Day 3 Categorical Outlook"}, + {LayerProduct::ConditionalIntensity, 3, "severe", 18, "Day 3 Severe Conditional Intensity"}, + {LayerProduct::Probability, 3, "severe", 19, "Day 3 Probabilistic Outlook"}, + {LayerProduct::Probability, 4, "severe", 21, "Day 4 Probabilistic Outlook"}, + {LayerProduct::Probability, 5, "severe", 22, "Day 5 Probabilistic Outlook"}, + {LayerProduct::Probability, 6, "severe", 23, "Day 6 Probabilistic Outlook"}, + {LayerProduct::Probability, 7, "severe", 24, "Day 7 Probabilistic Outlook"}, + {LayerProduct::Probability, 8, "severe", 25, "Day 8 Probabilistic Outlook"}, + {LayerProduct::FireWeather, 1, "outlook", 1, "Day 1 Outlook"}, + {LayerProduct::FireWeather, 1, "dry-thunderstorm", 2, "Day 1 Outlook Dry Thunderstorm"}, + {LayerProduct::FireWeather, 2, "outlook", 4, "Day 2 Outlook"}, + {LayerProduct::FireWeather, 2, "dry-thunderstorm", 5, "Day 2 Outlook Dry Thunderstorm"}, + {LayerProduct::FireWeather, 3, "dry-thunderstorm", 7, "Day 3 Dry Thunderstorm"}, + {LayerProduct::FireWeather, 3, "wind-low-humidity", 8, "Day 3 Winds and Low Humidity"}, + {LayerProduct::FireWeather, 4, "dry-thunderstorm", 10, "Day 4 Dry Thunderstorm"}, + {LayerProduct::FireWeather, 4, "wind-low-humidity", 11, "Day 4 Winds and Low Humidity"}, + {LayerProduct::FireWeather, 5, "dry-thunderstorm", 13, "Day 5 Dry Thunderstorm"}, + {LayerProduct::FireWeather, 5, "wind-low-humidity", 14, "Day 5 Winds and Low Humidity"}, + {LayerProduct::FireWeather, 6, "dry-thunderstorm", 16, "Day 6 Dry Thunderstorm"}, + {LayerProduct::FireWeather, 6, "wind-low-humidity", 17, "Day 6 Winds and Low Humidity"}, + {LayerProduct::FireWeather, 7, "dry-thunderstorm", 19, "Day 7 Dry Thunderstorm"}, + {LayerProduct::FireWeather, 7, "wind-low-humidity", 20, "Day 7 Winds and Low Humidity"}, + {LayerProduct::FireWeather, 8, "dry-thunderstorm", 22, "Day 8 Dry Thunderstorm"}, + {LayerProduct::FireWeather, 8, "wind-low-humidity", 23, "Day 8 Winds and Low Humidity"}, +}}; + +const LayerDescriptor* find_layer(LayerProduct product, std::int32_t day, + std::string_view subtype) { + for (const LayerDescriptor& descriptor : kLayers) { + if (descriptor.product == product && descriptor.day == day && + descriptor.subtype == subtype) { + return &descriptor; + } + } + return nullptr; +} + +std::shared_ptr usable_transport(std::shared_ptr transport) { + if (transport != nullptr) { + return transport; + } + return std::make_shared(); +} + +std::string normalized_severe_hazard(std::int32_t day, const std::string& hazard) { + if (day == 3 && hazard == "any") { + return "severe"; + } + return hazard; +} + +std::string percent_encode(std::string_view value) { + constexpr char kHex[] = "0123456789ABCDEF"; + std::string encoded; + encoded.reserve(value.size()); + for (const unsigned char ch : value) { + if (std::isalnum(ch) != 0 || ch == '-' || ch == '.' || ch == '_' || ch == '~') { + encoded.push_back(static_cast(ch)); + } else { + encoded.push_back('%'); + encoded.push_back(kHex[ch >> 4U]); + encoded.push_back(kHex[ch & 0x0FU]); + } + } + return encoded; +} + +const char* service_base(ArcGISService service) { + switch (service) { + case ArcGISService::Outlooks: + return kArcGisOutlks; + case ArcGISService::FireWeather: + return kArcGisFirewx; + case ArcGISService::MesoscaleDiscussions: + return kArcGisMd; + } + return kArcGisOutlks; +} + +Result inspect_arcgis_envelope(const std::string& body) { + const glz::expected root = detail::parse_root(body); + if (!root) { + return std::unexpected(Error::parse(root.error())); + } + const Json* error = detail::lookup(*root, "error"); + if (error != nullptr && error->is_object()) { + const double raw_code = detail::json_number_or_numeric_string(*error, "code"); + const int code = raw_code > 0.0 ? static_cast(raw_code) : 400; + return std::unexpected(Error::from_response(code, body)); + } + const Json* exceeded = detail::lookup(*root, "exceededTransferLimit"); + return exceeded != nullptr && exceeded->is_boolean() && exceeded->get(); +} + /// SPC 404 == "no active outlook" (FeedUnavailable). Map HTTP status to the /// right error; only a real body is handed to the parser. Result body_or_error(Result r) { @@ -41,21 +170,29 @@ Result body_or_error(Result r) { // ===================== StaticFeedClient ===================== struct StaticFeedClient::Impl { - HttpClient http; + std::shared_ptr http; RetryPolicy retry; - explicit Impl(ClientConfig cfg) : http(std::move(cfg)) {} + explicit Impl(ClientConfig cfg) : http(std::make_shared(std::move(cfg))) {} + explicit Impl(std::shared_ptr transport) + : http(usable_transport(std::move(transport))) {} }; StaticFeedClient::StaticFeedClient(ClientConfig config) : impl_(std::make_unique(std::move(config))) {} +StaticFeedClient::StaticFeedClient(std::shared_ptr transport) + : impl_(std::make_unique(std::move(transport))) {} StaticFeedClient::~StaticFeedClient() = default; StaticFeedClient::StaticFeedClient(StaticFeedClient&&) noexcept = default; StaticFeedClient& StaticFeedClient::operator=(StaticFeedClient&&) noexcept = default; Result StaticFeedClient::day_categorical(std::int32_t day) { + if (day < 1 || day > 3) { + return std::unexpected( + Error::invalid_request("categorical outlook day must be 1, 2, or 3")); + } const std::string url = std::format("{}day{}otlk_cat.nolyr.geojson", kStaticBase, day); Result body = - body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + body_or_error(with_retry([&] { return impl_->http->get(url); }, impl_->retry)); if (!body) { return std::unexpected(body.error()); } @@ -68,28 +205,40 @@ Result StaticFeedClient::day_categorical(std::int32_t Result StaticFeedClient::day_probabilistic(std::int32_t day, const std::string& hazard) { - // Day 1: dayNprobotlk_{torn,hail,wind}; Day 2: day2probotlk_any. - std::string tag = hazard; - if (hazard == "tornado") { + const std::string normalized = normalized_severe_hazard(day, hazard); + const LayerDescriptor* descriptor = find_layer(LayerProduct::Probability, day, normalized); + if (descriptor == nullptr || day > 3) { + return std::unexpected( + Error::invalid_request("probabilistic outlook requires tornado, hail, or wind on day 1 " + "or 2, or severe on day 3")); + } + std::string tag = normalized; + if (normalized == "tornado") { tag = "torn"; } - const std::string url = std::format("{}day{}probotlk_{}.nolyr.geojson", kStaticBase, day, tag); + const std::string filename = day == 3 ? "day3otlk_prob.nolyr.geojson" + : std::format("day{}otlk_{}.nolyr.geojson", day, tag); + const std::string url = std::string{kStaticBase} + filename; Result body = - body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + body_or_error(with_retry([&] { return impl_->http->get(url); }, impl_->retry)); if (!body) { return std::unexpected(body.error()); } try { - return parse_probabilistic(*body, day, hazard); + return parse_probabilistic(*body, day, normalized); } catch (const std::exception& e) { return std::unexpected(Error::parse(e.what())); } } Result StaticFeedClient::day4_8(std::int32_t day) { + if (day < 4 || day > 8) { + return std::unexpected( + Error::invalid_request("extended outlook day must be between 4 and 8")); + } const std::string url = std::format("{}day{}prob.nolyr.geojson", kStaticDay48Base, day); Result body = - body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + body_or_error(with_retry([&] { return impl_->http->get(url); }, impl_->retry)); if (!body) { return std::unexpected(body.error()); } @@ -103,37 +252,45 @@ Result StaticFeedClient::day4_8(std::int32_t day) { // ===================== ArcGISClient ===================== struct ArcGISClient::Impl { - HttpClient http; + std::shared_ptr http; RetryPolicy retry; - explicit Impl(ClientConfig cfg) : http(std::move(cfg)) {} + explicit Impl(ClientConfig cfg) : http(std::make_shared(std::move(cfg))) {} + explicit Impl(std::shared_ptr transport) + : http(usable_transport(std::move(transport))) {} /// One paged query. Concatenated raw page bodies are returned; the /// ArcGISPager advances on `exceededTransferLimit`. Result> paged(const char* base, std::int32_t layer, - const QueryParams& p) { + const QueryParams& p, + std::string_view out_spatial_reference = {}) { std::vector pages; ArcGISPager pager; while (pager.has_more()) { - std::string url = std::format("{}/{}/query?where={}&outFields={}&returnGeometry={}&f={}" - "&resultOffset={}&resultRecordCount={}", - base, layer, p.where, p.out_fields, - p.return_geometry ? "true" : "false", p.f, pager.offset(), - pager.page_size()); + std::string url = + std::format("{}/{}/query?where={}&outFields={}&returnGeometry={}&f={}" + "&resultOffset={}&resultRecordCount={}", + base, layer, percent_encode(p.where), percent_encode(p.out_fields), + p.return_geometry ? "true" : "false", percent_encode(p.f), + pager.offset(), pager.page_size()); if (!p.geometry.empty()) { - url += std::format("&geometry={}&geometryType={}&spatialRel={}", p.geometry, - p.geometry_type, p.spatial_rel); + url += std::format("&geometry={}&geometryType={}&spatialRel={}", + percent_encode(p.geometry), percent_encode(p.geometry_type), + percent_encode(p.spatial_rel)); + } + if (!out_spatial_reference.empty()) { + url += "&outSR=" + percent_encode(out_spatial_reference); } Result body = - body_or_error(with_retry([&] { return http.get(url); }, retry)); + body_or_error(with_retry([&] { return http->get(url); }, retry)); if (!body) { return std::unexpected(body.error()); } - // Detect the ArcGIS truncation flag without a full parse. - const bool exceeded = - body->find("\"exceededTransferLimit\":true") != std::string::npos || - body->find("\"exceededTransferLimit\": true") != std::string::npos; + const Result exceeded = inspect_arcgis_envelope(*body); + if (!exceeded) { + return std::unexpected(exceeded.error()); + } pages.push_back(std::move(*body)); - pager.advance(exceeded); + pager.advance(*exceeded); } return pages; } @@ -141,57 +298,25 @@ struct ArcGISClient::Impl { ArcGISClient::ArcGISClient(ClientConfig config) : impl_(std::make_unique(std::move(config))) {} +ArcGISClient::ArcGISClient(std::shared_ptr transport) + : impl_(std::make_unique(std::move(transport))) {} ArcGISClient::~ArcGISClient() = default; ArcGISClient::ArcGISClient(ArcGISClient&&) noexcept = default; ArcGISClient& ArcGISClient::operator=(ArcGISClient&&) noexcept = default; -namespace { - -// SPC_wx_outlks MapServer layer ids (documented layout). -std::int32_t cat_layer(std::int32_t day) { - if (day == 1) { - return 1; - } - if (day == 2) { - return 9; - } - return 17; // day 3 -} - -std::int32_t prob_layer(std::int32_t day, const std::string& hazard) { - if (day == 1) { - if (hazard == "tornado") { - return 3; - } - if (hazard == "hail") { - return 5; - } - return 7; // wind - } - return 15; // day 2 "any" -} - -std::int32_t fire_layer(std::int32_t day) { - // SPC_firewx: Day1 Outlook = layer 1, Day2 = 4, Day3 = 6 ... - if (day == 1) { - return 1; - } - if (day == 2) { - return 4; - } - return 6; -} - -} // namespace - Result ArcGISClient::query_categorical(std::int32_t day) { + const LayerDescriptor* descriptor = find_layer(LayerProduct::Categorical, day, ""); + if (descriptor == nullptr) { + return std::unexpected( + Error::invalid_request("categorical outlook day must be 1, 2, or 3")); + } QueryParams p; // Request GeoJSON so the VERBATIM parse_categorical (a GeoJSON-only // walker, parity-critical) consumes it unchanged. parse_esri_rings is // proven equivalent (test_arcgis) but the convective path must stay // byte-for-byte the spc-data parser, so we feed it its native shape. p.f = "geojson"; - Result> pages = impl_->paged(kArcGisOutlks, cat_layer(day), p); + Result> pages = impl_->paged(kArcGisOutlks, descriptor->id, p); if (!pages) { return std::unexpected(pages.error()); } @@ -210,20 +335,26 @@ Result ArcGISClient::query_categorical(std::int32_t d Result ArcGISClient::query_probabilistic(std::int32_t day, const std::string& hazard) { + const std::string normalized = normalized_severe_hazard(day, hazard); + const LayerDescriptor* descriptor = find_layer(LayerProduct::Probability, day, normalized); + if (descriptor == nullptr || day > 3) { + return std::unexpected( + Error::invalid_request("probabilistic outlook requires tornado, hail, or wind on day 1 " + "or 2, or severe on day 3")); + } QueryParams p; // GeoJSON for the verbatim parse_probabilistic (see query_categorical). p.f = "geojson"; - Result> pages = - impl_->paged(kArcGisOutlks, prob_layer(day, hazard), p); + Result> pages = impl_->paged(kArcGisOutlks, descriptor->id, p); if (!pages) { return std::unexpected(pages.error()); } ProbOutlookPayload out; out.day_offset = day; - out.hazard = hazard; + out.hazard = normalized; for (const std::string& body : *pages) { try { - ProbOutlookPayload pg = parse_probabilistic(body, day, hazard); + ProbOutlookPayload pg = parse_probabilistic(body, day, normalized); out.features.insert(out.features.end(), pg.features.begin(), pg.features.end()); } catch (const std::exception& e) { return std::unexpected(Error::parse(e.what())); @@ -232,46 +363,103 @@ Result ArcGISClient::query_probabilistic(std::int32_t day, return out; } -Result ArcGISClient::query_fire_weather(std::int32_t day) { - QueryParams p; - Result> pages = impl_->paged(kArcGisFirewx, fire_layer(day), p); +Result +ArcGISClient::query_conditional_intensity(std::int32_t day, const std::string& hazard) { + const std::string normalized = normalized_severe_hazard(day, hazard); + const LayerDescriptor* descriptor = + find_layer(LayerProduct::ConditionalIntensity, day, normalized); + if (descriptor == nullptr) { + return std::unexpected( + Error::invalid_request("conditional intensity requires tornado, hail, or wind on day 1 " + "or 2, or severe on day 3")); + } + QueryParams params; + Result> pages = impl_->paged(kArcGisOutlks, descriptor->id, params); if (!pages) { return std::unexpected(pages.error()); } - FireWeatherPayload out; - out.day = day; + ConditionalIntensityPayload output; + output.day = day; + output.hazard = normalized; for (const std::string& body : *pages) { try { - FireWeatherPayload pg = parse_fire_weather(body, day); - out.features.insert(out.features.end(), pg.features.begin(), pg.features.end()); + ConditionalIntensityPayload page = parse_conditional_intensity(body, day, normalized); + output.features.insert(output.features.end(), page.features.begin(), + page.features.end()); } catch (const std::exception& e) { return std::unexpected(Error::parse(e.what())); } } - return out; + return output; } -Result ArcGISClient::query_active_watches() { - // Active watches live in the hazards service; expose the raw escape - // hatch consumers can also use. Layer 1 of SPC_wx_outlks is categorical, - // so watches use the dedicated query_layer path with the watch parser. - QueryParams p; - Result> pages = query_layer(0, p); // placeholder layer +Result ArcGISClient::query_day4_8(std::int32_t day) { + const LayerDescriptor* descriptor = find_layer(LayerProduct::Probability, day, "severe"); + if (descriptor == nullptr || day < 4) { + return std::unexpected( + Error::invalid_request("extended outlook day must be between 4 and 8")); + } + QueryParams params; + Result> pages = impl_->paged(kArcGisOutlks, descriptor->id, params); if (!pages) { return std::unexpected(pages.error()); } - WatchPayload out; + Day48OutlookPayload output; + output.day = day; for (const std::string& body : *pages) { try { - WatchPayload pg = parse_watches(body); - out.watches.insert(out.watches.end(), pg.watches.begin(), pg.watches.end()); + Day48OutlookPayload page = parse_day4_8(body, day); + output.features.insert(output.features.end(), page.features.begin(), + page.features.end()); } catch (const std::exception& e) { return std::unexpected(Error::parse(e.what())); } } + return output; +} + +Result ArcGISClient::query_fire_weather(std::int32_t day) { + FireWeatherPayload out; + out.day = day; + const std::array subtypes = + day <= 2 ? std::array{"outlook", "dry-thunderstorm"} + : std::array{"dry-thunderstorm", "wind-low-humidity"}; + for (const std::string_view subtype : subtypes) { + const LayerDescriptor* descriptor = find_layer(LayerProduct::FireWeather, day, subtype); + if (descriptor == nullptr) { + return std::unexpected( + Error::invalid_request("fire-weather outlook day must be between 1 and 8")); + } + // Fire-weather layers default to Web Mercator. The public model uses + // longitude/latitude, so ask ArcGIS to transform geometry before parsing. + Result> pages = + impl_->paged(kArcGisFirewx, descriptor->id, {}, "4326"); + if (!pages) { + return std::unexpected(pages.error()); + } + for (const std::string& body : *pages) { + try { + FireWeatherLayer layer = FireWeatherLayer::WindLowHumidity; + if (subtype == "outlook") { + layer = FireWeatherLayer::Outlook; + } else if (subtype == "dry-thunderstorm") { + layer = FireWeatherLayer::DryThunderstorm; + } + FireWeatherPayload page = parse_fire_weather(body, day, layer); + out.features.insert(out.features.end(), page.features.begin(), page.features.end()); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } + } + } return out; } +Result ArcGISClient::query_active_watches() { + return std::unexpected(Error::invalid_request( + "NOAA WWA polygons omit SPC watch parameters; use ArchiveClient::watches()")); +} + Result ArcGISClient::query_active_md() { QueryParams p; Result> pages = impl_->paged(kArcGisMd, 0, p); @@ -302,18 +490,26 @@ Result ArcGISClient::query_storm_reports() { Result> ArcGISClient::query_layer(std::int32_t layer_id, const QueryParams& params) { - return impl_->paged(kArcGisOutlks, layer_id, params); + return query_layer(ArcGISService::Outlooks, layer_id, params); +} + +Result> +ArcGISClient::query_layer(ArcGISService service, std::int32_t layer_id, const QueryParams& params) { + if (layer_id < 0) { + return std::unexpected(Error::invalid_request("ArcGIS layer id must be non-negative")); + } + return impl_->paged(service_base(service), layer_id, params); } // ===================== ArchiveClient ===================== struct ArchiveClient::Impl { - HttpClient http; + std::shared_ptr http; RetryPolicy retry; RateLimiter limiter; explicit Impl(ClientConfig cfg) - : http(std::move(cfg)), retry([] { + : http(std::make_shared(std::move(cfg))), retry([] { // Conservative: IEM is a courtesy third party. RetryPolicy r; r.max_attempts = 4; @@ -321,10 +517,21 @@ struct ArchiveClient::Impl { return r; }()), limiter(RateLimiter::Config{}) {} + + explicit Impl(std::shared_ptr transport) + : http(usable_transport(std::move(transport))), retry([] { + RetryPolicy policy; + policy.max_attempts = 4; + policy.initial_delay = std::chrono::milliseconds{500}; + return policy; + }()), + limiter(RateLimiter::Config{}) {} }; ArchiveClient::ArchiveClient(ClientConfig config) : impl_(std::make_unique(std::move(config))) {} +ArchiveClient::ArchiveClient(std::shared_ptr transport) + : impl_(std::make_unique(std::move(transport))) {} ArchiveClient::~ArchiveClient() = default; ArchiveClient::ArchiveClient(ArchiveClient&&) noexcept = default; ArchiveClient& ArchiveClient::operator=(ArchiveClient&&) noexcept = default; @@ -338,7 +545,7 @@ Result ArchiveClient::watches(const std::string& ts) { url += std::format("?ts={}", ts); } Result body = - body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + body_or_error(with_retry([&] { return impl_->http->get(url); }, impl_->retry)); if (!body) { return std::unexpected(body.error()); } @@ -349,6 +556,7 @@ Result ArchiveClient::watches(const std::string& ts) { } } +// NOLINTBEGIN(bugprone-easily-swappable-parameters) Result ArchiveClient::storm_reports(const std::string& start_iso, const std::string& end_iso, const std::string& wfo) { @@ -361,7 +569,7 @@ Result ArchiveClient::storm_reports(const std::string& start url += std::format("&wfo={}", wfo); } Result body = - body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + body_or_error(with_retry([&] { return impl_->http->get(url); }, impl_->retry)); if (!body) { return std::unexpected(body.error()); } @@ -371,5 +579,6 @@ Result ArchiveClient::storm_reports(const std::string& start return std::unexpected(Error::parse(e.what())); } } +// NOLINTEND(bugprone-easily-swappable-parameters) } // namespace spc diff --git a/src/core/rate_limit.cpp b/src/core/rate_limit.cpp index fd9c789..a4bff49 100644 --- a/src/core/rate_limit.cpp +++ b/src/core/rate_limit.cpp @@ -8,7 +8,7 @@ namespace spc { RateLimiter::RateLimiter(Config config) - : config_(std::move(config)), tokens_(config_.initial_tokens), + : config_(config), tokens_(config_.initial_tokens), last_refill_(std::chrono::steady_clock::now()), day_start_(std::chrono::floor(std::chrono::system_clock::now())) {} diff --git a/src/http/client.cpp b/src/http/client.cpp index 5219535..aea9513 100644 --- a/src/http/client.cpp +++ b/src/http/client.cpp @@ -35,22 +35,49 @@ bool is_absolute_url(std::string_view path) { return path.starts_with("http://") || path.starts_with("https://"); } +class CurlRuntime { +public: + CurlRuntime() : status_(curl_global_init(CURL_GLOBAL_DEFAULT)) {} + ~CurlRuntime() { + if (status_ == CURLE_OK) { + curl_global_cleanup(); + } + } + + CurlRuntime(const CurlRuntime&) = delete; + CurlRuntime& operator=(const CurlRuntime&) = delete; + + [[nodiscard]] CURLcode status() const noexcept { return status_; } + +private: + CURLcode status_; +}; + +CurlRuntime& curl_runtime() { + // Function-local static initialization is thread-safe. Keep libcurl's + // process-wide state alive until normal process shutdown. + static CurlRuntime runtime; + return runtime; +} + } // namespace struct HttpClient::Impl { ClientConfig config; CURL* curl{nullptr}; + CURLcode global_status{CURLE_OK}; explicit Impl(ClientConfig cfg) : config(std::move(cfg)) { - curl_global_init(CURL_GLOBAL_DEFAULT); - curl = curl_easy_init(); + global_status = curl_runtime().status(); + if (global_status == CURLE_OK) { + curl = curl_easy_init(); + } } ~Impl() { if (curl != nullptr) { curl_easy_cleanup(curl); } - curl_global_cleanup(); } Impl(const Impl&) = delete; @@ -65,7 +92,10 @@ HttpClient& HttpClient::operator=(HttpClient&&) noexcept = default; Result HttpClient::get(std::string_view path) const { if (impl_->curl == nullptr) { - return std::unexpected(Error::network("curl_easy_init failed")); + const char* message = impl_->global_status == CURLE_OK + ? "curl_easy_init failed" + : curl_easy_strerror(impl_->global_status); + return std::unexpected(Error::network(message)); } CURL* curl = impl_->curl; diff --git a/src/models/common.cpp b/src/models/common.cpp index b562cb4..5776418 100644 --- a/src/models/common.cpp +++ b/src/models/common.cpp @@ -59,6 +59,18 @@ double json_number_or_numeric_string(const Json& obj, const char* key) { return 0.0; } +double normalized_probability(const Json& obj) { + double value = json_number_or_numeric_string(obj, "LABEL"); + if (value == 0.0) { + value = json_number_or_numeric_string(obj, "label"); + } + if (value == 0.0) { + value = json_number_or_numeric_string(obj, "dn"); + } + const double normalized = value > 1.0 ? value / 100.0 : value; + return normalized >= 0.0 && normalized <= 1.0 ? normalized : 0.0; +} + /// Convert SPC's compact "YYYYMMDDHHMM" timestamp to ISO 8601 /// "YYYY-MM-DDTHH:MM:00Z". Returns the input unchanged if the format doesn't /// match. diff --git a/src/models/convective.cpp b/src/models/convective.cpp index 5890b26..545a928 100644 --- a/src/models/convective.cpp +++ b/src/models/convective.cpp @@ -91,16 +91,7 @@ Day48OutlookPayload parse_day4_8(std::string_view body, std::int32_t day) { if (f.label.empty()) { f.label = detail::json_string(*props, "label"); } - double pct = detail::json_number_or_numeric_string(*props, "LABEL"); - if (pct == 0.0) { - pct = detail::json_number_or_numeric_string(*props, "label"); - } - if (pct == 0.0) { - pct = detail::json_number_or_numeric_string(*props, "dn"); - } - // LABEL "0.15" is already a fraction; dn "15" is a percent. Normalize - // to [0,1]: values > 1 are treated as percent. - f.probability = pct > 1.0 ? pct / 100.0 : pct; + f.probability = detail::normalized_probability(*props); f.issued_at = ts_any(*props, "ISSUE", "issue"); f.valid_from = ts_any(*props, "VALID", "valid"); f.valid_until = ts_any(*props, "EXPIRE", "expire"); diff --git a/src/models/fire_weather.cpp b/src/models/fire_weather.cpp index 7e0a04e..df05fc9 100644 --- a/src/models/fire_weather.cpp +++ b/src/models/fire_weather.cpp @@ -44,6 +44,58 @@ std::string ts_any(const Json& props, const char* upper, const char* lower) { return v; } +std::string label_any(const Json& props) { + std::string label = detail::json_string(props, "LABEL"); + if (label.empty()) { + label = detail::json_string(props, "label"); + } + return label; +} + +bool has_zero_dn(const Json& props) { + const Json* dn = detail::lookup(props, "dn"); + if (dn == nullptr) { + return false; + } + if (dn->is_number()) { + return dn->get() == 0.0; + } + if (!dn->is_string()) { + return false; + } + const std::string text = dn->get(); + std::size_t consumed = 0; + try { + return std::stod(text, &consumed) == 0.0 && consumed == text.size(); + } catch (...) { + return false; + } +} + +std::string label_from_dn(const Json& props, FireWeatherLayer layer) { + const double dn = detail::json_number_or_numeric_string(props, "dn"); + if (layer == FireWeatherLayer::Outlook) { + if (dn == 5.0) { + return "ELEV"; + } + if (dn == 8.0) { + return "CRIT"; + } + if (dn == 10.0) { + return "EXTM"; + } + } + if (layer == FireWeatherLayer::DryThunderstorm) { + if (dn == 5.0) { + return "IDRT"; + } + if (dn == 8.0) { + return "SDRT"; + } + } + return {}; +} + } // namespace std::uint8_t fire_severity_from_label(std::string_view label) noexcept { @@ -61,6 +113,11 @@ std::uint8_t fire_severity_from_label(std::string_view label) noexcept { } FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day) { + return parse_fire_weather(body, day, FireWeatherLayer::Outlook); +} + +FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day, + FireWeatherLayer layer) { const Json root = parse_root_or_throw(body); FireWeatherPayload payload; payload.day = day; @@ -74,18 +131,25 @@ FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day) { if (props == nullptr || geometry == nullptr) { continue; } + const std::string published_label = label_any(*props); + if (has_zero_dn(*props) || published_label == "Probability Too Low") { + continue; + } FireWeatherFeature f; f.day = day; - f.label = detail::json_string(*props, "LABEL"); - if (f.label.empty()) { - f.label = detail::json_string(*props, "label"); + f.layer = layer; + f.label = published_label; + if (day >= 3) { + f.probability = detail::normalized_probability(*props); + } else if (f.label.empty()) { + f.label = label_from_dn(*props, layer); } f.severity = fire_severity_from_label(f.label); f.issued_at = ts_any(*props, "ISSUE", "issue"); f.valid_from = ts_any(*props, "VALID", "valid"); f.valid_until = ts_any(*props, "EXPIRE", "expire"); f.rings = rings_any(*geometry); - if (!f.rings.empty()) { + if (!f.rings.empty() && (day <= 2 || f.probability > 0.0)) { payload.features.push_back(std::move(f)); } } diff --git a/src/models/outlook.cpp b/src/models/outlook.cpp index d8bfd3b..d8c3bb4 100644 --- a/src/models/outlook.cpp +++ b/src/models/outlook.cpp @@ -100,8 +100,9 @@ CategoricalOutlookPayload parse_categorical(std::string_view body, std::int32_t return payload; } -ProbOutlookPayload parse_probabilistic(std::string_view body, std::int32_t day_offset, - std::string hazard) { +ProbOutlookPayload parse_probabilistic( + std::string_view body, std::int32_t day_offset, + std::string hazard) { // NOLINT(performance-unnecessary-value-param): parity signature const Json root = parse_root_or_throw(body); ProbOutlookPayload payload; payload.day_offset = day_offset; @@ -126,14 +127,7 @@ ProbOutlookPayload parse_probabilistic(std::string_view body, std::int32_t day_o // values > 1 are percents to divide by 100; fractions pass through. A // bare `/ 100.0` here silently produced 100x-too-small probabilities // (0.02 -> 0.0002) for every real feed. - double pct = detail::json_number_or_numeric_string(*props, "LABEL"); - if (pct == 0.0) { - pct = detail::json_number_or_numeric_string(*props, "label"); - } - if (pct == 0.0) { - pct = detail::json_number_or_numeric_string(*props, "dn"); - } - pf.probability = pct > 1.0 ? pct / 100.0 : pct; + pf.probability = detail::normalized_probability(*props); pf.issued_at = detail::as_spc_ts(*props, "ISSUE"); if (pf.issued_at.empty()) { pf.issued_at = detail::as_spc_ts(*props, "issue"); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d2478fb..db765f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,11 +1,12 @@ add_executable(spc_tests + test_client.cpp test_geometry.cpp test_parser.cpp test_corpus.cpp test_arcgis.cpp ) target_link_libraries(spc_tests PRIVATE - spc_core spc_models spc_api + spc_api GTest::gtest_main ) target_compile_definitions(spc_tests PRIVATE @@ -13,4 +14,4 @@ target_compile_definitions(spc_tests PRIVATE ) target_compile_options(spc_tests PRIVATE -Wall -Wextra -Wpedantic) include(GoogleTest) -gtest_discover_tests(spc_tests) +gtest_discover_tests(spc_tests DISCOVERY_TIMEOUT 30) diff --git a/tests/consumer/fetchcontent/CMakeLists.txt b/tests/consumer/fetchcontent/CMakeLists.txt new file mode 100644 index 0000000..7a2349f --- /dev/null +++ b/tests/consumer/fetchcontent/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.20) +project(spc-fetchcontent-consumer LANGUAGES CXX) + +include(FetchContent) +set(SPC_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(SPC_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +FetchContent_Declare(spc_cpp SOURCE_DIR "${SPC_SOURCE_DIR}") +FetchContent_MakeAvailable(spc_cpp) + +add_executable(spc_fetchcontent_consumer ../main.cpp) +target_link_libraries(spc_fetchcontent_consumer PRIVATE spc::spc) +target_compile_features(spc_fetchcontent_consumer PRIVATE cxx_std_23) diff --git a/tests/consumer/installed/CMakeLists.txt b/tests/consumer/installed/CMakeLists.txt new file mode 100644 index 0000000..fb1c720 --- /dev/null +++ b/tests/consumer/installed/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.20) +project(spc-installed-consumer LANGUAGES CXX) + +find_package(spc 0.2 REQUIRED) + +add_executable(spc_installed_consumer ../main.cpp) +target_link_libraries(spc_installed_consumer PRIVATE spc::spc) +target_compile_features(spc_installed_consumer PRIVATE cxx_std_23) diff --git a/tests/consumer/main.cpp b/tests/consumer/main.cpp new file mode 100644 index 0000000..c40e2f9 --- /dev/null +++ b/tests/consumer/main.cpp @@ -0,0 +1,16 @@ +#include "spc/spc.hpp" + +#include + +int main() { + spc::ArcGISPager pager{100}; + const spc::ClientConfig config; + spc::ArcGISClient arcgis; + spc::StaticFeedClient feeds; + const spc::Result bad_day = arcgis.query_day4_8(3); + const spc::Result bad_category = feeds.day_categorical(4); + const bool passed = pager.page_size() == 100 && config.verify_ssl && !bad_day && + bad_day.error().code == spc::ErrorCode::InvalidRequest && !bad_category && + bad_category.error().code == spc::ErrorCode::InvalidRequest; + return passed ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index c613890..ea5af09 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -1,10 +1,10 @@ # SPC characterization fixtures -All payloads here were captured **live** on 2026-05-17 from the real upstream -SPC sources (no synthetic/invented data). They pin the byte-identity gate for -the spc-cpp extraction (Workstream C) and double as the spc-cpp parity corpus. +Most payloads here were captured live on 2026-05-17. They pin the parser parity +corpus. Files ending in `.synthetic.json` cover states that had no live features +at capture time. -## Static `www.spc.noaa.gov` GeoJSON (the 7-feed fallback path) +## Static `www.spc.noaa.gov` GeoJSON - `day1otlk_cat.nolyr.geojson`, `day2otlk_cat.nolyr.geojson`, `day3otlk_cat.nolyr.geojson` — live day 1/2/3 categorical outlooks @@ -14,7 +14,7 @@ the spc-cpp extraction (Workstream C) and double as the spc-cpp parity corpus. - `spc_404_no_active_outlook.html` — the literal HTML body SPC returns with HTTP 404 when no active outlook exists (e.g. day-1 probabilistic overnight). Characterizes `main.cpp`'s 404 -> clear-rows path. The static - `day{1,2}probotlk_*.nolyr.geojson` feeds were 404 at capture time (the + `day{1,2}otlk_*.nolyr.geojson` feeds were 404 at capture time (the documented normal overnight state) so the probabilistic GeoJSON corpus comes from the ArcGIS `f=geojson` mirror of the same upstream product (below). @@ -25,16 +25,24 @@ test (`*.esri.json` = `f=json` Esri rings; `*.geojson` = `f=geojson`): - `arcgis_day{1,2,3}_categorical.{esri.json,geojson}` - `arcgis_day1_prob_{tornado,hail,wind}.{esri.json,geojson}`, - `arcgis_day2_prob_any.{esri.json,geojson}` — active probabilistic isopleths + `arcgis_day2_prob_wind.{esri.json,geojson}` — active probabilistic isopleths (`dn` numeric percent + `label` as `"0.02"`; lowercase `valid`/`expire`). - `arcgis_day1_torn_conditional_intensity.esri.json` — net-new conditional intensity (`label` `"CIG1"`). -- `arcgis_day4_8_convective.esri.json` — empty FeatureSet (0 features) at - capture time; pins the empty-collection path. +- `arcgis_day4_8_nonempty.synthetic.json` pins a nonempty day 4 response. - `arcgis_day{1,2}_fire_weather.esri.json` — `SPC_firewx` MapServer. - `arcgis_mesoscale_discussion.esri.json` — `spc_mesoscale_discussion` MapServer (raw `name`/`folderpath`/`popupinfo`; narrative NOT parsed). +## Layer metadata + +`arcgis_layers_2026-09-03.json` records NOAA ArcGIS 11.3 layer IDs, names, and +parents from the three service metadata URLs stored in the file. Run +`python3 tools/verify_arcgis_metadata.py` to compare it with the live services. + +`SHA256SUMS` covers every payload and metadata fixture. Run `make +fixtures-check` after checkout and whenever a fixture changes. + ## IEM archive (`mesonet.agron.iastate.edu`, best-effort backfill) - `iem_storm_reports.json` — LSR GeoJSON FeatureCollection (Point geometry). diff --git a/tests/fixtures/SHA256SUMS b/tests/fixtures/SHA256SUMS new file mode 100644 index 0000000..a6faa8d --- /dev/null +++ b/tests/fixtures/SHA256SUMS @@ -0,0 +1,28 @@ +8ab543681d5fcb3f22aaa4a9700f8b455f8461d24ba151916491bd0e0c5390de tests/fixtures/arcgis_day1_categorical.esri.json +1dbe6e812fd6d9c3742ba4de9489b4301885b6c32ca9e117375c8f2fdb343446 tests/fixtures/arcgis_day1_categorical.geojson +fc981b232e65da7c4ceabdf21633f21d208eb1989064f692267e113b3eb56ce8 tests/fixtures/arcgis_day1_fire_weather.esri.json +d10bb4b36c1e092ba6744840e7065f2027e733fab2ed6e4f7f2bf73389378ee0 tests/fixtures/arcgis_day1_prob_hail.esri.json +1c385640822074ee720c007312083618df9e2e924578dca91ee72615ff176341 tests/fixtures/arcgis_day1_prob_hail.geojson +f99e132e4426476b9467fe6b9512bbf35a73af670e8b7e1d5ed8d8e8ce20d6db tests/fixtures/arcgis_day1_prob_tornado.esri.json +0f9093000bc1526a9b78a9bde859776c1be420b0fb15383bb321ae6c89d92aa7 tests/fixtures/arcgis_day1_prob_tornado.geojson +4b345605cdfd43705686d716ad1e2599aae4877862acff9ac520c9c81815c1fd tests/fixtures/arcgis_day1_prob_wind.esri.json +49e1d9b89db48ac9a1674ed6f57f429e6401e9603ab2829c74eb73a5436bbaf6 tests/fixtures/arcgis_day1_prob_wind.geojson +611038421eeb1e8181cc004a9af274418c122666ddc9e0dbc5782cc3326f10c9 tests/fixtures/arcgis_day1_torn_conditional_intensity.esri.json +f6c8efc1cc797e0ebe31f965a0e97fb5e9c0871ef14517b85ad8156a5308f6eb tests/fixtures/arcgis_day2_categorical.esri.json +724e3a890d2e9ab29c212248675437d6547a9f9e675689bd9d7f2cadc48c0b7c tests/fixtures/arcgis_day2_categorical.geojson +4bf0f7a57c967b7c993d28c1e82c0fb7cc0a48175982f345451f5e8a711aa735 tests/fixtures/arcgis_day2_fire_weather.esri.json +079b23548584c551644f0a2e5462216cbfec5cf47147ddf5679a8718f40ed1b9 tests/fixtures/arcgis_day2_prob_wind.esri.json +684ea79c14635cb8e2df76aee22ea8a8e8a6148cf0a4d89e7892efaa2a7cc823 tests/fixtures/arcgis_day2_prob_wind.geojson +c59171a082d59c835f14368e747f25cf80db5961267a64f4138ed592492517a1 tests/fixtures/arcgis_day3_categorical.esri.json +c41e57e732e97c5b7f96a13b21637d747e811f9964b4ba4ed339406568ca9f37 tests/fixtures/arcgis_day3_categorical.geojson +4c09f373cbf83db55368e57c0a82269c34f8620fdd0d245150288a89677536bb tests/fixtures/arcgis_day4_8_nonempty.synthetic.json +189a75a700176a461028f9c5a18583a54c60e7df72105e2409fdb6199c98049f tests/fixtures/arcgis_layers_2026-09-03.json +57d0569b81a112fa9a6f010ffa5e1ee8f6fc97b11b31d8f9c5fe3307c811a6e6 tests/fixtures/arcgis_mesoscale_discussion.esri.json +78aebb102a8c4ace9dbf41a587c01985dd448fde32352d7fca8a95e0db5f7ad5 tests/fixtures/day1otlk_cat.nolyr.geojson +4fb19332bd7076455d03e0411f8487ad79d9d44aef0daea87ed94822f8c1ebff tests/fixtures/day2otlk_cat.nolyr.geojson +ec516d76dbb9c81ed359be2c1b065207d336265f219f19a047feb1ec1284d261 tests/fixtures/day3otlk_cat.nolyr.geojson +943b95f22d07b2a2e2d3bb11f7e13213119454a4ad5bd8bf16f24fad566c4635 tests/fixtures/day4prob.nolyr.geojson +d8a9a7f3178911d1f4dc11e4a552d31dff5985ad7aee074d045d4ceba0c0dd4a tests/fixtures/iem_spc_watch.json +1dcd4adc8c8f82262484f9b4baf58578e43b21fc8413dcae72eb57d5bfb21d98 tests/fixtures/iem_spcoutlook_torn.json +ebd0aa7a6f16c441b66d4aae551e9fc88d9192537fad544b376e21fee490f02b tests/fixtures/iem_storm_reports.json +88a7fbd527aa941c236ffb45beb933f4c1d146a6a7ff0888117aa3abf13ad508 tests/fixtures/spc_404_no_active_outlook.html diff --git a/tests/fixtures/arcgis_day2_prob_any.esri.json b/tests/fixtures/arcgis_day2_prob_wind.esri.json similarity index 100% rename from tests/fixtures/arcgis_day2_prob_any.esri.json rename to tests/fixtures/arcgis_day2_prob_wind.esri.json diff --git a/tests/fixtures/arcgis_day2_prob_any.geojson b/tests/fixtures/arcgis_day2_prob_wind.geojson similarity index 100% rename from tests/fixtures/arcgis_day2_prob_any.geojson rename to tests/fixtures/arcgis_day2_prob_wind.geojson diff --git a/tests/fixtures/arcgis_day4_8_convective.esri.json b/tests/fixtures/arcgis_day4_8_convective.esri.json deleted file mode 100644 index d22dece..0000000 --- a/tests/fixtures/arcgis_day4_8_convective.esri.json +++ /dev/null @@ -1 +0,0 @@ -{"error":{"code":400,"message":"Invalid or missing input parameters.","details":[]}} \ No newline at end of file diff --git a/tests/fixtures/arcgis_day4_8_nonempty.synthetic.json b/tests/fixtures/arcgis_day4_8_nonempty.synthetic.json new file mode 100644 index 0000000..3588dd2 --- /dev/null +++ b/tests/fixtures/arcgis_day4_8_nonempty.synthetic.json @@ -0,0 +1,17 @@ +{ + "features": [ + { + "attributes": { + "dn": 15, + "valid": "202609031200", + "expire": "202609071200" + }, + "geometry": { + "rings": [ + [[-100.0, 40.0], [-99.0, 40.0], [-99.0, 39.0], [-100.0, 39.0], [-100.0, 40.0]] + ] + } + } + ], + "exceededTransferLimit": false +} diff --git a/tests/fixtures/arcgis_layers_2026-09-03.json b/tests/fixtures/arcgis_layers_2026-09-03.json new file mode 100644 index 0000000..d3d671c --- /dev/null +++ b/tests/fixtures/arcgis_layers_2026-09-03.json @@ -0,0 +1,80 @@ +{ + "captured_at": "2026-09-03", + "services": [ + { + "name": "outlooks", + "url": "https://mapservices.weather.noaa.gov/vector/rest/services/outlooks/SPC_wx_outlks/MapServer?f=pjson", + "currentVersion": 11.3, + "queryable": [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 21, 22, 23, 24, 25], + "layers": [ + [0, "Day 1 Convective Outlook", -1], + [1, "Day 1 Categorical Outlook", 0], + [2, "Day 1 Tornado Conditional Intensity", 0], + [3, "Day 1 Probabilistic Tornado Outlook", 0], + [4, "Day 1 Hail Conditional Intensity", 0], + [5, "Day 1 Probabilistic Hail Outlook", 0], + [6, "Day 1 Wind Conditional Intensity", 0], + [7, "Day 1 Probabilistic Wind Outlook", 0], + [8, "Day 2 Convective Outlook", -1], + [9, "Day 2 Categorical Outlook", 8], + [10, "Day 2 Tornado Conditional Intensity", 8], + [11, "Day 2 Probabilistic Tornado Outlook", 8], + [12, "Day 2 Hail Conditional Intensity", 8], + [13, "Day 2 Probabilistic Hail Outlook", 8], + [14, "Day 2 Wind Conditional Intensity", 8], + [15, "Day 2 Probabilistic Wind Outlook", 8], + [16, "Day 3 Convective Outlook", -1], + [17, "Day 3 Categorical Outlook", 16], + [18, "Day 3 Severe Conditional Intensity", 16], + [19, "Day 3 Probabilistic Outlook", 16], + [20, "Day 4 - 8 Convective Outlook", -1], + [21, "Day 4 Probabilistic Outlook", 20], + [22, "Day 5 Probabilistic Outlook", 20], + [23, "Day 6 Probabilistic Outlook", 20], + [24, "Day 7 Probabilistic Outlook", 20], + [25, "Day 8 Probabilistic Outlook", 20] + ] + }, + { + "name": "fire-weather", + "url": "https://mapservices.weather.noaa.gov/vector/rest/services/fire_weather/SPC_firewx/MapServer?f=pjson", + "currentVersion": 11.3, + "queryable": [1, 2, 4, 5, 7, 8, 10, 11, 13, 14, 16, 17, 19, 20, 22, 23], + "layers": [ + [0, "Day 1 Fire Weather Outlook", -1], + [1, "Day 1 Outlook", 0], + [2, "Day 1 Outlook Dry Thunderstorm", 0], + [3, "Day 2 Fire Weather Outlook", -1], + [4, "Day 2 Outlook", 3], + [5, "Day 2 Outlook Dry Thunderstorm", 3], + [6, "Day 3 Fire Weather Outlook", -1], + [7, "Day 3 Dry Thunderstorm", 6], + [8, "Day 3 Winds and Low Humidity", 6], + [9, "Day 4 Fire Weather Outlook", -1], + [10, "Day 4 Dry Thunderstorm", 9], + [11, "Day 4 Winds and Low Humidity", 9], + [12, "Day 5 Fire Weather Outlook", -1], + [13, "Day 5 Dry Thunderstorm", 12], + [14, "Day 5 Winds and Low Humidity", 12], + [15, "Day 6 Fire Weather Outlook", -1], + [16, "Day 6 Dry Thunderstorm", 15], + [17, "Day 6 Winds and Low Humidity", 15], + [18, "Day 7 Fire Weather Outlook", -1], + [19, "Day 7 Dry Thunderstorm", 18], + [20, "Day 7 Winds and Low Humidity", 18], + [21, "Day 8 Fire Weather Outlook", -1], + [22, "Day 8 Dry Thunderstorm", 21], + [23, "Day 8 Winds and Low Humidity", 21] + ] + }, + { + "name": "mesoscale-discussions", + "url": "https://mapservices.weather.noaa.gov/vector/rest/services/outlooks/spc_mesoscale_discussion/MapServer?f=pjson", + "currentVersion": 11.3, + "queryable": [0], + "layers": [ + [0, "SPC Mesoscale Discussion", -1] + ] + } + ] +} diff --git a/tests/test_arcgis.cpp b/tests/test_arcgis.cpp index 30e2a3c..db9490f 100644 --- a/tests/test_arcgis.cpp +++ b/tests/test_arcgis.cpp @@ -107,7 +107,6 @@ TEST(ArcGISParity, EsriRingsMatchGeoJsonForDay1Categorical) { constexpr double kSkip = 5.0e-5; std::size_t esri_bands = 0; - std::size_t probe_total = 0; std::size_t probe_agree = 0; std::size_t probe_compared = 0; for (const glz::generic& feat : feats->get_array()) { @@ -158,7 +157,6 @@ TEST(ArcGISParity, EsriRingsMatchGeoJsonForDay1Categorical) { for (int iy = 0; iy <= kN; ++iy) { const double px = minx + (maxx - minx) * (static_cast(ix) / kN); const double py = miny + (maxy - miny) * (static_cast(iy) / kN); - ++probe_total; // Skip the fuzzy ~1 m boundary band of EITHER encoding. if (dist_to_boundary(px, py, g->rings) < kSkip || dist_to_boundary(px, py, esri_rings) < kSkip) { @@ -205,6 +203,15 @@ TEST(NetNewModels, Day48ParsesStaticGeoJson) { } } +TEST(NetNewModels, Day48ParsesNonemptySyntheticArcGisResponse) { + const Day48OutlookPayload payload = + parse_day4_8(slurp("arcgis_day4_8_nonempty.synthetic.json"), 4); + ASSERT_EQ(payload.features.size(), 1u); + EXPECT_EQ(payload.features[0].day, 4); + EXPECT_DOUBLE_EQ(payload.features[0].probability, 0.15); + EXPECT_FALSE(payload.features[0].rings.empty()); +} + TEST(NetNewModels, ConditionalIntensityCigMapper) { EXPECT_EQ(cig_severity_from_label("CIG1"), 1); EXPECT_EQ(cig_severity_from_label("CIG2"), 2); @@ -225,12 +232,70 @@ TEST(NetNewModels, FireWeatherOwnSeverityMapper) { EXPECT_EQ(fire_severity_from_label("SLGT"), 0); // not categorical const FireWeatherPayload p = parse_fire_weather(slurp("arcgis_day1_fire_weather.esri.json"), 1); EXPECT_EQ(p.day, 1); - EXPECT_GT(p.features.size(), 0u); + ASSERT_EQ(p.features.size(), 3u); + EXPECT_EQ(p.features[0].label, "ELEV"); + EXPECT_EQ(p.features[0].severity, 1); + EXPECT_EQ(p.features[1].label, "CRIT"); + EXPECT_EQ(p.features[1].severity, 2); + EXPECT_EQ(p.features[2].label, "EXTM"); + EXPECT_EQ(p.features[2].severity, 3); for (const FireWeatherFeature& f : p.features) { EXPECT_FALSE(f.rings.empty()); } } +TEST(NetNewModels, FireWeatherDryThunderstormCodesUseTheirOwnLabels) { + const std::string body = R"({"features":[ + {"attributes":{"dn":5},"geometry":{"rings":[[[0,1],[1,1],[1,0],[0,0],[0,1]]]}}, + {"attributes":{"dn":8},"geometry":{"rings":[[[2,1],[3,1],[3,0],[2,0],[2,1]]]}} + ]})"; + const FireWeatherPayload payload = + parse_fire_weather(body, 1, FireWeatherLayer::DryThunderstorm); + ASSERT_EQ(payload.features.size(), 2u); + EXPECT_EQ(payload.features[0].label, "IDRT"); + EXPECT_EQ(payload.features[0].layer, FireWeatherLayer::DryThunderstorm); + EXPECT_EQ(payload.features[0].severity, 0); + EXPECT_EQ(payload.features[1].label, "SDRT"); + EXPECT_EQ(payload.features[1].layer, FireWeatherLayer::DryThunderstorm); + EXPECT_EQ(payload.features[1].severity, 0); +} + +TEST(NetNewModels, FireWeatherOmitsNoRiskSentinelPolygons) { + const std::string body = R"({"features":[ + {"attributes":{"dn":0},"geometry":{"rings":[[[0,1],[1,1],[1,0],[0,0],[0,1]]]}}, + {"attributes":{"LABEL":"Probability Too Low"},"geometry":{"rings":[[[2,1],[3,1],[3,0],[2,0],[2,1]]]}}, + {"attributes":{"dn":5},"geometry":{"rings":[[[4,1],[5,1],[5,0],[4,0],[4,1]]]}} + ]})"; + + const FireWeatherPayload payload = + parse_fire_weather(body, 4, FireWeatherLayer::DryThunderstorm); + + ASSERT_EQ(payload.features.size(), 1u); + EXPECT_TRUE(payload.features[0].label.empty()); + EXPECT_DOUBLE_EQ(payload.features[0].probability, 0.05); + EXPECT_EQ(payload.features[0].severity, 0); +} + +TEST(NetNewModels, ExtendedFireWeatherNormalizesPublishedProbabilities) { + const std::string body = R"({"features":[ + {"attributes":{"label":"0.40","dn":40},"geometry":{"rings":[[[0,1],[1,1],[1,0],[0,0],[0,1]]]}}, + {"attributes":{"dn":"15"},"geometry":{"rings":[[[2,1],[3,1],[3,0],[2,0],[2,1]]]}}, + {"attributes":{"dn":150},"geometry":{"rings":[[[4,1],[5,1],[5,0],[4,0],[4,1]]]}}, + {"attributes":{"dn":-5},"geometry":{"rings":[[[6,1],[7,1],[7,0],[6,0],[6,1]]]}} + ]})"; + + const FireWeatherPayload payload = + parse_fire_weather(body, 5, FireWeatherLayer::WindLowHumidity); + + ASSERT_EQ(payload.features.size(), 2u); + EXPECT_EQ(payload.features[0].label, "0.40"); + EXPECT_DOUBLE_EQ(payload.features[0].probability, 0.40); + EXPECT_EQ(payload.features[0].severity, 0); + EXPECT_TRUE(payload.features[1].label.empty()); + EXPECT_DOUBLE_EQ(payload.features[1].probability, 0.15); + EXPECT_EQ(payload.features[1].severity, 0); +} + TEST(NetNewModels, MesoscaleRawTextOnly) { const MesoscalePayload p = parse_mesoscale_discussions(slurp("arcgis_mesoscale_discussion.esri.json")); diff --git a/tests/test_client.cpp b/tests/test_client.cpp new file mode 100644 index 0000000..e7242b9 --- /dev/null +++ b/tests/test_client.cpp @@ -0,0 +1,280 @@ +#include "spc/api.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace spc; + +class RecordingTransport final : public HttpTransport { +public: + mutable std::vector requests; + std::vector responses; + + [[nodiscard]] Result get(std::string_view path) const override { + requests.emplace_back(path); + if (requests.size() > responses.size()) { + return std::unexpected(Error::network("no response queued")); + } + return responses[requests.size() - 1]; + } +}; + +HttpResponse empty_feature_collection() { + return {200, R"({"type":"FeatureCollection","features":[],"exceededTransferLimit":false})", {}}; +} + +#if defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif +Result call_deprecated_active_watches(ArcGISClient& client) { + return client.query_active_watches(); +} +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + +TEST(StaticFeedClientRouting, UsesPublishedProbabilisticFilenames) { + std::shared_ptr transport = std::make_shared(); + transport->responses.assign(3, empty_feature_collection()); + StaticFeedClient client{transport}; + + ASSERT_TRUE(client.day_probabilistic(1, "tornado")); + ASSERT_TRUE(client.day_probabilistic(2, "hail")); + ASSERT_TRUE(client.day_probabilistic(3, "severe")); + + ASSERT_EQ(transport->requests.size(), 3u); + EXPECT_EQ(transport->requests[0], + "https://www.spc.noaa.gov/products/outlook/day1otlk_torn.nolyr.geojson"); + EXPECT_EQ(transport->requests[1], + "https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson"); + EXPECT_EQ(transport->requests[2], + "https://www.spc.noaa.gov/products/outlook/day3otlk_prob.nolyr.geojson"); +} + +TEST(StaticFeedClientRouting, RejectsUnsupportedProductsBeforeNetworkAccess) { + std::shared_ptr transport = std::make_shared(); + StaticFeedClient client{transport}; + + const Result categorical = client.day_categorical(4); + const Result probabilistic = client.day_probabilistic(2, "any"); + const Result extended = client.day4_8(3); + + ASSERT_FALSE(categorical); + ASSERT_FALSE(probabilistic); + ASSERT_FALSE(extended); + EXPECT_EQ(categorical.error().code, ErrorCode::InvalidRequest); + EXPECT_EQ(probabilistic.error().code, ErrorCode::InvalidRequest); + EXPECT_EQ(extended.error().code, ErrorCode::InvalidRequest); + EXPECT_TRUE(transport->requests.empty()); +} + +TEST(ArcGISClientRouting, UsesEveryPublishedConvectiveLayerType) { + std::shared_ptr transport = std::make_shared(); + transport->responses.assign(4, empty_feature_collection()); + ArcGISClient client{transport}; + + ASSERT_TRUE(client.query_categorical(3)); + ASSERT_TRUE(client.query_probabilistic(2, "tornado")); + ASSERT_TRUE(client.query_conditional_intensity(3, "severe")); + ASSERT_TRUE(client.query_day4_8(8)); + + ASSERT_EQ(transport->requests.size(), 4u); + EXPECT_NE(transport->requests[0].find("/17/query?"), std::string::npos); + EXPECT_NE(transport->requests[1].find("/11/query?"), std::string::npos); + EXPECT_NE(transport->requests[2].find("/18/query?"), std::string::npos); + EXPECT_NE(transport->requests[3].find("/25/query?"), std::string::npos); +} + +TEST(ArcGISClientRouting, CoversThePublishedOutlookLayerTable) { + struct Case { + std::int32_t day; + std::string hazard; + std::int32_t layer; + }; + const std::vector probability_cases = { + {1, "tornado", 3}, {1, "hail", 5}, {1, "wind", 7}, {2, "tornado", 11}, + {2, "hail", 13}, {2, "wind", 15}, {3, "severe", 19}, + }; + const std::vector conditional_cases = { + {1, "tornado", 2}, {1, "hail", 4}, {1, "wind", 6}, {2, "tornado", 10}, + {2, "hail", 12}, {2, "wind", 14}, {3, "severe", 18}, + }; + std::shared_ptr transport = std::make_shared(); + transport->responses.assign(22, empty_feature_collection()); + ArcGISClient client{transport}; + + for (std::int32_t day = 1; day <= 3; ++day) { + ASSERT_TRUE(client.query_categorical(day)); + } + for (const Case& test_case : probability_cases) { + ASSERT_TRUE(client.query_probabilistic(test_case.day, test_case.hazard)); + } + for (const Case& test_case : conditional_cases) { + ASSERT_TRUE(client.query_conditional_intensity(test_case.day, test_case.hazard)); + } + for (std::int32_t day = 4; day <= 8; ++day) { + ASSERT_TRUE(client.query_day4_8(day)); + } + + const std::vector expected_layers = { + 1, 9, 17, 3, 5, 7, 11, 13, 15, 19, 2, 4, 6, 10, 12, 14, 18, 21, 22, 23, 24, 25, + }; + ASSERT_EQ(transport->requests.size(), expected_layers.size()); + for (std::size_t index = 0; index < expected_layers.size(); ++index) { + EXPECT_NE(transport->requests[index].find("/" + std::to_string(expected_layers[index]) + + "/query?"), + std::string::npos); + } +} + +TEST(ArcGISClientRouting, CombinesBothPublishedFireWeatherLayers) { + std::shared_ptr transport = std::make_shared(); + transport->responses.assign(2, empty_feature_collection()); + ArcGISClient client{transport}; + + ASSERT_TRUE(client.query_fire_weather(3)); + + ASSERT_EQ(transport->requests.size(), 2u); + EXPECT_NE(transport->requests[0].find("/7/query?"), std::string::npos); + EXPECT_NE(transport->requests[1].find("/8/query?"), std::string::npos); + EXPECT_NE(transport->requests[0].find("outSR=4326"), std::string::npos); + EXPECT_NE(transport->requests[1].find("outSR=4326"), std::string::npos); +} + +TEST(ArcGISClientRouting, PreservesLabelsFromBothDayOneFireProducts) { + std::shared_ptr transport = std::make_shared(); + const std::string body = R"({"features":[ + {"attributes":{"dn":5},"geometry":{"rings":[[[0,1],[1,1],[1,0],[0,0],[0,1]]]}} + ],"exceededTransferLimit":false})"; + transport->responses = {{200, body, {}}, {200, body, {}}}; + ArcGISClient client{transport}; + + const Result result = client.query_fire_weather(1); + + ASSERT_TRUE(result); + ASSERT_EQ(result->features.size(), 2u); + EXPECT_EQ(result->features[0].label, "ELEV"); + EXPECT_EQ(result->features[0].layer, FireWeatherLayer::Outlook); + EXPECT_EQ(result->features[1].label, "IDRT"); + EXPECT_EQ(result->features[1].layer, FireWeatherLayer::DryThunderstorm); +} + +TEST(ArcGISClientRouting, CoversEveryPublishedFireWeatherFeatureLayer) { + std::shared_ptr transport = std::make_shared(); + transport->responses.assign(16, empty_feature_collection()); + ArcGISClient client{transport}; + + for (std::int32_t day = 1; day <= 8; ++day) { + ASSERT_TRUE(client.query_fire_weather(day)); + } + + const std::vector expected_layers = { + 1, 2, 4, 5, 7, 8, 10, 11, 13, 14, 16, 17, 19, 20, 22, 23, + }; + ASSERT_EQ(transport->requests.size(), expected_layers.size()); + for (std::size_t index = 0; index < expected_layers.size(); ++index) { + EXPECT_NE(transport->requests[index].find("/" + std::to_string(expected_layers[index]) + + "/query?"), + std::string::npos); + } +} + +TEST(ArcGISClientRouting, RejectsUnsupportedProductsBeforeNetworkAccess) { + std::shared_ptr transport = std::make_shared(); + ArcGISClient client{transport}; + + const Result categorical = client.query_categorical(4); + const Result probabilistic = client.query_probabilistic(3, "hail"); + const Result conditional = + client.query_conditional_intensity(2, "severe"); + const Result fire = client.query_fire_weather(9); + + EXPECT_FALSE(categorical); + EXPECT_FALSE(probabilistic); + EXPECT_FALSE(conditional); + EXPECT_FALSE(fire); + EXPECT_TRUE(transport->requests.empty()); +} + +TEST(ArcGISClientPaging, EncodesParametersAndFetchesEveryPage) { + std::shared_ptr transport = std::make_shared(); + transport->responses = { + {200, R"({"features":[],"exceededTransferLimit":true})", {}}, + {200, R"({"features":[],"exceededTransferLimit":false})", {}}, + }; + ArcGISClient client{transport}; + QueryParams params; + params.where = "LABEL = 'SLGT'"; + params.out_fields = "LABEL,valid"; + params.geometry = R"({"xmin":-105,"ymin":39})"; + params.geometry_type = "esriGeometryEnvelope"; + + const Result> result = + client.query_layer(ArcGISService::Outlooks, 1, params); + + ASSERT_TRUE(result); + ASSERT_EQ(result->size(), 2u); + ASSERT_EQ(transport->requests.size(), 2u); + EXPECT_NE(transport->requests[0].find("where=LABEL%20%3D%20%27SLGT%27"), std::string::npos); + EXPECT_NE(transport->requests[0].find("outFields=LABEL%2Cvalid"), std::string::npos); + EXPECT_NE(transport->requests[0].find("geometry=%7B%22xmin%22%3A-105%2C%22ymin%22%3A39%7D"), + std::string::npos); + EXPECT_NE(transport->requests[0].find("resultOffset=0"), std::string::npos); + EXPECT_NE(transport->requests[1].find("resultOffset=2000"), std::string::npos); +} + +TEST(ArcGISClientPaging, ReturnsLogicalArcGISErrorsReportedWithHttp200) { + std::shared_ptr transport = std::make_shared(); + transport->responses = { + {200, + R"({"error":{"code":400,"message":"Invalid or missing input parameters.","details":[]}})", + {}}, + }; + ArcGISClient client{transport}; + + const Result> result = + client.query_layer(ArcGISService::Outlooks, 0, {}); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidRequest); + EXPECT_EQ(result.error().http_status, 400); + EXPECT_EQ(result.error().message, "Invalid or missing input parameters."); +} + +TEST(ArcGISClientRouting, ActiveWatchesDirectCallersToTheIemClient) { + std::shared_ptr transport = std::make_shared(); + ArcGISClient client{transport}; + + const Result result = call_deprecated_active_watches(client); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidRequest); + EXPECT_NE(result.error().message.find("ArchiveClient"), std::string::npos); + EXPECT_TRUE(transport->requests.empty()); +} + +TEST(HttpClientLifecycle, ConcurrentClientsShareProcessWideCurlState) { + std::vector workers; + workers.reserve(16); + for (int index = 0; index < 16; ++index) { + workers.emplace_back([] { + HttpClient first; + HttpClient second; + EXPECT_TRUE(first.config().verify_ssl); + EXPECT_TRUE(second.config().verify_ssl); + }); + } + for (std::thread& worker : workers) { + worker.join(); + } +} + +} // namespace diff --git a/tools/test_consumers.sh b/tools/test_consumers.sh new file mode 100755 index 0000000..d107cf8 --- /dev/null +++ b/tools/test_consumers.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +spc_source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +spc_scratch_dir=$(mktemp -d "${TMPDIR:-/tmp}/spc-consumer.XXXXXX") +trap 'rm -rf "$spc_scratch_dir"' EXIT + +cmake -S "$spc_source_dir" -B "$spc_scratch_dir/sdk" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$spc_scratch_dir/prefix" \ + -DSPC_BUILD_TESTS=OFF \ + -DSPC_BUILD_EXAMPLES=OFF +cmake --build "$spc_scratch_dir/sdk" --parallel +cmake --install "$spc_scratch_dir/sdk" + +cmake -S "$spc_source_dir/tests/consumer/installed" \ + -B "$spc_scratch_dir/installed" \ + -DCMAKE_PREFIX_PATH="$spc_scratch_dir/prefix" +cmake --build "$spc_scratch_dir/installed" --parallel +"$spc_scratch_dir/installed/spc_installed_consumer" + +cmake -S "$spc_source_dir/tests/consumer/fetchcontent" \ + -B "$spc_scratch_dir/fetchcontent" \ + -DSPC_SOURCE_DIR="$spc_source_dir" +cmake --build "$spc_scratch_dir/fetchcontent" --parallel +"$spc_scratch_dir/fetchcontent/spc_fetchcontent_consumer" diff --git a/tools/verify_arcgis_metadata.py b/tools/verify_arcgis_metadata.py new file mode 100755 index 0000000..eda3f1d --- /dev/null +++ b/tools/verify_arcgis_metadata.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Compare NOAA's live ArcGIS layer metadata with the checked contract.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys +from urllib.error import URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "tests" / "fixtures" / "arcgis_layers_2026-09-03.json" + + +def fetch_json(url: str) -> dict[str, object]: + request = Request(url, headers={"User-Agent": "spc-cpp metadata check"}) + with urlopen(request, timeout=20) as response: + return json.load(response) + + +def normalized_layers(document: dict[str, object]) -> list[list[object]]: + layers = document.get("layers") + if not isinstance(layers, list): + raise ValueError("metadata response has no layers array") + result: list[list[object]] = [] + for layer in layers: + if not isinstance(layer, dict): + raise ValueError("metadata layer is not an object") + result.append([layer.get("id"), layer.get("name"), layer.get("parentLayerId")]) + return result + + +def main() -> int: + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + failures: list[str] = [] + for service in contract["services"]: + try: + live = fetch_json(service["url"]) + except (OSError, URLError, TimeoutError, json.JSONDecodeError) as error: + failures.append(f"{service['name']}: fetch failed: {error}") + continue + if live.get("currentVersion") != service["currentVersion"]: + failures.append( + f"{service['name']}: version {live.get('currentVersion')} != " + f"{service['currentVersion']}" + ) + if normalized_layers(live) != service["layers"]: + failures.append(f"{service['name']}: layer ids, names, or parents changed") + continue + service_url = service["url"].split("?", 1)[0] + query = urlencode( + { + "where": "1=1", + "outFields": "*", + "returnGeometry": "false", + "resultRecordCount": "1", + "f": "json", + } + ) + for layer_id in service["queryable"]: + query_url = f"{service_url}/{layer_id}/query?{query}" + try: + response = fetch_json(query_url) + except (OSError, URLError, TimeoutError, json.JSONDecodeError) as error: + failures.append(f"{service['name']} layer {layer_id}: query failed: {error}") + continue + if "error" in response or not isinstance(response.get("features"), list): + failures.append( + f"{service['name']} layer {layer_id}: query returned " + f"{response.get('error', 'no features array')}" + ) + + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + print("NOAA ArcGIS metadata and all 39 feature layers match the 2026-09-03 contract") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_fixture_checksums.py b/tools/verify_fixture_checksums.py new file mode 100755 index 0000000..c206630 --- /dev/null +++ b/tools/verify_fixture_checksums.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Verify that every parser fixture matches the checked SHA-256 manifest.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "tests" / "fixtures" +MANIFEST = FIXTURES / "SHA256SUMS" + + +def main() -> int: + expected: dict[Path, str] = {} + for line_number, line in enumerate(MANIFEST.read_text(encoding="utf-8").splitlines(), 1): + digest, separator, relative_name = line.partition(" ") + if not separator or len(digest) != 64: + print(f"SHA256SUMS:{line_number}: malformed entry", file=sys.stderr) + return 1 + expected[ROOT / relative_name] = digest + + actual = { + path + for path in FIXTURES.iterdir() + if path.is_file() and path.name not in {"README.md", "SHA256SUMS"} + } + if actual != set(expected): + missing = sorted(str(path.relative_to(ROOT)) for path in actual - set(expected)) + stale = sorted(str(path.relative_to(ROOT)) for path in set(expected) - actual) + print(f"unlisted fixtures: {missing}", file=sys.stderr) + print(f"missing fixtures: {stale}", file=sys.stderr) + return 1 + + failures: list[str] = [] + for path, wanted in expected.items(): + found = hashlib.sha256(path.read_bytes()).hexdigest() + if found != wanted: + failures.append(f"{path.relative_to(ROOT)}: {found} != {wanted}") + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + print(f"verified {len(expected)} fixture checksums") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())