diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99f4600..def4cbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,16 @@ jobs: steps: - uses: actions/checkout@v6 + # FetchContent clones glaze/googletest anonymously; GitHub + # rate-limits anonymous clones under load (intermittent 403). + # Rewriting to the workflow token makes the clone authenticated + # (much higher limit) and removes the recurring flake. + - name: Authenticate git for FetchContent + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/" + - name: Install dependencies run: | sudo apt-get update @@ -33,6 +43,12 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Authenticate git for FetchContent + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/" + - name: Install dependencies run: | brew install pkg-config curl clang-format @@ -52,6 +68,13 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Authenticate git for FetchContent + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global url."https://x-access-token:$env:GH_TOKEN@github.com/".insteadOf "https://github.com/" + - name: Install dependencies (vcpkg) shell: pwsh run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a1015..92c15ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,21 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.0] - 2026-05-17 + ### Added +- **National-climate settlement feeds.** `ncei/models/climate_index.hpp` + with `deserialize_cag_series` (NCEI Climate at a Glance / NOAAGlobalTemp + `data.json` — "hottest year rank"), `deserialize_gistemp_csv` (NASA + GISTEMP `GLB.Ts+dSST.csv` cross-check), and + `deserialize_nsidc_sea_ice_csv` (NSIDC Sea Ice Index v4 — "minimum + Arctic sea-ice extent"). New `MonitoringClient` + (`get_cag_global_annual` / `get_gistemp_global` / + `get_nsidc_arctic_extent`). `HttpClient` now passes absolute URLs + through unchanged so one client reaches all three hosts (ncei.noaa.gov, + data.giss.nasa.gov, noaadata.apps.nsidc.org); relative CDO / Data + Service paths are unaffected. Consumed by polymarket-data (Phase 3c). - `.editorconfig` (fleet-standard: tabs, 4-width, LF, UTF-8, 100-col max for C++). Sibling to `.clang-format`; covers editors that don't read `.clang-format` (#12). @@ -135,7 +148,8 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). location_categories, locations, stations, data) + Data Service (data, metadata) -[Unreleased]: https://github.com/Reddimus/ncei-cpp/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/Reddimus/ncei-cpp/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/Reddimus/ncei-cpp/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/Reddimus/ncei-cpp/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/Reddimus/ncei-cpp/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/Reddimus/ncei-cpp/releases/tag/v0.1.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index ef81823..b23c694 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(ncei-cpp VERSION 0.2.0 LANGUAGES CXX) +project(ncei-cpp VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 6fd24f7..81fd75e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ make coverage # Code coverage report (requires lcov) include(FetchContent) FetchContent_Declare(ncei-cpp GIT_REPOSITORY https://github.com/Reddimus/ncei-cpp.git - GIT_TAG v0.2.0 + GIT_TAG v0.3.0 ) FetchContent_MakeAvailable(ncei-cpp) target_link_libraries(myapp PRIVATE ncei) diff --git a/include/ncei/models/climate_index.hpp b/include/ncei/models/climate_index.hpp new file mode 100644 index 0000000..f669db3 --- /dev/null +++ b/include/ncei/models/climate_index.hpp @@ -0,0 +1,65 @@ +// Copyright (c) 2026 PredictionMarketsAI +// SPDX-License-Identifier: MIT +#pragma once + +/// @file climate_index.hpp +/// @brief Authoritative settlement series for national-climate contracts. +/// +/// Polymarket national-climate markets settle on: +/// - "hottest year rank" / global-temperature-anomaly -> NCEI Climate +/// at a Glance (NOAAGlobalTemp) JSON + NASA GISTEMP CSV (cross-check). +/// - "minimum Arctic sea-ice extent" -> NSIDC Sea Ice Index v4 CSV. +/// +/// These are thin JSON/CSV shapes — pure `deserialize_*` parsers here; +/// the fetch convenience lives in `ncei/monitoring_client.hpp`. + +#include "ncei/error.hpp" + +#include +#include +#include + +namespace ncei { + +/// One period -> value sample (period is "YYYY" or "YYYY-MM" or ISO date). +struct ClimatePoint { + std::string period; + double value{0.0}; +}; + +/// NCEI Climate at a Glance global time series (`.../data.json`). +struct CagSeries { + std::string title; + std::string units; + std::string base_period; + std::vector data; ///< annual (or monthly) anomalies +}; + +/// NASA GISTEMP global Land-Ocean Temperature Index annual means +/// (`GLB.Ts+dSST.csv`, the `J-D` column). +struct GistempSeries { + std::vector annual; ///< year -> anomaly (base 1951-1980) +}; + +/// NSIDC Sea Ice Index v4 extent series (G02135 monthly or daily CSV). +struct SeaIceSeries { + bool monthly{true}; + std::string region{"arctic"}; + std::vector extent; ///< period -> extent (million km^2) +}; + +/// Parse an NCEI Climate-at-a-Glance `data.json` +/// (`{"description":{title,units,base_period,...},"data":{"1880":"-0.19",...}}`). +[[nodiscard]] Result deserialize_cag_series(std::string_view body, CagSeries& out); + +/// Parse a NASA GISS `GLB.Ts+dSST.csv` (skips the banner rows, reads the +/// `Year` and `J-D` columns; "***" / blank annual cells are dropped). +[[nodiscard]] Result deserialize_gistemp_csv(std::string_view body, GistempSeries& out); + +/// Parse an NSIDC Sea Ice Index v4 CSV. Monthly file columns: +/// `year, mo, data-type, region, extent, area`; daily file columns: +/// `Year, Month, Day, Extent, Missing, Source Data`. +[[nodiscard]] Result deserialize_nsidc_sea_ice_csv(std::string_view body, bool monthly, + SeaIceSeries& out); + +} // namespace ncei diff --git a/include/ncei/monitoring_client.hpp b/include/ncei/monitoring_client.hpp new file mode 100644 index 0000000..0225ce9 --- /dev/null +++ b/include/ncei/monitoring_client.hpp @@ -0,0 +1,62 @@ +// Copyright (c) 2026 PredictionMarketsAI +// SPDX-License-Identifier: MIT +#pragma once + +/// @file monitoring_client.hpp +/// @brief Authoritative national-climate settlement feeds (no auth). +/// +/// Thin client over the multi-host static feeds that polymarket +/// national-climate contracts settle on: +/// - NCEI Climate at a Glance (NOAAGlobalTemp) — "hottest year rank" +/// - NASA GISTEMP — independent global-anomaly cross-check +/// - NSIDC Sea Ice Index v4 — "minimum Arctic sea-ice extent" +/// +/// All keyless; relies on the HttpClient absolute-URL passthrough. + +#include "ncei/error.hpp" +#include "ncei/http_client.hpp" +#include "ncei/models/climate_index.hpp" +#include "ncei/retry.hpp" + +#include +#include + +namespace ncei { + +class MonitoringClient { +public: + struct Config { + ClientConfig http; + RetryPolicy retry{}; + }; + + explicit MonitoringClient(Config config); + ~MonitoringClient(); + MonitoringClient(MonitoringClient&&) noexcept; + MonitoringClient& operator=(MonitoringClient&&) noexcept; + MonitoringClient(const MonitoringClient&) = delete; + MonitoringClient& operator=(const MonitoringClient&) = delete; + + /// NCEI Climate at a Glance global land+ocean annual anomaly series + /// (`.../globe/land_ocean/12/1/{start}-{end}/data.json`). + [[nodiscard]] Result get_cag_global_annual(int start_year, int end_year); + + /// NASA GISTEMP global Land-Ocean Temperature Index (annual J-D). + [[nodiscard]] Result get_gistemp_global(); + + /// NSIDC Sea Ice Index v4 Arctic extent. `monthly` false = daily file; + /// `month` selects the monthly file (default 9 = the September minimum + /// that annual "minimum Arctic sea-ice" contracts settle on). + [[nodiscard]] Result get_nsidc_arctic_extent(bool monthly = true, int month = 9); + + [[nodiscard]] HttpClient& http_client(); + [[nodiscard]] const HttpClient& http_client() const; + +private: + struct Impl; + std::unique_ptr impl_; + + [[nodiscard]] Result fetch(const std::string& url); +}; + +} // namespace ncei diff --git a/include/ncei/ncei.hpp b/include/ncei/ncei.hpp index 4dd9fdc..03f5651 100644 --- a/include/ncei/ncei.hpp +++ b/include/ncei/ncei.hpp @@ -12,10 +12,12 @@ #include "ncei/models/cdo/location.hpp" #include "ncei/models/cdo/location_category.hpp" #include "ncei/models/cdo/station.hpp" +#include "ncei/models/climate_index.hpp" #include "ncei/models/common.hpp" #include "ncei/models/data_service/data_point.hpp" #include "ncei/models/data_service/dataset_metadata.hpp" #include "ncei/models/data_service/search_result.hpp" +#include "ncei/monitoring_client.hpp" #include "ncei/pagination.hpp" #include "ncei/rate_limit.hpp" #include "ncei/retry.hpp" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 39ada64..ae91150 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -41,6 +41,7 @@ add_library(ncei_models STATIC models/data_service/data_point.cpp models/data_service/search_result.cpp models/data_service/dataset_metadata.cpp + models/climate_index.cpp ) target_link_libraries(ncei_models PUBLIC ncei_core) target_include_directories(ncei_models PUBLIC @@ -62,6 +63,7 @@ target_include_directories(ncei_cdo PUBLIC # Data Service Client library add_library(ncei_data STATIC api/data_service_client.cpp + api/monitoring_client.cpp ) target_link_libraries(ncei_data PUBLIC ncei_core ncei_http ncei_models) target_include_directories(ncei_data PUBLIC diff --git a/src/api/monitoring_client.cpp b/src/api/monitoring_client.cpp new file mode 100644 index 0000000..613e21d --- /dev/null +++ b/src/api/monitoring_client.cpp @@ -0,0 +1,104 @@ +// Copyright (c) 2026 PredictionMarketsAI +// SPDX-License-Identifier: MIT + +#include "ncei/monitoring_client.hpp" + +#include +#include +#include + +namespace ncei { + +struct MonitoringClient::Impl { + HttpClient http; + RetryPolicy retry; + + Impl(ClientConfig http_config, RetryPolicy rp) + : http(std::move(http_config)), retry(std::move(rp)) {} +}; + +MonitoringClient::MonitoringClient(Config config) { + // base_url stays empty — every feed is requested as an absolute URL + // (HttpClient passes those through unchanged). + impl_ = std::make_unique(std::move(config.http), config.retry); +} + +MonitoringClient::~MonitoringClient() = default; +MonitoringClient::MonitoringClient(MonitoringClient&&) noexcept = default; +MonitoringClient& MonitoringClient::operator=(MonitoringClient&&) noexcept = default; + +HttpClient& MonitoringClient::http_client() { + return impl_->http; +} + +const HttpClient& MonitoringClient::http_client() const { + return impl_->http; +} + +Result MonitoringClient::fetch(const std::string& url) { + Result r = + with_retry([&]() -> Result { return impl_->http.get(url); }, impl_->retry); + if (!r) { + return std::unexpected(r.error()); + } + if (r->status_code != 200) { + return std::unexpected(Error::from_response(r->status_code, r->body)); + } + return r; +} + +Result MonitoringClient::get_cag_global_annual(int start_year, int end_year) { + const std::string url = + std::format("https://www.ncei.noaa.gov/access/monitoring/climate-at-a-glance/global/" + "time-series/globe/land_ocean/12/1/{}-{}/data.json", + start_year, end_year); + Result resp = fetch(url); + if (!resp) { + return std::unexpected(resp.error()); + } + CagSeries out; + Result parse = deserialize_cag_series(resp->body, out); + if (!parse) { + return std::unexpected(parse.error()); + } + return out; +} + +Result MonitoringClient::get_gistemp_global() { + Result resp = + fetch("https://data.giss.nasa.gov/gistemp/tabledata_v4/GLB.Ts+dSST.csv"); + if (!resp) { + return std::unexpected(resp.error()); + } + GistempSeries out; + Result parse = deserialize_gistemp_csv(resp->body, out); + if (!parse) { + return std::unexpected(parse.error()); + } + return out; +} + +Result MonitoringClient::get_nsidc_arctic_extent(bool monthly, int month) { + std::string url; + if (monthly) { + url = std::format("https://noaadata.apps.nsidc.org/NOAA/G02135/north/monthly/" + "data/N_{:02d}_extent_v4.0.csv", + month); + } else { + url = "https://noaadata.apps.nsidc.org/NOAA/G02135/north/daily/data/" + "N_seaice_extent_daily_v4.0.csv"; + } + Result resp = fetch(url); + if (!resp) { + return std::unexpected(resp.error()); + } + SeaIceSeries out; + out.region = "arctic"; + Result parse = deserialize_nsidc_sea_ice_csv(resp->body, monthly, out); + if (!parse) { + return std::unexpected(parse.error()); + } + return out; +} + +} // namespace ncei diff --git a/src/http/client.cpp b/src/http/client.cpp index c056525..5ecf595 100644 --- a/src/http/client.cpp +++ b/src/http/client.cpp @@ -64,7 +64,13 @@ Result HttpClient::get(std::string_view path) const { } CURL* curl = impl_->curl; - std::string url = impl_->config.base_url + std::string(path); + // Absolute URLs pass through unchanged so one client can reach the + // several hosts the climate-index feeds live on (ncei.noaa.gov, + // data.giss.nasa.gov, noaadata.apps.nsidc.org). Relative paths keep + // the base_url prefix (CDO / Data Service behaviour, unchanged). + std::string url = (path.starts_with("http://") || path.starts_with("https://")) + ? std::string(path) + : impl_->config.base_url + std::string(path); std::string body; std::vector> response_headers; diff --git a/src/models/climate_index.cpp b/src/models/climate_index.cpp new file mode 100644 index 0000000..5dacbed --- /dev/null +++ b/src/models/climate_index.cpp @@ -0,0 +1,192 @@ +// Copyright (c) 2026 PredictionMarketsAI +// SPDX-License-Identifier: MIT + +#include "ncei/models/climate_index.hpp" + +#include "ncei/csv_parser.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "common_glaze_detail.hpp" + +namespace ncei { + +namespace { + +std::string trim(std::string_view s) { + std::size_t a = s.find_first_not_of(" \t\r\n"); + if (a == std::string_view::npos) { + return {}; + } + std::size_t b = s.find_last_not_of(" \t\r\n"); + return std::string(s.substr(a, b - a + 1)); +} + +// Tolerant double parse: handles GISTEMP's leading-dot form ("-.19"), +// missing markers ("***", "", "-9999", "-99.99"). Returns false if not a +// usable value. +bool parse_value(std::string_view raw, double& out) { + const std::string t = trim(raw); + if (t.empty() || t == "***" || t == "-9999" || t == "-99.99" || t == "NA") { + return false; + } + const char* b = t.c_str(); + char* end = nullptr; + const double v = std::strtod(b, &end); + if (end == b) { + return false; + } + out = v; + return true; +} + +int header_index(const std::vector& header, std::string_view name) { + for (std::size_t i = 0; i < header.size(); ++i) { + if (trim(header[i]) == name) { + return static_cast(i); + } + } + return -1; +} + +} // namespace + +Result deserialize_cag_series(std::string_view body, CagSeries& out) { + glz::generic root{}; + const glz::error_ctx ec = glz::read_json(root, std::string(body)); + if (ec || !root.is_object()) { + return std::unexpected(Error::parse("CAG: invalid JSON")); + } + const glz::generic::object_t& o = root.get_object(); + + glz::generic::object_t::const_iterator dit = o.find("description"); + if (dit != o.end() && dit->second.is_object()) { + out.title = detail::get_string(dit->second, "title"); + out.units = detail::get_string(dit->second, "units"); + out.base_period = detail::get_string(dit->second, "base_period"); + } + + glz::generic::object_t::const_iterator data = o.find("data"); + if (data == o.end() || !data->second.is_object()) { + return std::unexpected(Error::parse("CAG: no data object")); + } + for (const std::pair& kv : data->second.get_object()) { + double v = 0.0; + bool ok = false; + if (kv.second.is_string()) { + ok = parse_value(kv.second.get(), v); + } else if (kv.second.is_number()) { + v = kv.second.get(); + ok = true; + } + if (ok) { + out.data.push_back(ClimatePoint{kv.first, v}); + } + } + if (out.data.empty()) { + return std::unexpected(Error::parse("CAG: empty data series")); + } + return {}; +} + +Result deserialize_gistemp_csv(std::string_view body, GistempSeries& out) { + const DelimitedParser parser(DelimitedParser::Delimiter::Comma); + const std::vector> rows = parser.parse(body); + + int year_col = -1; + int jd_col = -1; + bool in_data = false; + for (const std::vector& row : rows) { + if (row.empty()) { + continue; + } + if (!in_data) { + const int yc = header_index(row, "Year"); + const int jc = header_index(row, "J-D"); + if (yc >= 0 && jc >= 0) { + year_col = yc; + jd_col = jc; + in_data = true; + } + continue; + } + if (static_cast(row.size()) <= jd_col) { + continue; + } + const std::string yr = trim(row[static_cast(year_col)]); + if (yr.empty() || !std::isdigit(static_cast(yr[0]))) { + continue; // repeated header / banner rows + } + double v = 0.0; + if (parse_value(row[static_cast(jd_col)], v)) { + out.annual.push_back(ClimatePoint{yr, v}); + } + } + if (out.annual.empty()) { + return std::unexpected(Error::parse("GISTEMP: no annual J-D values")); + } + return {}; +} + +Result deserialize_nsidc_sea_ice_csv(std::string_view body, bool monthly, SeaIceSeries& out) { + out.monthly = monthly; + const DelimitedParser parser(DelimitedParser::Delimiter::Comma); + const std::vector> rows = parser.parse(body); + if (rows.empty()) { + return std::unexpected(Error::parse("NSIDC: empty CSV")); + } + + const std::vector& header = rows.front(); + const int yi = header_index(header, monthly ? "year" : "Year"); + const int ext = header_index(header, monthly ? "extent" : "Extent"); + const int mi = header_index(header, monthly ? "mo" : "Month"); + const int di = header_index(header, "Day"); + if (yi < 0 || ext < 0) { + return std::unexpected(Error::parse("NSIDC: unrecognized CSV header")); + } + + for (std::size_t r = 1; r < rows.size(); ++r) { + const std::vector& row = rows[r]; + if (static_cast(row.size()) <= ext) { + continue; + } + double extent = 0.0; + if (!parse_value(row[static_cast(ext)], extent) || extent <= 0.0) { + continue; + } + const std::string y = trim(row[static_cast(yi)]); + if (y.empty()) { + continue; + } + std::string period = y; + if (mi >= 0 && static_cast(row.size()) > mi) { + const std::string m = trim(row[static_cast(mi)]); + if (m.size() == 1) { + period += "-0" + m; + } else if (!m.empty()) { + period += "-" + m; + } + } + if (!monthly && di >= 0 && static_cast(row.size()) > di) { + const std::string d = trim(row[static_cast(di)]); + if (d.size() == 1) { + period += "-0" + d; + } else if (!d.empty()) { + period += "-" + d; + } + } + out.extent.push_back(ClimatePoint{period, extent}); + } + if (out.extent.empty()) { + return std::unexpected(Error::parse("NSIDC: no usable extent rows")); + } + return {}; +} + +} // namespace ncei diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b4f2104..a5987e8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ add_executable(ncei_tests test_cdo_client.cpp test_data_service_models.cpp test_data_service_client.cpp + test_climate_index.cpp ) target_link_libraries(ncei_tests PRIVATE ncei_core ncei_http ncei_models ncei_cdo ncei_data diff --git a/tests/test_climate_index.cpp b/tests/test_climate_index.cpp new file mode 100644 index 0000000..3b2427a --- /dev/null +++ b/tests/test_climate_index.cpp @@ -0,0 +1,78 @@ +// Copyright (c) 2026 PredictionMarketsAI +// SPDX-License-Identifier: MIT + +#include "ncei/models/climate_index.hpp" + +#include +#include + +namespace ncei { +namespace { + +TEST(ClimateIndex, ParsesCagJson) { + const std::string body = R"({"description":{"title":"Global Land and Ocean",)" + R"("units":"Degrees Celsius","base_period":"1901-2000"},)" + R"("data":{"1880":"-0.19","2024":"+1.29","2025":"+1.26"}})"; + CagSeries s; + Result r = deserialize_cag_series(body, s); + ASSERT_TRUE(r.has_value()) << r.error().message; + EXPECT_EQ(s.units, "Degrees Celsius"); + EXPECT_EQ(s.base_period, "1901-2000"); + ASSERT_EQ(s.data.size(), 3u); + bool found_2025 = false; + for (const ClimatePoint& p : s.data) { + if (p.period == "2025") { + found_2025 = true; + EXPECT_DOUBLE_EQ(p.value, 1.26); + } + } + EXPECT_TRUE(found_2025); +} + +TEST(ClimateIndex, RejectsBadCag) { + CagSeries s; + EXPECT_FALSE(deserialize_cag_series("not json", s).has_value()); + EXPECT_FALSE(deserialize_cag_series(R"({"description":{}})", s).has_value()); +} + +TEST(ClimateIndex, ParsesGistempCsv) { + const std::string csv = + "Land-Ocean Temperature Index (C)\n" + "--------------------------------\n" + "Year,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec,J-D,D-N\n" + "1880,-.19,-.25,-.10,-.16,-.10,-.21,-.18,-.10,-.15,-.23,-.22,-.18,-.16,***\n" + "2024,1.25,1.40,1.30,1.27,1.20,1.22,1.24,1.28,1.30,1.40,1.30,1.25,1.28,1.27\n"; + GistempSeries s; + Result r = deserialize_gistemp_csv(csv, s); + ASSERT_TRUE(r.has_value()) << r.error().message; + ASSERT_EQ(s.annual.size(), 2u); + EXPECT_EQ(s.annual[0].period, "1880"); + EXPECT_DOUBLE_EQ(s.annual[0].value, -0.16); + EXPECT_EQ(s.annual[1].period, "2024"); + EXPECT_DOUBLE_EQ(s.annual[1].value, 1.28); +} + +TEST(ClimateIndex, ParsesNsidcMonthlyCsv) { + const std::string csv = "year, mo, data-type, region, extent, area\n" + "1979, 9, Goddard, N, 7.05, 4.58\n" + "2012, 9, Goddard, N, 3.57, 2.41\n" + "2024, 9, NRTSI-G, N, 4.28, -9999\n"; + SeaIceSeries s; + Result r = deserialize_nsidc_sea_ice_csv(csv, true, s); + ASSERT_TRUE(r.has_value()) << r.error().message; + EXPECT_TRUE(s.monthly); + ASSERT_EQ(s.extent.size(), 3u); + EXPECT_EQ(s.extent[0].period, "1979-09"); + EXPECT_DOUBLE_EQ(s.extent[0].value, 7.05); + EXPECT_EQ(s.extent[1].period, "2012-09"); + EXPECT_DOUBLE_EQ(s.extent[1].value, 3.57); +} + +TEST(ClimateIndex, RejectsBadNsidc) { + SeaIceSeries s; + EXPECT_FALSE(deserialize_nsidc_sea_ice_csv("", true, s).has_value()); + EXPECT_FALSE(deserialize_nsidc_sea_ice_csv("foo,bar\n1,2\n", true, s).has_value()); +} + +} // namespace +} // namespace ncei