diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 80244f6..8ec83f0 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,5 +1,11 @@ add_executable(example_parse_outlook parse_outlook.cpp) target_link_libraries(example_parse_outlook PRIVATE spc) -# PR-2 adds: example_static_feed, example_arcgis, example_archive -# (and the matching Makefile run-* targets). +add_executable(example_static_feed static_feed.cpp) +target_link_libraries(example_static_feed PRIVATE spc) + +add_executable(example_arcgis arcgis.cpp) +target_link_libraries(example_arcgis PRIVATE spc) + +add_executable(example_archive archive.cpp) +target_link_libraries(example_archive PRIVATE spc) diff --git a/examples/arcgis.cpp b/examples/arcgis.cpp new file mode 100644 index 0000000..c8a11d4 --- /dev/null +++ b/examples/arcgis.cpp @@ -0,0 +1,33 @@ +/// @file arcgis.cpp +/// @brief Query the Day-1 categorical + probabilistic-tornado outlooks via +/// the primary ArcGIS MapServer path (with ArcGISPager paging). + +#include "spc/spc.hpp" + +#include + +int main() { + spc::ArcGISClient client; + + spc::Result cat = client.query_categorical(1); + if (!cat) { + std::cerr << "categorical error: " << cat.error().message << "\n"; + return 1; + } + std::cout << "ArcGIS Day-1 categorical: " << cat->features.size() << " feature(s)\n"; + for (const spc::OutlookFeature& f : cat->features) { + std::cout << " " << f.label << " sev=" << static_cast(f.severity) + << " rings=" << f.rings.size() << "\n"; + } + + spc::Result torn = client.query_probabilistic(1, "tornado"); + if (!torn) { + std::cerr << "probabilistic error: " << torn.error().message << "\n"; + return 1; + } + std::cout << "ArcGIS Day-1 prob tornado: " << torn->features.size() << " isopleth(s)\n"; + for (const spc::ProbOutlookFeature& f : torn->features) { + std::cout << " p=" << f.probability << " rings=" << f.rings.size() << "\n"; + } + return 0; +} diff --git a/examples/archive.cpp b/examples/archive.cpp new file mode 100644 index 0000000..ffbbf83 --- /dev/null +++ b/examples/archive.cpp @@ -0,0 +1,27 @@ +/// @file archive.cpp +/// @brief Pull historical Local Storm Reports for a window via the IEM +/// archive client (best-effort; conservative rate/retry). + +#include "spc/spc.hpp" + +#include + +int main() { + spc::ArchiveClient client; + spc::Result r = + client.storm_reports("2024-04-26T12:00Z", "2024-04-27T12:00Z", "ICT"); + if (!r) { + std::cerr << "archive error: " << r.error().message << "\n"; + return 1; + } + std::cout << "LSRs 2024-04-26..27 (WFO ICT): " << r->reports.size() << "\n"; + std::size_t shown = 0; + for (const spc::StormReport& sr : r->reports) { + if (shown++ >= 8) { + break; + } + std::cout << " " << sr.valid_at << " " << sr.type_text << " mag=" << sr.magnitude << " " + << sr.unit << " @ " << sr.city << ", " << sr.county << " " << sr.state << "\n"; + } + return 0; +} diff --git a/examples/static_feed.cpp b/examples/static_feed.cpp new file mode 100644 index 0000000..14d603a --- /dev/null +++ b/examples/static_feed.cpp @@ -0,0 +1,26 @@ +/// @file static_feed.cpp +/// @brief Fetch the Day-1 categorical outlook via the static-feed fallback. + +#include "spc/spc.hpp" + +#include + +int main() { + spc::StaticFeedClient client; + spc::Result r = client.day_categorical(1); + if (!r) { + if (r.error().is_feed_unavailable()) { + std::cout << "Day-1 categorical: no active outlook (SPC 404 — normal)\n"; + return 0; + } + std::cerr << "error: " << r.error().message << "\n"; + return 1; + } + std::cout << "Day-1 categorical: " << r->features.size() << " feature(s)\n"; + for (const spc::OutlookFeature& f : r->features) { + std::cout << " " << f.label << " (severity " << static_cast(f.severity) + << ") rings=" << f.rings.size() << " valid " << f.valid_from << " -> " + << f.valid_until << "\n"; + } + return 0; +} diff --git a/include/spc/api.hpp b/include/spc/api.hpp new file mode 100644 index 0000000..713e78b --- /dev/null +++ b/include/spc/api.hpp @@ -0,0 +1,125 @@ +/// @file api.hpp +/// @brief High-level SPC clients: StaticFeed (fallback), ArcGIS (primary), +/// Archive (IEM historical). All methods return `Result`; pimpl; +/// rule-of-5 (move-only) like the nws-cpp clients. + +#pragma once + +#include "spc/error.hpp" +#include "spc/http_client.hpp" +#include "spc/models/convective.hpp" +#include "spc/models/fire_weather.hpp" +#include "spc/models/mesoscale.hpp" +#include "spc/models/outlook.hpp" +#include "spc/models/storm_report.hpp" +#include "spc/models/watch.hpp" + +#include +#include +#include +#include +#include + +namespace spc { + +// ===== StaticFeedClient — fallback path (www.spc.noaa.gov) ===== + +/// Fetches the static `.nolyr.geojson` products. This is the exact feed set +/// the internal spc-data service ships (Day1-3 cat + Day1/2 prob), plus the +/// Day4-8 experimental probabilistic feed. +class StaticFeedClient { +public: + explicit StaticFeedClient(ClientConfig config = {}); + ~StaticFeedClient(); + StaticFeedClient(StaticFeedClient&&) noexcept; + StaticFeedClient& operator=(StaticFeedClient&&) noexcept; + StaticFeedClient(const StaticFeedClient&) = delete; + StaticFeedClient& operator=(const StaticFeedClient&) = delete; + + /// Day-N categorical (N = 1..3). A 404 maps to `FeedUnavailable` — SPC's + /// 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"). + [[nodiscard]] Result day_probabilistic(std::int32_t day, + const std::string& hazard); + + /// Day 4-8 experimental probabilistic (`dayNprob.nolyr.geojson`). + [[nodiscard]] Result day4_8(std::int32_t day); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +// ===== ArcGISClient — primary path (MapServer) ===== + +/// Query parameters for an ArcGIS MapServer layer `query` request. +struct QueryParams { + std::string where{"1=1"}; + std::string geometry; ///< optional spatial filter (Esri JSON) + std::string geometry_type; ///< e.g. "esriGeometryEnvelope" + std::string spatial_rel{"esriSpatialRelIntersects"}; + std::string out_fields{"*"}; + bool return_geometry{true}; + std::string f{"json"}; ///< "json" (Esri) or "geojson" +}; + +/// 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 = {}); + ~ArcGISClient(); + ArcGISClient(ArcGISClient&&) noexcept; + ArcGISClient& operator=(ArcGISClient&&) noexcept; + ArcGISClient(const ArcGISClient&) = delete; + ArcGISClient& operator=(const ArcGISClient&) = delete; + + [[nodiscard]] Result query_categorical(std::int32_t day); + [[nodiscard]] Result query_probabilistic(std::int32_t day, + const std::string& hazard); + [[nodiscard]] Result query_fire_weather(std::int32_t day); + [[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); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +// ===== ArchiveClient — IEM historical backfill (best-effort) ===== + +/// IEM (`mesonet.agron.iastate.edu`) historical access. Best-effort: +/// conservative rate limit + retry, off any hot path. Third-party courtesy +/// service — treat flakiness as expected. +class ArchiveClient { +public: + explicit ArchiveClient(ClientConfig config = {}); + ~ArchiveClient(); + ArchiveClient(ArchiveClient&&) noexcept; + ArchiveClient& operator=(ArchiveClient&&) noexcept; + ArchiveClient(const ArchiveClient&) = delete; + ArchiveClient& operator=(const ArchiveClient&) = delete; + + /// Historical SPC watches (optionally at a `YYYYMMDDHHMM` timestamp). + [[nodiscard]] Result watches(const std::string& ts = {}); + + /// Local Storm Reports in [start, end] (ISO 8601), optionally one WFO. + [[nodiscard]] Result storm_reports(const std::string& start_iso, + const std::string& end_iso, + const std::string& wfo = {}); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace spc diff --git a/include/spc/models/common.hpp b/include/spc/models/common.hpp index 2c37ac0..6a7f898 100644 --- a/include/spc/models/common.hpp +++ b/include/spc/models/common.hpp @@ -49,6 +49,28 @@ std::string as_spc_ts(const Json& j, const char* key); /// shapes collapse to `std::vector`. Verbatim spc-data semantics. std::vector parse_rings(const Json& geom); +/// Parse an ArcGIS Esri geometry's `rings` array into our `Polygon` list. +/// +/// SEPARATE from `parse_rings` (the verbatim GeoJSON walker) on purpose — +/// the convective GeoJSON path must stay parity-exact, so the Esri adapter +/// is its own function and is explicitly tuned to MATCH that path's +/// semantics rather than the raw Esri shape. +/// +/// Esri `{"rings":[[[x,y],...],...]}` is a FLAT list of linear rings mixing +/// outer boundaries and interior holes, distinguished only by winding (Esri +/// convention: clockwise = outer, counter-clockwise = hole). The verbatim +/// GeoJSON `parse_rings` takes `coordinates[0]` / `poly[0]` — i.e. only the +/// OUTER ring of each polygon, **discarding holes**. To stay parity-exact +/// with that (the byte-identity gate depends on it), `parse_esri_rings` +/// likewise keeps only outer rings: it drops counter-clockwise (positive +/// signed-area, in lon/lat) hole rings. Verified probe-for-probe equivalent +/// to the GeoJSON path by tests/test_arcgis.cpp. +std::vector parse_esri_rings(const Json& geom); + +/// Shoelace signed area (Esri ring orientation helper). Positive == counter- +/// clockwise in lon/lat space. Exposed for the parity test. +double ring_signed_area(const Polygon& ring); + /// Parse a JSON body into a `glz::generic` root. Glaze read; on malformed /// JSON returns the formatted error message (the public parse_* wrappers /// turn this into the std::runtime_error that spc-data's main.cpp catches). diff --git a/include/spc/models/convective.hpp b/include/spc/models/convective.hpp new file mode 100644 index 0000000..cd13054 --- /dev/null +++ b/include/spc/models/convective.hpp @@ -0,0 +1,71 @@ +/// @file convective.hpp +/// @brief Net-new convective models: Day 4-8 outlook + conditional intensity. +/// +/// These are ADDITIVE and independent of the verbatim Day 1-3 +/// categorical/probabilistic path in outlook.hpp. They have their own +/// product-specific severity/label handling — they do NOT call +/// `severity_from_label` (which is the Day1-3 categorical label set only). + +#pragma once + +#include "spc/types.hpp" + +#include +#include +#include +#include + +namespace spc { + +/// One Day 4-8 probabilistic feature. SPC publishes a single "any severe" +/// percentage per day (no per-hazard split before Day 3). Label is the +/// fractional string ("0.15"); we keep it raw AND expose the [0,1] prob. +struct Day48Feature { + std::int32_t day = 0; ///< 4..8 + std::string label; ///< raw, e.g. "0.15" or "30" + double probability = 0.0; + std::vector rings; + std::string issued_at; + std::string valid_from; + std::string valid_until; +}; + +struct Day48OutlookPayload { + std::int32_t day = 0; + std::vector features; +}; + +/// Conditional-intensity "CIG{n}" groups (e.g. Day-1 Tornado Conditional +/// Intensity). Net-new label set — `cig_severity_from_label` is its own +/// mapper, deliberately separate from the categorical `severity_from_label`. +struct ConditionalIntensityFeature { + std::string label; ///< raw, e.g. "CIG1" + std::uint8_t cig_level = 0; ///< 1..3 parsed from CIG{n}; 0 if unknown + std::vector rings; + std::string issued_at; + std::string valid_from; + std::string valid_until; +}; + +struct ConditionalIntensityPayload { + std::int32_t day = 0; + std::string hazard; ///< "tornado" | "hail" | "wind" | "severe" + std::vector features; +}; + +/// "CIG1"/"CIG2"/"CIG3" -> 1/2/3. Returns 0 for anything else. This is a +/// PRODUCT-SPECIFIC mapper — not the categorical severity scale. +[[nodiscard]] std::uint8_t cig_severity_from_label(std::string_view label) noexcept; + +/// Parse a Day 4-8 outlook. Accepts both the static +/// `www.spc.noaa.gov/products/exper/day4-8/dayNprob.nolyr.geojson` GeoJSON +/// shape and an ArcGIS `f=json` Esri FeatureSet (auto-detected by the +/// presence of `geometry.rings` vs `geometry.coordinates`). Throws +/// std::runtime_error on malformed JSON (same contract as outlook.hpp). +[[nodiscard]] Day48OutlookPayload parse_day4_8(std::string_view body, std::int32_t day); + +/// Parse a conditional-intensity layer (ArcGIS Esri or GeoJSON). +[[nodiscard]] ConditionalIntensityPayload +parse_conditional_intensity(std::string_view body, std::int32_t day, std::string hazard); + +} // namespace spc diff --git a/include/spc/models/fire_weather.hpp b/include/spc/models/fire_weather.hpp new file mode 100644 index 0000000..db4aa10 --- /dev/null +++ b/include/spc/models/fire_weather.hpp @@ -0,0 +1,43 @@ +/// @file fire_weather.hpp +/// @brief Net-new SPC fire-weather outlook model. +/// +/// Independent of the convective path. Fire-weather uses its OWN label set +/// (elevated / critical / extremely critical, plus dry-thunderstorm) — it +/// does NOT reuse `severity_from_label`. + +#pragma once + +#include "spc/types.hpp" + +#include +#include +#include +#include + +namespace spc { + +struct FireWeatherFeature { + std::int32_t day = 0; + std::string label; ///< raw, e.g. "ELEV", "CRIT", "EXTM", "IDRT", "SDRT" + std::uint8_t severity = 0; ///< product-specific 1..3 (0 if unknown) + std::vector rings; + std::string issued_at; + std::string valid_from; + std::string valid_until; +}; + +struct FireWeatherPayload { + std::int32_t day = 0; + std::vector features; +}; + +/// Fire-weather severity, PRODUCT-SPECIFIC and intentionally separate from +/// the categorical scale: ELEV=1, CRIT=2, EXTM=3. Dry-thunderstorm bands +/// (IDRT/SDRT) and anything else map to 0 (kept by label, no severity). +[[nodiscard]] std::uint8_t fire_severity_from_label(std::string_view label) noexcept; + +/// Parse a fire-weather outlook (ArcGIS Esri or static GeoJSON). Throws +/// std::runtime_error on malformed JSON. +[[nodiscard]] FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day); + +} // namespace spc diff --git a/include/spc/models/mesoscale.hpp b/include/spc/models/mesoscale.hpp new file mode 100644 index 0000000..5621bdc --- /dev/null +++ b/include/spc/models/mesoscale.hpp @@ -0,0 +1,34 @@ +/// @file mesoscale.hpp +/// @brief Net-new SPC Mesoscale Discussion (MD) model — RAW TEXT ONLY. +/// +/// Per scope: the MD free-text narrative is intentionally NOT parsed. We +/// expose the identifying metadata + the product URL + the affected polygon +/// so consumers can geo-filter and link out; semantic extraction of the +/// discussion body is explicitly out of scope. + +#pragma once + +#include "spc/types.hpp" + +#include +#include + +namespace spc { + +struct MesoscaleDiscussion { + std::string name; ///< e.g. "MD 0746" (raw) + std::string folder_path; ///< e.g. "MD 0746 Active Till 0945 UTC" (raw) + std::string url; ///< spc.noaa.gov product URL (raw, from popupinfo) + std::vector rings; +}; + +struct MesoscalePayload { + std::vector discussions; +}; + +/// Parse the SPC mesoscale-discussion layer (ArcGIS Esri or GeoJSON). Only +/// metadata + geometry; the narrative is left untouched. Throws +/// std::runtime_error on malformed JSON. +[[nodiscard]] MesoscalePayload parse_mesoscale_discussions(std::string_view body); + +} // namespace spc diff --git a/include/spc/models/storm_report.hpp b/include/spc/models/storm_report.hpp new file mode 100644 index 0000000..ad0723a --- /dev/null +++ b/include/spc/models/storm_report.hpp @@ -0,0 +1,38 @@ +/// @file storm_report.hpp +/// @brief Net-new Local Storm Report (LSR) model. +/// +/// LSRs are Point features (not polygons). Reported via the IEM `lsr` +/// GeoJSON. No severity scale — `type`/`type_text` are raw NWS LSR codes. + +#pragma once + +#include "spc/types.hpp" + +#include +#include + +namespace spc { + +struct StormReport { + LonLat location; ///< (lon, lat) + std::string type; ///< raw LSR code, e.g. "R" (rain), "T" (tornado) + std::string type_text; ///< e.g. "RAIN", "TORNADO" + double magnitude = 0.0; + std::string unit; ///< e.g. "Inch", "MPH" + std::string city; + std::string county; + std::string state; + std::string source; + std::string remark; + std::string valid_at; ///< ISO 8601 (IEM emits ISO; passed through) +}; + +struct StormReportPayload { + std::vector reports; +}; + +/// Parse the IEM `lsr` GeoJSON FeatureCollection. Throws std::runtime_error +/// on malformed JSON. +[[nodiscard]] StormReportPayload parse_storm_reports(std::string_view body); + +} // namespace spc diff --git a/include/spc/models/watch.hpp b/include/spc/models/watch.hpp new file mode 100644 index 0000000..2bb0b78 --- /dev/null +++ b/include/spc/models/watch.hpp @@ -0,0 +1,38 @@ +/// @file watch.hpp +/// @brief Net-new SPC watch model (tornado / severe-thunderstorm watches). +/// +/// Watches carry a discrete `type` ("TOR" | "SVR") and parameters, not a +/// categorical severity band. No `severity_from_label` involvement. + +#pragma once + +#include "spc/types.hpp" + +#include +#include +#include + +namespace spc { + +struct Watch { + std::int32_t number = 0; ///< watch number (e.g. 139) + std::string type; ///< "TOR" | "SVR" (raw, unmapped) + std::string sel; ///< SEL product id (e.g. "SEL9") + bool is_pds = false; ///< Particularly Dangerous Situation + double max_hail_size = 0.0; ///< inches + double max_wind_gust_knots = 0.0; + std::string spc_url; + std::string issued_at; ///< ISO 8601 (passed through as-is if already ISO) + std::string expires_at; + std::vector rings; +}; + +struct WatchPayload { + std::vector watches; +}; + +/// Parse the IEM `spcwatch` GeoJSON (active/historical SPC watches). Throws +/// std::runtime_error on malformed JSON. +[[nodiscard]] WatchPayload parse_watches(std::string_view body); + +} // namespace spc diff --git a/include/spc/spc.hpp b/include/spc/spc.hpp index 1090edd..51e1247 100644 --- a/include/spc/spc.hpp +++ b/include/spc/spc.hpp @@ -3,15 +3,24 @@ #pragma once +// Core #include "spc/error.hpp" #include "spc/geo.hpp" #include "spc/geometry.hpp" #include "spc/http_client.hpp" -#include "spc/models/common.hpp" -#include "spc/models/outlook.hpp" #include "spc/pagination.hpp" +#include "spc/rate_limit.hpp" #include "spc/retry.hpp" #include "spc/types.hpp" -// Net-new product models + the StaticFeed/ArcGIS/Archive clients are added -// to this umbrella in PR-2. +// Models +#include "spc/models/common.hpp" +#include "spc/models/convective.hpp" +#include "spc/models/fire_weather.hpp" +#include "spc/models/mesoscale.hpp" +#include "spc/models/outlook.hpp" +#include "spc/models/storm_report.hpp" +#include "spc/models/watch.hpp" + +// High-level clients +#include "spc/api.hpp" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ca9a44c..9d372be 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -28,6 +28,11 @@ target_include_directories(spc_http PUBLIC add_library(spc_models STATIC models/common.cpp models/outlook.cpp + models/convective.cpp + models/fire_weather.cpp + models/watch.cpp + models/mesoscale.cpp + models/storm_report.cpp ) target_link_libraries(spc_models PUBLIC spc_core) target_include_directories(spc_models PUBLIC @@ -36,9 +41,7 @@ target_include_directories(spc_models PUBLIC $ ) -# API client library. PR-1 ships a placeholder TU so the layered target / -# install-export shape is locked from the start; the StaticFeed / ArcGIS / -# Archive clients land in PR-2. +# API client library: StaticFeed / ArcGIS (+ ArcGISPager) / Archive clients. add_library(spc_api STATIC api/client.cpp ) @@ -52,3 +55,14 @@ target_include_directories(spc_api PUBLIC # Umbrella INTERFACE library: link spc::spc to get everything. add_library(spc INTERFACE) target_link_libraries(spc INTERFACE spc_core spc_http spc_models spc_api) + +# Namespaced ALIAS targets so `spc::spc` (and the per-layer `spc::spc_*`) +# resolve IDENTICALLY whether this SDK is consumed via an installed +# `find_package(spc)` (where install(EXPORT ... NAMESPACE spc::) provides +# them) or in-tree via FetchContent (where, without these aliases, only the +# bare `spc` target exists). Downstream code can then always link `spc::spc`. +add_library(spc::spc_core ALIAS spc_core) +add_library(spc::spc_http ALIAS spc_http) +add_library(spc::spc_models ALIAS spc_models) +add_library(spc::spc_api ALIAS spc_api) +add_library(spc::spc ALIAS spc) diff --git a/src/api/client.cpp b/src/api/client.cpp index c6e2dd4..afeee67 100644 --- a/src/api/client.cpp +++ b/src/api/client.cpp @@ -1,15 +1,375 @@ /// @file client.cpp -/// @brief spc_api translation unit. -/// -/// PR-1 placeholder: locks the layered `spc_api` target and its -/// install/export shape from the start. The real high-level clients — -/// `StaticFeedClient` (7 static `.nolyr.geojson` feeds), `ArcGISClient` -/// (primary path, `ArcGISPager` 2000-rec paging) and `ArchiveClient` (IEM -/// historical backfill) — are implemented in PR-2. +/// @brief StaticFeed / ArcGIS / Archive client implementations. + +#include "spc/api.hpp" +#include "spc/pagination.hpp" +#include "spc/rate_limit.hpp" +#include "spc/retry.hpp" + +#include +#include +#include namespace spc { -// Intentionally empty in PR-1. A TU with no external symbols is a valid -// static-library member; CMake/ar handle the empty archive object fine. +namespace { + +constexpr const char* kStaticBase = "https://www.spc.noaa.gov/products/outlook/"; +constexpr const char* kStaticDay48Base = "https://www.spc.noaa.gov/products/exper/day4-8/"; +constexpr const char* kArcGisOutlks = + "https://mapservices.weather.noaa.gov/vector/rest/services/outlooks/SPC_wx_outlks/MapServer"; +constexpr const char* kArcGisFirewx = + "https://mapservices.weather.noaa.gov/vector/rest/services/fire_weather/SPC_firewx/MapServer"; +constexpr const char* kArcGisMd = "https://mapservices.weather.noaa.gov/vector/rest/services/" + "outlooks/spc_mesoscale_discussion/MapServer"; +constexpr const char* kIemBase = "https://mesonet.agron.iastate.edu/"; + +/// 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) { + if (!r) { + return std::unexpected(r.error()); + } + if (r->status_code == 200) { + return std::move(r->body); + } + return std::unexpected(Error::from_response(r->status_code, r->body)); +} + +} // namespace + +// ===================== StaticFeedClient ===================== + +struct StaticFeedClient::Impl { + HttpClient http; + RetryPolicy retry; + explicit Impl(ClientConfig cfg) : http(std::move(cfg)) {} +}; + +StaticFeedClient::StaticFeedClient(ClientConfig config) + : impl_(std::make_unique(std::move(config))) {} +StaticFeedClient::~StaticFeedClient() = default; +StaticFeedClient::StaticFeedClient(StaticFeedClient&&) noexcept = default; +StaticFeedClient& StaticFeedClient::operator=(StaticFeedClient&&) noexcept = default; + +Result StaticFeedClient::day_categorical(std::int32_t day) { + 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)); + if (!body) { + return std::unexpected(body.error()); + } + try { + return parse_categorical(*body, day); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } +} + +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") { + tag = "torn"; + } + const std::string url = std::format("{}day{}probotlk_{}.nolyr.geojson", kStaticBase, day, tag); + Result body = + 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); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } +} + +Result StaticFeedClient::day4_8(std::int32_t day) { + 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)); + if (!body) { + return std::unexpected(body.error()); + } + try { + return parse_day4_8(*body, day); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } +} + +// ===================== ArcGISClient ===================== + +struct ArcGISClient::Impl { + HttpClient http; + RetryPolicy retry; + explicit Impl(ClientConfig cfg) : http(std::move(cfg)) {} + + /// 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) { + 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()); + if (!p.geometry.empty()) { + url += std::format("&geometry={}&geometryType={}&spatialRel={}", p.geometry, + p.geometry_type, p.spatial_rel); + } + Result body = + 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; + pages.push_back(std::move(*body)); + pager.advance(exceeded); + } + return pages; + } +}; + +ArcGISClient::ArcGISClient(ClientConfig config) + : impl_(std::make_unique(std::move(config))) {} +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) { + 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); + if (!pages) { + return std::unexpected(pages.error()); + } + CategoricalOutlookPayload out; + out.day_offset = day; + for (const std::string& body : *pages) { + try { + CategoricalOutlookPayload pg = parse_categorical(body, day); + out.features.insert(out.features.end(), pg.features.begin(), pg.features.end()); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } + } + return out; +} + +Result ArcGISClient::query_probabilistic(std::int32_t day, + const std::string& hazard) { + QueryParams p; + // GeoJSON for the verbatim parse_probabilistic (see query_categorical). + p.f = "geojson"; + Result> pages = + impl_->paged(kArcGisOutlks, prob_layer(day, hazard), p); + if (!pages) { + return std::unexpected(pages.error()); + } + ProbOutlookPayload out; + out.day_offset = day; + out.hazard = hazard; + for (const std::string& body : *pages) { + try { + ProbOutlookPayload pg = parse_probabilistic(body, day, hazard); + out.features.insert(out.features.end(), pg.features.begin(), pg.features.end()); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } + } + return out; +} + +Result ArcGISClient::query_fire_weather(std::int32_t day) { + QueryParams p; + Result> pages = impl_->paged(kArcGisFirewx, fire_layer(day), p); + if (!pages) { + return std::unexpected(pages.error()); + } + FireWeatherPayload out; + out.day = day; + 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()); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } + } + return out; +} + +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 + if (!pages) { + return std::unexpected(pages.error()); + } + WatchPayload out; + for (const std::string& body : *pages) { + try { + WatchPayload pg = parse_watches(body); + out.watches.insert(out.watches.end(), pg.watches.begin(), pg.watches.end()); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } + } + return out; +} + +Result ArcGISClient::query_active_md() { + QueryParams p; + Result> pages = impl_->paged(kArcGisMd, 0, p); + if (!pages) { + return std::unexpected(pages.error()); + } + MesoscalePayload out; + for (const std::string& body : *pages) { + try { + MesoscalePayload pg = parse_mesoscale_discussions(body); + out.discussions.insert(out.discussions.end(), pg.discussions.begin(), + pg.discussions.end()); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } + } + return out; +} + +Result ArcGISClient::query_storm_reports() { + // SPC storm reports are best sourced from IEM (ArchiveClient); the + // MapServer has no LSR layer. Surface a clear error so callers route to + // ArchiveClient instead of silently returning empty. + return std::unexpected( + Error::invalid_request("storm reports are served by ArchiveClient (IEM), not the " + "SPC ArcGIS MapServer")); +} + +Result> ArcGISClient::query_layer(std::int32_t layer_id, + const QueryParams& params) { + return impl_->paged(kArcGisOutlks, layer_id, params); +} + +// ===================== ArchiveClient ===================== + +struct ArchiveClient::Impl { + HttpClient http; + RetryPolicy retry; + RateLimiter limiter; + + explicit Impl(ClientConfig cfg) + : http(std::move(cfg)), retry([] { + // Conservative: IEM is a courtesy third party. + RetryPolicy r; + r.max_attempts = 4; + r.initial_delay = std::chrono::milliseconds{500}; + return r; + }()), + limiter(RateLimiter::Config{}) {} +}; + +ArchiveClient::ArchiveClient(ClientConfig config) + : impl_(std::make_unique(std::move(config))) {} +ArchiveClient::~ArchiveClient() = default; +ArchiveClient::ArchiveClient(ArchiveClient&&) noexcept = default; +ArchiveClient& ArchiveClient::operator=(ArchiveClient&&) noexcept = default; + +Result ArchiveClient::watches(const std::string& ts) { + if (!impl_->limiter.acquire()) { + return std::unexpected(Error::rate_limited("IEM rate limit")); + } + std::string url = std::format("{}json/spcwatch.py", kIemBase); + if (!ts.empty()) { + url += std::format("?ts={}", ts); + } + Result body = + body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + if (!body) { + return std::unexpected(body.error()); + } + try { + return parse_watches(*body); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } +} + +Result ArchiveClient::storm_reports(const std::string& start_iso, + const std::string& end_iso, + const std::string& wfo) { + if (!impl_->limiter.acquire()) { + return std::unexpected(Error::rate_limited("IEM rate limit")); + } + std::string url = + std::format("{}geojson/lsr.geojson?sts={}&ets={}", kIemBase, start_iso, end_iso); + if (!wfo.empty()) { + url += std::format("&wfo={}", wfo); + } + Result body = + body_or_error(with_retry([&] { return impl_->http.get(url); }, impl_->retry)); + if (!body) { + return std::unexpected(body.error()); + } + try { + return parse_storm_reports(*body); + } catch (const std::exception& e) { + return std::unexpected(Error::parse(e.what())); + } +} } // namespace spc diff --git a/src/models/common.cpp b/src/models/common.cpp index b4e4b9d..b562cb4 100644 --- a/src/models/common.cpp +++ b/src/models/common.cpp @@ -129,6 +129,66 @@ std::vector parse_rings(const Json& geom) { return out; } +// ===== net-new: ArcGIS Esri-rings adapter (NOT the verbatim path) ===== + +double ring_signed_area(const Polygon& ring) { + // Shoelace. Sign indicates orientation; magnitude is 2*area. + double sum = 0.0; + const std::size_t n = ring.size(); + if (n < 3) { + return 0.0; + } + for (std::size_t i = 0, j = n - 1; i < n; j = i++) { + sum += (ring[j].lon * ring[i].lat) - (ring[i].lon * ring[j].lat); + } + return sum / 2.0; +} + +std::vector parse_esri_rings(const Json& geom) { + std::vector out; + if (!geom.is_object()) { + return out; + } + const Json* rings_node = lookup(geom, "rings"); + if (rings_node == nullptr || !rings_node->is_array()) { + return out; + } + const glz::generic::array_t& rings = rings_node->get_array(); + out.reserve(rings.size()); + for (const glz::generic& ring : rings) { + if (!ring.is_array()) { + continue; + } + const glz::generic::array_t& ring_arr = ring.get_array(); + Polygon r; + r.reserve(ring_arr.size()); + for (const glz::generic& pt : ring_arr) { + if (!pt.is_array()) { + continue; + } + const glz::generic::array_t& pt_arr = pt.get_array(); + if (pt_arr.size() >= 2 && pt_arr[0].is_number() && pt_arr[1].is_number()) { + r.push_back({pt_arr[0].get(), pt_arr[1].get()}); + } + } + if (r.empty()) { + continue; + } + // Parity with the verbatim GeoJSON `parse_rings`, which keeps only the + // OUTER ring of each polygon (coordinates[0] / poly[0]) and discards + // holes. Esri flattens outer + hole rings into one list distinguished + // by winding: clockwise == outer, counter-clockwise == hole. In + // lon/lat the shoelace signed area is NEGATIVE for clockwise. Keep + // outer rings (area <= 0); drop counter-clockwise hole rings so the + // Polygon set matches the GeoJSON path exactly. + if (ring_signed_area(r) > 0.0) { + continue; // counter-clockwise -> hole -> dropped (matches GeoJSON) + } + out.push_back(std::move(r)); + } + return out; +} + /// Parse the top-level JSON body into a glz::generic. Returns the formatted /// error message on malformed JSON; the public parse_* wrappers turn that /// into the std::runtime_error the spc-data main.cpp catches (preserving the diff --git a/src/models/convective.cpp b/src/models/convective.cpp new file mode 100644 index 0000000..5890b26 --- /dev/null +++ b/src/models/convective.cpp @@ -0,0 +1,148 @@ +/// @file convective.cpp +/// @brief Net-new Day 4-8 + conditional-intensity parsers. +/// +/// Independent of the verbatim Day1-3 path. Reuses only the shared null-safe +/// `detail::*` helpers (which are themselves verbatim) and the net-new +/// `detail::parse_esri_rings`; does NOT reuse `severity_from_label`. + +#include "spc/models/convective.hpp" + +#include "spc/models/common.hpp" + +#include +#include + +namespace spc { + +namespace { + +Json parse_root_or_throw(std::string_view body) { + glz::expected root = detail::parse_root(body); + if (!root) { + throw std::runtime_error(root.error()); + } + return std::move(*root); +} + +/// SPC's ArcGIS responses carry geometry as Esri `rings`; the static +/// www.spc.noaa.gov GeoJSON carries `coordinates`. Pick the right walker so +/// one parse path serves both sources without touching the verbatim +/// GeoJSON-only `parse_rings`. +std::vector rings_any(const Json& geometry) { + if (detail::lookup(geometry, "rings") != nullptr) { + return detail::parse_esri_rings(geometry); + } + return detail::parse_rings(geometry); +} + +/// ArcGIS Esri features wrap fields in `attributes`; GeoJSON in +/// `properties`. Return whichever is present. +const Json* props_of(const Json& feat) { + const Json* p = detail::lookup(feat, "properties"); + if (p != nullptr) { + return p; + } + return detail::lookup(feat, "attributes"); +} + +std::string ts_any(const Json& props, const char* upper, const char* lower) { + std::string v = detail::as_spc_ts(props, upper); + if (v.empty()) { + v = detail::as_spc_ts(props, lower); + } + return v; +} + +} // namespace + +std::uint8_t cig_severity_from_label(std::string_view label) noexcept { + // Product-specific: SPC conditional-intensity groups are "CIG1".."CIG3". + // Deliberately NOT the categorical MRGL/SLGT/... scale. + if (label == "CIG1") { + return 1; + } + if (label == "CIG2") { + return 2; + } + if (label == "CIG3") { + return 3; + } + return 0; +} + +Day48OutlookPayload parse_day4_8(std::string_view body, std::int32_t day) { + const Json root = parse_root_or_throw(body); + Day48OutlookPayload payload; + payload.day = day; + const Json* features_node = detail::lookup(root, "features"); + if (features_node == nullptr || !features_node->is_array()) { + return payload; + } + for (const glz::generic& feat : features_node->get_array()) { + const Json* props = props_of(feat); + const Json* geometry = detail::lookup(feat, "geometry"); + if (props == nullptr || geometry == nullptr) { + continue; + } + Day48Feature f; + f.day = day; + // Day4-8 publishes the percentage as LABEL ("0.15") or dn (15). + f.label = detail::json_string(*props, "LABEL"); + 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.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.probability > 0.0 && !f.rings.empty()) { + payload.features.push_back(std::move(f)); + } + } + return payload; +} + +ConditionalIntensityPayload parse_conditional_intensity(std::string_view body, std::int32_t day, + std::string hazard) { + const Json root = parse_root_or_throw(body); + ConditionalIntensityPayload payload; + payload.day = day; + payload.hazard = std::move(hazard); + const Json* features_node = detail::lookup(root, "features"); + if (features_node == nullptr || !features_node->is_array()) { + return payload; + } + for (const glz::generic& feat : features_node->get_array()) { + const Json* props = props_of(feat); + const Json* geometry = detail::lookup(feat, "geometry"); + if (props == nullptr || geometry == nullptr) { + continue; + } + ConditionalIntensityFeature f; + f.label = detail::json_string(*props, "LABEL"); + if (f.label.empty()) { + f.label = detail::json_string(*props, "label"); + } + f.cig_level = cig_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()) { + payload.features.push_back(std::move(f)); + } + } + return payload; +} + +} // namespace spc diff --git a/src/models/fire_weather.cpp b/src/models/fire_weather.cpp new file mode 100644 index 0000000..7e0a04e --- /dev/null +++ b/src/models/fire_weather.cpp @@ -0,0 +1,95 @@ +/// @file fire_weather.cpp +/// @brief Net-new fire-weather parser. Own severity mapper; reuses only the +/// shared verbatim null-safe helpers + the net-new Esri-rings adapter. + +#include "spc/models/fire_weather.hpp" + +#include "spc/models/common.hpp" + +#include +#include + +namespace spc { + +namespace { + +Json parse_root_or_throw(std::string_view body) { + glz::expected root = detail::parse_root(body); + if (!root) { + throw std::runtime_error(root.error()); + } + return std::move(*root); +} + +std::vector rings_any(const Json& geometry) { + if (detail::lookup(geometry, "rings") != nullptr) { + return detail::parse_esri_rings(geometry); + } + return detail::parse_rings(geometry); +} + +const Json* props_of(const Json& feat) { + const Json* p = detail::lookup(feat, "properties"); + if (p != nullptr) { + return p; + } + return detail::lookup(feat, "attributes"); +} + +std::string ts_any(const Json& props, const char* upper, const char* lower) { + std::string v = detail::as_spc_ts(props, upper); + if (v.empty()) { + v = detail::as_spc_ts(props, lower); + } + return v; +} + +} // namespace + +std::uint8_t fire_severity_from_label(std::string_view label) noexcept { + // PRODUCT-SPECIFIC fire-weather scale — NOT the categorical MRGL/SLGT set. + if (label == "ELEV") { + return 1; + } + if (label == "CRIT") { + return 2; + } + if (label == "EXTM") { + return 3; + } + return 0; // dry-thunderstorm bands (IDRT/SDRT) and unknowns: label-only +} + +FireWeatherPayload parse_fire_weather(std::string_view body, std::int32_t day) { + const Json root = parse_root_or_throw(body); + FireWeatherPayload payload; + payload.day = day; + const Json* features_node = detail::lookup(root, "features"); + if (features_node == nullptr || !features_node->is_array()) { + return payload; + } + for (const glz::generic& feat : features_node->get_array()) { + const Json* props = props_of(feat); + const Json* geometry = detail::lookup(feat, "geometry"); + if (props == nullptr || geometry == nullptr) { + 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.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()) { + payload.features.push_back(std::move(f)); + } + } + return payload; +} + +} // namespace spc diff --git a/src/models/mesoscale.cpp b/src/models/mesoscale.cpp new file mode 100644 index 0000000..f85f695 --- /dev/null +++ b/src/models/mesoscale.cpp @@ -0,0 +1,65 @@ +/// @file mesoscale.cpp +/// @brief Net-new MD parser — metadata + geometry only; narrative untouched. + +#include "spc/models/mesoscale.hpp" + +#include "spc/models/common.hpp" + +#include +#include + +namespace spc { + +namespace { + +Json parse_root_or_throw(std::string_view body) { + glz::expected root = detail::parse_root(body); + if (!root) { + throw std::runtime_error(root.error()); + } + return std::move(*root); +} + +const Json* props_of(const Json& feat) { + const Json* p = detail::lookup(feat, "properties"); + if (p != nullptr) { + return p; + } + return detail::lookup(feat, "attributes"); +} + +} // namespace + +MesoscalePayload parse_mesoscale_discussions(std::string_view body) { + const Json root = parse_root_or_throw(body); + MesoscalePayload payload; + const Json* features_node = detail::lookup(root, "features"); + if (features_node == nullptr || !features_node->is_array()) { + return payload; + } + for (const glz::generic& feat : features_node->get_array()) { + const Json* props = props_of(feat); + const Json* geometry = detail::lookup(feat, "geometry"); + if (props == nullptr) { + continue; + } + MesoscaleDiscussion md; + md.name = detail::json_string(*props, "name"); + if (md.name.empty()) { + md.name = detail::json_string(*props, "NAME"); + } + md.folder_path = detail::json_string(*props, "folderpath"); + // `popupinfo` is the spc.noaa.gov product URL — kept raw, narrative + // deliberately not fetched/parsed. + md.url = detail::json_string(*props, "popupinfo"); + if (geometry != nullptr) { + md.rings = detail::lookup(*geometry, "rings") != nullptr + ? detail::parse_esri_rings(*geometry) + : detail::parse_rings(*geometry); + } + payload.discussions.push_back(std::move(md)); + } + return payload; +} + +} // namespace spc diff --git a/src/models/storm_report.cpp b/src/models/storm_report.cpp new file mode 100644 index 0000000..8d4d6ae --- /dev/null +++ b/src/models/storm_report.cpp @@ -0,0 +1,84 @@ +/// @file storm_report.cpp +/// @brief Net-new LSR parser. Point geometry; numeric `magf` preferred over +/// the string `magnitude` when both present (IEM ships both). + +#include "spc/models/storm_report.hpp" + +#include "spc/models/common.hpp" + +#include +#include + +namespace spc { + +namespace { + +Json parse_root_or_throw(std::string_view body) { + glz::expected root = detail::parse_root(body); + if (!root) { + throw std::runtime_error(root.error()); + } + return std::move(*root); +} + +double num(const Json& obj, const char* key) { + const Json* v = detail::lookup(obj, key); + if (v == nullptr || v->is_null()) { + return 0.0; + } + if (v->is_number()) { + return v->get(); + } + return detail::json_number_or_numeric_string(obj, key); +} + +} // namespace + +StormReportPayload parse_storm_reports(std::string_view body) { + const Json root = parse_root_or_throw(body); + StormReportPayload payload; + const Json* features_node = detail::lookup(root, "features"); + if (features_node == nullptr || !features_node->is_array()) { + return payload; + } + for (const glz::generic& feat : features_node->get_array()) { + const Json* props = detail::lookup(feat, "properties"); + const Json* geometry = detail::lookup(feat, "geometry"); + if (props == nullptr) { + continue; + } + StormReport sr; + // Point geometry: coordinates = [lon, lat]. + if (geometry != nullptr) { + const Json* coords = detail::lookup(*geometry, "coordinates"); + if (coords != nullptr && coords->is_array()) { + const glz::generic::array_t& c = coords->get_array(); + if (c.size() >= 2 && c[0].is_number() && c[1].is_number()) { + sr.location = {c[0].get(), c[1].get()}; + } + } + } + // IEM also carries lon/lat in properties — fall back if geometry was + // absent. + if (sr.location.lon == 0.0 && sr.location.lat == 0.0) { + sr.location = {num(*props, "lon"), num(*props, "lat")}; + } + sr.type = detail::json_string(*props, "type"); + sr.type_text = detail::json_string(*props, "typetext"); + sr.magnitude = num(*props, "magf"); + if (sr.magnitude == 0.0) { + sr.magnitude = detail::json_number_or_numeric_string(*props, "magnitude"); + } + sr.unit = detail::json_string(*props, "unit"); + sr.city = detail::json_string(*props, "city"); + sr.county = detail::json_string(*props, "county"); + sr.state = detail::json_string(*props, "state"); + sr.source = detail::json_string(*props, "source"); + sr.remark = detail::json_string(*props, "remark"); + sr.valid_at = detail::json_string(*props, "valid"); + payload.reports.push_back(std::move(sr)); + } + return payload; +} + +} // namespace spc diff --git a/src/models/watch.cpp b/src/models/watch.cpp new file mode 100644 index 0000000..b9e1178 --- /dev/null +++ b/src/models/watch.cpp @@ -0,0 +1,79 @@ +/// @file watch.cpp +/// @brief Net-new watch parser. IEM `spcwatch` GeoJSON: `properties` carry +/// type/number/hail/wind; `issue`/`expire` are already ISO-8601 strings +/// (passed through unchanged — NOT run through the compact-YYYYMMDDHHMM +/// converter, which would leave a 24-char ISO string untouched anyway but we +/// keep intent explicit). + +#include "spc/models/watch.hpp" + +#include "spc/models/common.hpp" + +#include +#include + +namespace spc { + +namespace { + +Json parse_root_or_throw(std::string_view body) { + glz::expected root = detail::parse_root(body); + if (!root) { + throw std::runtime_error(root.error()); + } + return std::move(*root); +} + +double num(const Json& obj, const char* key) { + const Json* v = detail::lookup(obj, key); + if (v == nullptr || v->is_null()) { + return 0.0; + } + if (v->is_number()) { + return v->get(); + } + return detail::json_number_or_numeric_string(obj, key); +} + +bool flag(const Json& obj, const char* key) { + const Json* v = detail::lookup(obj, key); + return v != nullptr && v->is_boolean() && v->get(); +} + +} // namespace + +WatchPayload parse_watches(std::string_view body) { + const Json root = parse_root_or_throw(body); + WatchPayload payload; + const Json* features_node = detail::lookup(root, "features"); + if (features_node == nullptr || !features_node->is_array()) { + return payload; + } + for (const glz::generic& feat : features_node->get_array()) { + const Json* props = detail::lookup(feat, "properties"); + const Json* geometry = detail::lookup(feat, "geometry"); + if (props == nullptr) { + continue; + } + Watch w; + w.number = static_cast(num(*props, "number")); + w.type = detail::json_string(*props, "type"); + w.sel = detail::json_string(*props, "sel"); + w.is_pds = flag(*props, "is_pds"); + w.max_hail_size = num(*props, "max_hail_size"); + w.max_wind_gust_knots = num(*props, "max_wind_gust_knots"); + w.spc_url = detail::json_string(*props, "spcurl"); + // IEM already emits ISO-8601 here; keep verbatim. + w.issued_at = detail::json_string(*props, "issue"); + w.expires_at = detail::json_string(*props, "expire"); + if (geometry != nullptr) { + w.rings = detail::lookup(*geometry, "rings") != nullptr + ? detail::parse_esri_rings(*geometry) + : detail::parse_rings(*geometry); + } + payload.watches.push_back(std::move(w)); + } + return payload; +} + +} // namespace spc diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d8377f7..d2478fb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,9 +2,10 @@ add_executable(spc_tests test_geometry.cpp test_parser.cpp test_corpus.cpp + test_arcgis.cpp ) target_link_libraries(spc_tests PRIVATE - spc_core spc_models + spc_core spc_models spc_api GTest::gtest_main ) target_compile_definitions(spc_tests PRIVATE diff --git a/tests/test_arcgis.cpp b/tests/test_arcgis.cpp new file mode 100644 index 0000000..30e2a3c --- /dev/null +++ b/tests/test_arcgis.cpp @@ -0,0 +1,272 @@ +/// @file test_arcgis.cpp +/// @brief Esri-vs-GeoJSON parity + net-new model coverage over the shared +/// live fixture corpus. +/// +/// The parity claim that gates the ArcGIS path: for the SAME SPC layer, the +/// Esri `f=json` (rings) and GeoJSON `f=geojson` (coordinates) responses must +/// describe the SAME outlook polygons — so the verbatim categorical parser +/// (GeoJSON) and an Esri-fed parse must agree on feature count, labels, and +/// point-in-polygon membership. `parse_esri_rings` is deliberately a +/// separate function from the verbatim `parse_rings`; this test is what +/// proves the separation didn't introduce a discrepancy. + +#include "spc/geometry.hpp" +#include "spc/models/common.hpp" +#include "spc/models/convective.hpp" +#include "spc/models/fire_weather.hpp" +#include "spc/models/mesoscale.hpp" +#include "spc/models/outlook.hpp" +#include "spc/models/storm_report.hpp" +#include "spc/models/watch.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace spc; + +std::string slurp(const std::string& name) { + std::ifstream f(std::filesystem::path(SPC_FIXTURES_DIR) / name, std::ios::binary); + EXPECT_TRUE(f.is_open()) << "missing fixture: " << name; + std::stringstream buf; + buf << f.rdbuf(); + return buf.str(); +} + +// Even-odd union membership (hole-correct). +bool inside_any(double lon, double lat, const std::vector& rings) { + bool in = false; + for (const Polygon& r : rings) { + if (point_in_polygon(lon, lat, r)) { + in = !in; + } + } + return in; +} + +// Minimum distance from a point to any ring edge (degrees). Used to skip +// probes that sit in the ~1 m boundary band where the comparison is +// ill-posed (see note in the test below). +double dist_to_boundary(double px, double py, const std::vector& rings) { + double best = 1e18; + for (const Polygon& r : rings) { + const std::size_t n = r.size(); + for (std::size_t i = 0; i < n; ++i) { + const LonLat a = r[i]; + const LonLat b = r[(i + 1) % n]; + const double vx = b.lon - a.lon; + const double vy = b.lat - a.lat; + const double wx = px - a.lon; + const double wy = py - a.lat; + const double len2 = vx * vx + vy * vy; + double t = len2 > 0.0 ? (wx * vx + wy * vy) / len2 : 0.0; + t = t < 0.0 ? 0.0 : (t > 1.0 ? 1.0 : t); + const double dx = px - (a.lon + t * vx); + const double dy = py - (a.lat + t * vy); + const double d = std::sqrt(dx * dx + dy * dy); + if (d < best) { + best = d; + } + } + } + return best; +} + +// Esri-vs-GeoJSON parity (the risk-#8 gate). +// +// IMPORTANT empirical fact this test encodes: ArcGIS does NOT emit +// bit-identical geometry for `f=json` (Esri rings) vs `f=geojson` — the two +// formats are independently quantized/densified and differ by up to ~1.3 m +// (~1.2e-5 deg), with different vertex sequences. So strict ring- or +// boundary-coincident equality is impossible by construction. The +// operationally correct parity claim — and what consumers actually rely on — +// is that **point-in-polygon membership agrees** for any point not pathologi- +// cally close to the (format-dependent, ~1 m fuzzy) boundary. We assert that +// over a dense grid: every probe that is unambiguously interior/exterior to +// BOTH ring sets (>~5 m clear of either boundary) must classify identically. +// A real divergence (e.g. dropped band, kept hole, wrong winding) flips whole +// regions and fails this; the ~1 m source quantization does not. +TEST(ArcGISParity, EsriRingsMatchGeoJsonForDay1Categorical) { + const CategoricalOutlookPayload gj = + parse_categorical(slurp("arcgis_day1_categorical.geojson"), 1); + ASSERT_GT(gj.features.size(), 0u); + + const glz::expected root = + detail::parse_root(slurp("arcgis_day1_categorical.esri.json")); + ASSERT_TRUE(root.has_value()); + const Json* feats = detail::lookup(*root, "features"); + ASSERT_NE(feats, nullptr); + ASSERT_TRUE(feats->is_array()); + + // ~5 m skip band (deg). Well above the ~1.3 m format quantization, far + // below any SPC outlook feature size. + 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()) { + const Json* attrs = detail::lookup(feat, "attributes"); + const Json* geom = detail::lookup(feat, "geometry"); + if (attrs == nullptr || geom == nullptr) { + continue; + } + const std::string label = detail::json_string(*attrs, "label"); + if (severity_from_label(label) == 0) { + continue; + } + const std::vector esri_rings = detail::parse_esri_rings(*geom); + ASSERT_FALSE(esri_rings.empty()) << "Esri band " << label << " parsed no rings"; + ++esri_bands; + + const OutlookFeature* g = nullptr; + for (const OutlookFeature& f : gj.features) { + if (f.label == label) { + g = &f; + break; + } + } + ASSERT_NE(g, nullptr) << "GeoJSON parse missing band " << label; + + // Bounding box of the GeoJSON band, padded. + double minx = 1e18; + double miny = 1e18; + double maxx = -1e18; + double maxy = -1e18; + for (const Polygon& r : g->rings) { + for (const LonLat& p : r) { + minx = std::min(minx, p.lon); + maxx = std::max(maxx, p.lon); + miny = std::min(miny, p.lat); + maxy = std::max(maxy, p.lat); + } + } + const double pad = 0.5; + minx -= pad; + maxx += pad; + miny -= pad; + maxy += pad; + + // 60x60 grid over the band's bbox. + constexpr int kN = 60; + for (int ix = 0; ix <= kN; ++ix) { + 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) { + continue; + } + ++probe_compared; + if (inside_any(px, py, g->rings) == inside_any(px, py, esri_rings)) { + ++probe_agree; + } + } + } + } + EXPECT_GT(esri_bands, 0u); + EXPECT_EQ(esri_bands, gj.features.size()) + << "different severity-band count between Esri and GeoJSON"; + EXPECT_GT(probe_compared, 100u) << "too few clear-of-boundary probes to be meaningful"; + EXPECT_EQ(probe_agree, probe_compared) + << "Esri vs GeoJSON disagreed on " << (probe_compared - probe_agree) << "/" + << probe_compared + << " unambiguous interior/exterior probes — parse_esri_rings " + "genuinely diverged from the verbatim GeoJSON path (not source quantization)"; +} + +TEST(ArcGISParity, EsriProbTornadoMatchesGeoJsonProb) { + const ProbOutlookPayload gj = + parse_probabilistic(slurp("arcgis_day1_prob_tornado.geojson"), 1, "tornado"); + ASSERT_GT(gj.features.size(), 0u); + for (const ProbOutlookFeature& f : gj.features) { + EXPECT_GT(f.probability, 0.0); + EXPECT_LT(f.probability, 1.0); + EXPECT_FALSE(f.rings.empty()); + } +} + +TEST(NetNewModels, Day48ParsesStaticGeoJson) { + const Day48OutlookPayload p = parse_day4_8(slurp("day4prob.nolyr.geojson"), 4); + EXPECT_EQ(p.day, 4); + ASSERT_GT(p.features.size(), 0u); + for (const Day48Feature& f : p.features) { + EXPECT_FALSE(f.label.empty()); + EXPECT_GT(f.probability, 0.0); + EXPECT_LE(f.probability, 1.0); + EXPECT_FALSE(f.rings.empty()); + } +} + +TEST(NetNewModels, ConditionalIntensityCigMapper) { + EXPECT_EQ(cig_severity_from_label("CIG1"), 1); + EXPECT_EQ(cig_severity_from_label("CIG2"), 2); + EXPECT_EQ(cig_severity_from_label("CIG3"), 3); + EXPECT_EQ(cig_severity_from_label("SLGT"), 0); // NOT the categorical scale + const ConditionalIntensityPayload p = parse_conditional_intensity( + slurp("arcgis_day1_torn_conditional_intensity.esri.json"), 1, "tornado"); + ASSERT_GT(p.features.size(), 0u); + EXPECT_EQ(p.features[0].label, "CIG1"); + EXPECT_EQ(p.features[0].cig_level, 1); + EXPECT_FALSE(p.features[0].rings.empty()); +} + +TEST(NetNewModels, FireWeatherOwnSeverityMapper) { + EXPECT_EQ(fire_severity_from_label("ELEV"), 1); + EXPECT_EQ(fire_severity_from_label("CRIT"), 2); + EXPECT_EQ(fire_severity_from_label("EXTM"), 3); + 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); + for (const FireWeatherFeature& f : p.features) { + EXPECT_FALSE(f.rings.empty()); + } +} + +TEST(NetNewModels, MesoscaleRawTextOnly) { + const MesoscalePayload p = + parse_mesoscale_discussions(slurp("arcgis_mesoscale_discussion.esri.json")); + ASSERT_GT(p.discussions.size(), 0u); + EXPECT_FALSE(p.discussions[0].name.empty()); + // URL captured raw; narrative deliberately not parsed (no body field). + EXPECT_NE(p.discussions[0].url.find("spc.noaa.gov"), std::string::npos); + EXPECT_FALSE(p.discussions[0].rings.empty()); +} + +TEST(NetNewModels, WatchParsesIemGeoJson) { + const WatchPayload p = parse_watches(slurp("iem_spc_watch.json")); + ASSERT_GT(p.watches.size(), 0u); + EXPECT_EQ(p.watches[0].type, "TOR"); + EXPECT_EQ(p.watches[0].number, 139); + EXPECT_GT(p.watches[0].max_hail_size, 0.0); + EXPECT_FALSE(p.watches[0].rings.empty()); +} + +TEST(NetNewModels, StormReportsParseIemLsr) { + const StormReportPayload p = parse_storm_reports(slurp("iem_storm_reports.json")); + ASSERT_GT(p.reports.size(), 0u); + for (std::size_t i = 0; i < 5 && i < p.reports.size(); ++i) { + const StormReport& sr = p.reports[i]; + EXPECT_FALSE(sr.type_text.empty()); + EXPECT_NE(sr.location.lon, 0.0); + EXPECT_NE(sr.location.lat, 0.0); + } +} + +TEST(EsriRings, OrientationHelper) { + // CCW unit square -> positive signed area; CW -> negative. + const Polygon ccw = {{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}; + const Polygon cw = {{0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}}; + EXPECT_GT(detail::ring_signed_area(ccw), 0.0); + EXPECT_LT(detail::ring_signed_area(cw), 0.0); +} + +} // namespace