Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions examples/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions examples/arcgis.cpp
Original file line number Diff line number Diff line change
@@ -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 <iostream>

int main() {
spc::ArcGISClient client;

spc::Result<spc::CategoricalOutlookPayload> 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<int>(f.severity)
<< " rings=" << f.rings.size() << "\n";
}

spc::Result<spc::ProbOutlookPayload> 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;
}
27 changes: 27 additions & 0 deletions examples/archive.cpp
Original file line number Diff line number Diff line change
@@ -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 <iostream>

int main() {
spc::ArchiveClient client;
spc::Result<spc::StormReportPayload> 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;
}
26 changes: 26 additions & 0 deletions examples/static_feed.cpp
Original file line number Diff line number Diff line change
@@ -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 <iostream>

int main() {
spc::StaticFeedClient client;
spc::Result<spc::CategoricalOutlookPayload> 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<int>(f.severity)
<< ") rings=" << f.rings.size() << " valid " << f.valid_from << " -> "
<< f.valid_until << "\n";
}
return 0;
}
125 changes: 125 additions & 0 deletions include/spc/api.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/// @file api.hpp
/// @brief High-level SPC clients: StaticFeed (fallback), ArcGIS (primary),
/// Archive (IEM historical). All methods return `Result<T>`; 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 <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>

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<CategoricalOutlookPayload> day_categorical(std::int32_t day);

/// Day-N probabilistic (day 1: hazard tornado|hail|wind; day 2: "any").
[[nodiscard]] Result<ProbOutlookPayload> day_probabilistic(std::int32_t day,
const std::string& hazard);

/// Day 4-8 experimental probabilistic (`dayNprob.nolyr.geojson`).
[[nodiscard]] Result<Day48OutlookPayload> day4_8(std::int32_t day);

private:
struct Impl;
std::unique_ptr<Impl> 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<CategoricalOutlookPayload> query_categorical(std::int32_t day);
[[nodiscard]] Result<ProbOutlookPayload> query_probabilistic(std::int32_t day,
const std::string& hazard);
[[nodiscard]] Result<FireWeatherPayload> query_fire_weather(std::int32_t day);
[[nodiscard]] Result<WatchPayload> query_active_watches();
[[nodiscard]] Result<MesoscalePayload> query_active_md();
[[nodiscard]] Result<StormReportPayload> query_storm_reports();

/// Escape hatch: raw paged query against an arbitrary layer id; returns
/// the concatenated raw response bodies (one per page).
[[nodiscard]] Result<std::vector<std::string>> query_layer(std::int32_t layer_id,
const QueryParams& params);

private:
struct Impl;
std::unique_ptr<Impl> 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<WatchPayload> watches(const std::string& ts = {});

/// Local Storm Reports in [start, end] (ISO 8601), optionally one WFO.
[[nodiscard]] Result<StormReportPayload> storm_reports(const std::string& start_iso,
const std::string& end_iso,
const std::string& wfo = {});

private:
struct Impl;
std::unique_ptr<Impl> impl_;
};

} // namespace spc
22 changes: 22 additions & 0 deletions include/spc/models/common.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ std::string as_spc_ts(const Json& j, const char* key);
/// shapes collapse to `std::vector<Polygon>`. Verbatim spc-data semantics.
std::vector<Polygon> 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<Polygon> 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).
Expand Down
71 changes: 71 additions & 0 deletions include/spc/models/convective.hpp
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <string>
#include <string_view>
#include <vector>

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<Polygon> rings;
std::string issued_at;
std::string valid_from;
std::string valid_until;
};

struct Day48OutlookPayload {
std::int32_t day = 0;
std::vector<Day48Feature> 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<Polygon> 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<ConditionalIntensityFeature> 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
43 changes: 43 additions & 0 deletions include/spc/models/fire_weather.hpp
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <string>
#include <string_view>
#include <vector>

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<Polygon> rings;
std::string issued_at;
std::string valid_from;
std::string valid_until;
};

struct FireWeatherPayload {
std::int32_t day = 0;
std::vector<FireWeatherFeature> 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
Loading
Loading