From b0f54f96bddffb00d9e13ab4f1407fbe98af0bd2 Mon Sep 17 00:00:00 2001 From: katieannemills Date: Tue, 7 Jul 2026 15:34:15 -0400 Subject: [PATCH] functional draft of copernicus route --- api/src/helpers/dataset_config.rs | 68 +++++++++++++++++++ api/src/helpers/schema.rs | 107 ++++++++++++++++++++++++++++++ api/src/main.rs | 47 ++++++++++++- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/api/src/helpers/dataset_config.rs b/api/src/helpers/dataset_config.rs index 3962100..3e83573 100644 --- a/api/src/helpers/dataset_config.rs +++ b/api/src/helpers/dataset_config.rs @@ -160,6 +160,27 @@ pub const OISST_CONFIG: DatasetConfig = DatasetConfig { allowed_data_vars: &["sst"], }; +/// Copernicus SLA is a sea-surface product: like OI SST, a single +/// vertical level modeled as a one-element levels array of 0.0 so the +/// tile_generator / filter_composer path works unchanged. See the +/// comment on `OISST_LEVELS` for the mechanics. +pub const COPERNICUSSLA_LEVELS: &[f64] = &[0.0]; + +/// Configuration for the Copernicus sea level anomaly timeseries dataset. +/// +/// Surface-only, global coverage. Tile size and radius cap deliberately +/// match OI SST (5° / 100 km) — same uniformity argument, same "relax +/// once usage informs us" caveat. Six variables: sea level anomaly, +/// absolute dynamic topography, and the geostrophic velocity components +/// for each (u/v, anomaly and absolute). +pub const COPERNICUSSLA_CONFIG: DatasetConfig = DatasetConfig { + tile_degrees: 5.0, + max_radius_meters: 100_000.0, // 100 km — same starting cap as OI SST + levels: COPERNICUSSLA_LEVELS, + coverage_bbox: None, + allowed_data_vars: &["sla", "adt", "ugosa", "ugos", "vgosa", "vgos"], +}; + #[cfg(test)] mod tests { use super::*; @@ -268,4 +289,51 @@ mod tests { // OI SST is global; no coverage_bbox skip available. assert!(OISST_CONFIG.coverage_bbox.is_none()); } + + // ---- Copernicus SLA config invariants (mirror the OI SST checks) ------- + + #[test] + fn copernicussla_tile_degrees_is_positive_and_divides_a_hemisphere() { + assert!(COPERNICUSSLA_CONFIG.tile_degrees > 0.0); + assert!( + (180.0_f64 % COPERNICUSSLA_CONFIG.tile_degrees).abs() < 1e-9, + "tile_degrees should evenly divide 180° for clean global coverage" + ); + assert!( + (360.0_f64 % COPERNICUSSLA_CONFIG.tile_degrees).abs() < 1e-9, + "tile_degrees should evenly divide 360° for clean global coverage" + ); + } + + #[test] + fn copernicussla_max_radius_is_positive_and_subhemispheric() { + assert!(COPERNICUSSLA_CONFIG.max_radius_meters > 0.0); + assert!(COPERNICUSSLA_CONFIG.max_radius_meters < 1.0e7); + } + + #[test] + fn copernicussla_has_exactly_one_surface_level() { + // Sea level anomaly is by construction a surface product; the + // single-element levels array keeps the tile generator on the + // no-special-case path (see OI SST). + assert_eq!(COPERNICUSSLA_CONFIG.levels.len(), 1); + assert!((COPERNICUSSLA_CONFIG.levels[0] - 0.0).abs() < 1e-9); + } + + #[test] + fn copernicussla_has_global_coverage() { + // Altimetry-derived SLA is global; no coverage_bbox skip available. + assert!(COPERNICUSSLA_CONFIG.coverage_bbox.is_none()); + } + + #[test] + fn copernicussla_advertises_all_six_variables() { + // sla/adt plus u/v geostrophic velocities in anomaly and absolute + // flavours. If the upstream product adds or drops a variable this + // list (and the meta doc's data_info) must move together. + assert_eq!( + COPERNICUSSLA_CONFIG.allowed_data_vars, + &["sla", "adt", "ugosa", "ugos", "vgosa", "vgos"] + ); + } } diff --git a/api/src/helpers/schema.rs b/api/src/helpers/schema.rs index 8a7114b..6d684b2 100644 --- a/api/src/helpers/schema.rs +++ b/api/src/helpers/schema.rs @@ -324,6 +324,113 @@ impl IsTimeseriesMeta for OisstMeta { } } +// copernicus sla ///////////////////////////////////////////////////////////// + +/// One spatial cell of the Copernicus sea level anomaly grid. Surface-only +/// (no vertical dimension; `level` is always `0.0`), exactly the OI SST +/// shape. `data` holds the timeseries per variable — up to six (sla, adt, +/// ugosa, ugos, vgosa, vgos), ordered per the meta doc's `data_info`. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CopernicusSlaSchema { + 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 OI SST, 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 CopernicusSlaSchema { + 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 Copernicus SLA dataset. Same layout as +/// `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 CopernicusSlaMeta { + 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 CopernicusSlaMeta { + 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 10bd141..e2ecac6 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -54,6 +54,7 @@ use dataset_config::{DatasetConfig, DatasetSource}; // load-and-register block in main() and a route handler above. 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)); // ---- route handlers -------------------------------------------------------- // @@ -111,6 +112,27 @@ async fn oisst_handler( .await } +#[get("/timeseries/copernicussla")] +async fn copernicussla_handler( + req: HttpRequest, + query_params: web::Query, +) -> impl Responder { + let source = COPERNICUSSLA_SOURCE + .lock() + .unwrap() + .as_ref() + .expect("COPERNICUSSLA_SOURCE not initialized at startup") + .clone(); + + serve_timeseries::( + req, + query_params.into_inner(), + &dataset_config::COPERNICUSSLA_CONFIG, + &source, + ) + .await +} + // ---- generic timeseries handler -------------------------------------------- /// Generic body of the `/timeseries/{dataset}` endpoint. Parameterized by @@ -510,16 +532,32 @@ async fn main() -> std::io::Result<()> { enabled_oisst = true; } - if !enabled_bsose && !enabled_oisst { + let mut enabled_copernicussla = false; + if let Some(client) = dataset_client("MONGODB_URI_COPERNICUSSLA").await { + let copernicussla = load_dataset_source::( + client, + "argo", + "copernicusSLA", + "timeseriesMeta", + "sea_level_anomaly", + ) + .await + .expect("failed to load Copernicus SLA dataset source at startup"); + *COPERNICUSSLA_SOURCE.lock().unwrap() = Some(copernicussla); + enabled_copernicussla = true; + } + + if !enabled_bsose && !enabled_oisst && !enabled_copernicussla { eprintln!( "warning: no datasets enabled. Set at least one of \ - MONGODB_URI_BSOSE / MONGODB_URI_NOAAOISST." + MONGODB_URI_BSOSE / MONGODB_URI_NOAAOISST / MONGODB_URI_COPERNICUSSLA." ); } else { println!( - "Datasets enabled:{}{}", + "Datasets enabled:{}{}{}", if enabled_bsose { " bsose" } else { "" }, if enabled_oisst { " noaaoisst" } else { "" }, + if enabled_copernicussla { " copernicussla" } else { "" }, ); } @@ -534,6 +572,9 @@ async fn main() -> std::io::Result<()> { if enabled_oisst { cfg.service(oisst_handler); } + if enabled_copernicussla { + cfg.service(copernicussla_handler); + } }) }) .bind(("0.0.0.0", 8080))?