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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
65 changes: 65 additions & 0 deletions include/ncei/models/climate_index.hpp
Original file line number Diff line number Diff line change
@@ -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 <string>
#include <string_view>
#include <vector>

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<ClimatePoint> 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<ClimatePoint> 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<ClimatePoint> 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<void> 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<void> 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<void> deserialize_nsidc_sea_ice_csv(std::string_view body, bool monthly,
SeaIceSeries& out);

} // namespace ncei
62 changes: 62 additions & 0 deletions include/ncei/monitoring_client.hpp
Original file line number Diff line number Diff line change
@@ -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 <memory>
#include <string>

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<CagSeries> get_cag_global_annual(int start_year, int end_year);

/// NASA GISTEMP global Land-Ocean Temperature Index (annual J-D).
[[nodiscard]] Result<GistempSeries> 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<SeaIceSeries> 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> impl_;

[[nodiscard]] Result<HttpResponse> fetch(const std::string& url);
};

} // namespace ncei
2 changes: 2 additions & 0 deletions include/ncei/ncei.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
104 changes: 104 additions & 0 deletions src/api/monitoring_client.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 PredictionMarketsAI
// SPDX-License-Identifier: MIT

#include "ncei/monitoring_client.hpp"

#include <format>
#include <string>
#include <utility>

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<Impl>(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<HttpResponse> MonitoringClient::fetch(const std::string& url) {
Result<HttpResponse> r =
with_retry([&]() -> Result<HttpResponse> { 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<CagSeries> 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<HttpResponse> resp = fetch(url);
if (!resp) {
return std::unexpected(resp.error());
}
CagSeries out;
Result<void> parse = deserialize_cag_series(resp->body, out);
if (!parse) {
return std::unexpected(parse.error());
}
return out;
}

Result<GistempSeries> MonitoringClient::get_gistemp_global() {
Result<HttpResponse> resp =
fetch("https://data.giss.nasa.gov/gistemp/tabledata_v4/GLB.Ts+dSST.csv");
if (!resp) {
return std::unexpected(resp.error());
}
GistempSeries out;
Result<void> parse = deserialize_gistemp_csv(resp->body, out);
if (!parse) {
return std::unexpected(parse.error());
}
return out;
}

Result<SeaIceSeries> 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<HttpResponse> resp = fetch(url);
if (!resp) {
return std::unexpected(resp.error());
}
SeaIceSeries out;
out.region = "arctic";
Result<void> parse = deserialize_nsidc_sea_ice_csv(resp->body, monthly, out);
if (!parse) {
return std::unexpected(parse.error());
}
return out;
}

} // namespace ncei
8 changes: 7 additions & 1 deletion src/http/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ Result<HttpResponse> 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<std::pair<std::string, std::string>> response_headers;

Expand Down
Loading
Loading