From 7c97ea2eb4c609e34cf146e6ac590f002306914b Mon Sep 17 00:00:00 2001 From: katieannemills Date: Tue, 21 Jul 2026 13:59:05 -0400 Subject: [PATCH] draft for ccmpwind --- api/src/helpers/dataset_config.rs | 65 ++++++++++++++++++ api/src/helpers/schema.rs | 108 ++++++++++++++++++++++++++++++ api/src/main.rs | 48 ++++++++++++- 3 files changed, 218 insertions(+), 3 deletions(-) diff --git a/api/src/helpers/dataset_config.rs b/api/src/helpers/dataset_config.rs index b0f85ee..392a400 100644 --- a/api/src/helpers/dataset_config.rs +++ b/api/src/helpers/dataset_config.rs @@ -183,6 +183,25 @@ pub const COPERNICUSSLA_CONFIG: DatasetConfig = DatasetConfig { allowed_data_vars: &["sla", "adt", "ugosa", "ugos", "vgosa", "vgos"], }; +/// CCMP wind is a sea-surface product: single vertical level modeled as +/// a one-element levels array of 0.0, same as OI SST and Copernicus SLA. +/// See the comment on `OISST_LEVELS` for the mechanics. +pub const CCMPWIND_LEVELS: &[f64] = &[0.0]; + +/// Configuration for the CCMP wind timeseries dataset. +/// +/// Surface-only, global coverage. Tile size and radius cap deliberately +/// match Copernicus SLA / OI SST (5° / 100 km) — same uniformity +/// argument, same "relax once usage informs us" caveat. Four variables: +/// the wind vector components, wind speed, and observation count. +pub const CCMPWIND_CONFIG: DatasetConfig = DatasetConfig { + tile_degrees: 5.0, + max_radius_meters: 100_000.0, // 100 km — same starting cap as the others + levels: CCMPWIND_LEVELS, + coverage_bbox: None, + allowed_data_vars: &["uwnd", "vwnd", "ws", "nobs"], +}; + #[cfg(test)] mod tests { use super::*; @@ -338,4 +357,50 @@ mod tests { &["sla", "adt", "ugosa", "ugos", "vgosa", "vgos"] ); } + + // ---- CCMP wind config invariants (mirror the Copernicus SLA checks) ---- + + #[test] + fn ccmpwind_tile_degrees_is_positive_and_divides_a_hemisphere() { + assert!(CCMPWIND_CONFIG.tile_degrees > 0.0); + assert!( + (180.0_f64 % CCMPWIND_CONFIG.tile_degrees).abs() < 1e-9, + "tile_degrees should evenly divide 180° for clean global coverage" + ); + assert!( + (360.0_f64 % CCMPWIND_CONFIG.tile_degrees).abs() < 1e-9, + "tile_degrees should evenly divide 360° for clean global coverage" + ); + } + + #[test] + fn ccmpwind_max_radius_is_positive_and_subhemispheric() { + assert!(CCMPWIND_CONFIG.max_radius_meters > 0.0); + assert!(CCMPWIND_CONFIG.max_radius_meters < 1.0e7); + } + + #[test] + fn ccmpwind_has_exactly_one_surface_level() { + // Surface wind product; the single-element levels array keeps + // the tile generator on the no-special-case path (see OI SST). + assert_eq!(CCMPWIND_CONFIG.levels.len(), 1); + assert!((CCMPWIND_CONFIG.levels[0] - 0.0).abs() < 1e-9); + } + + #[test] + fn ccmpwind_has_global_coverage() { + // CCMP is a global gridded product; no coverage_bbox skip available. + assert!(CCMPWIND_CONFIG.coverage_bbox.is_none()); + } + + #[test] + fn ccmpwind_advertises_all_four_variables() { + // Wind vector components, speed, and observation count. If the + // upstream product adds or drops a variable this list (and the + // meta doc's data_info) must move together. + assert_eq!( + CCMPWIND_CONFIG.allowed_data_vars, + &["uwnd", "vwnd", "ws", "nobs"] + ); + } } diff --git a/api/src/helpers/schema.rs b/api/src/helpers/schema.rs index 6d684b2..76dbfc6 100644 --- a/api/src/helpers/schema.rs +++ b/api/src/helpers/schema.rs @@ -431,6 +431,114 @@ impl IsTimeseriesMeta for CopernicusSlaMeta { } } +// ccmp wind ////////////////////////////////////////////////////////////////// + +/// One spatial cell of the CCMP wind grid. Surface-only (no vertical +/// dimension; `level` is always `0.0`), exactly the Copernicus SLA +/// shape. `data` holds the timeseries per variable — up to four (uwnd, +/// vwnd, ws, nobs), ordered per the meta doc's `data_info`. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CcmpWindSchema { + pub(crate) _id: String, + // Reachable from main.rs (batchmeta branch reads `metadata()`), so + // `pub` for symmetry with the other schemas. + pub metadata: Vec, + pub(crate) basin: f64, + pub(crate) geolocation: GeoJSONPoint, + pub(crate) level: f64, + // Omitted from the response when empty (no `data=` qsp); see the + // matching annotation on `BsoseSchema.data` for the full reasoning. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) data: Vec>, + // Like the other surface grids, data docs don't carry `timeseries` + // or `data_info` of their own — both are populated at request time + // per the response-shape rule (see the annotations on `OisstSchema`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) timeseries: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) data_info: Option, +} + +impl IsTimeseries for CcmpWindSchema { + fn get_timeseries(&self) -> bool { + return true; + } + + fn data(&mut self) -> &mut Vec> { + &mut self.data + } + + fn set_data(&mut self, data: Vec>) { + self.data = data; + } + + fn timeseries(&mut self) -> Option<&mut Vec> { + self.timeseries.as_mut() + } + + fn set_timeseries(&mut self, timeseries: Vec) { + self.timeseries = Some(timeseries); + } + + fn data_info(&mut self) -> Option { + self.data_info.clone() + } + + fn set_data_info(&mut self, data_info: Option) { + self.data_info = data_info; + } + + fn _id(&self) -> String { + self._id.clone() + } + + fn longitude(&self) -> f64 { + self.geolocation.coordinates[0] + } + + fn latitude(&self) -> f64 { + self.geolocation.coordinates[1] + } + + fn level(&self) -> f64 { + self.level + } + + fn metadata(&self) -> Vec { + self.metadata.clone() + } +} + +/// Metadata doc for the CCMP wind dataset. Same layout as +/// `CopernicusSlaMeta` / `OisstMeta` — `data_info` lives here +/// (per-dataset default) rather than on every data doc, and the +/// `source` / `lattice` substructures follow the same pipeline +/// conventions, so those structs are reused directly. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CcmpWindMeta { + pub(crate) _id: String, + pub(crate) data_type: String, + pub data_info: DataInfo, + pub(crate) date_updated_argovis: BsonDateTime, + pub timeseries: Vec, + pub(crate) source: Vec, + pub(crate) lattice: Lattice, +} + +impl IsTimeseriesMeta for CcmpWindMeta { + fn get_timeseries_meta(&self) -> bool { + return true; + } + + fn timeseries(&self) -> Vec { + self.timeseries.clone() + } + + fn data_info(&self) -> DataInfo { + self.data_info.clone() + } +} + // /////////////////////////////////////////////////////////////////////////// #[derive(Deserialize, Debug, Clone)] diff --git a/api/src/main.rs b/api/src/main.rs index e2ecac6..6a99ee6 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -55,6 +55,7 @@ use dataset_config::{DatasetConfig, DatasetSource}; static BSOSE_SOURCE: Lazy>> = Lazy::new(|| Mutex::new(None)); static OISST_SOURCE: Lazy>> = Lazy::new(|| Mutex::new(None)); static COPERNICUSSLA_SOURCE: Lazy>> = Lazy::new(|| Mutex::new(None)); +static CCMPWIND_SOURCE: Lazy>> = Lazy::new(|| Mutex::new(None)); // ---- route handlers -------------------------------------------------------- // @@ -133,6 +134,27 @@ async fn copernicussla_handler( .await } +#[get("/timeseries/ccmpwind")] +async fn ccmpwind_handler( + req: HttpRequest, + query_params: web::Query, +) -> impl Responder { + let source = CCMPWIND_SOURCE + .lock() + .unwrap() + .as_ref() + .expect("CCMPWIND_SOURCE not initialized at startup") + .clone(); + + serve_timeseries::( + req, + query_params.into_inner(), + &dataset_config::CCMPWIND_CONFIG, + &source, + ) + .await +} + // ---- generic timeseries handler -------------------------------------------- /// Generic body of the `/timeseries/{dataset}` endpoint. Parameterized by @@ -547,17 +569,34 @@ async fn main() -> std::io::Result<()> { enabled_copernicussla = true; } - if !enabled_bsose && !enabled_oisst && !enabled_copernicussla { + let mut enabled_ccmpwind = false; + if let Some(client) = dataset_client("MONGODB_URI_CCMPWIND").await { + let ccmpwind = load_dataset_source::( + client, + "argo", + "ccmpwind", + "timeseriesMeta", + "ccmpwind", + ) + .await + .expect("failed to load CCMP wind dataset source at startup"); + *CCMPWIND_SOURCE.lock().unwrap() = Some(ccmpwind); + enabled_ccmpwind = true; + } + + if !enabled_bsose && !enabled_oisst && !enabled_copernicussla && !enabled_ccmpwind { eprintln!( "warning: no datasets enabled. Set at least one of \ - MONGODB_URI_BSOSE / MONGODB_URI_NOAAOISST / MONGODB_URI_COPERNICUSSLA." + MONGODB_URI_BSOSE / MONGODB_URI_NOAAOISST / \ + MONGODB_URI_COPERNICUSSLA / MONGODB_URI_CCMPWIND." ); } else { println!( - "Datasets enabled:{}{}{}", + "Datasets enabled:{}{}{}{}", if enabled_bsose { " bsose" } else { "" }, if enabled_oisst { " noaaoisst" } else { "" }, if enabled_copernicussla { " copernicussla" } else { "" }, + if enabled_ccmpwind { " ccmpwind" } else { "" }, ); } @@ -575,6 +614,9 @@ async fn main() -> std::io::Result<()> { if enabled_copernicussla { cfg.service(copernicussla_handler); } + if enabled_ccmpwind { + cfg.service(ccmpwind_handler); + } }) }) .bind(("0.0.0.0", 8080))?