From a3550283fb15cda0df7ec3d6d34f77e2a1b8d25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Thu, 13 Aug 2026 16:15:14 +0200 Subject: [PATCH 1/5] Integrate CMIP/CORDEX climate-risk analysis --- backend/src/auth.rs | 1 + backend/src/handler.rs | 8 + backend/src/lib.rs | 1 + .../biodiversity_sensitive_areas/mod.rs | 1 + backend/src/processes/climate_risk/compute.rs | 819 +++++++++++++++ backend/src/processes/climate_risk/mod.rs | 517 ++++++++++ backend/src/processes/climate_risk/tests.rs | 933 ++++++++++++++++++ backend/src/processes/climate_risk/types.rs | 556 +++++++++++ .../src/processes/climate_risk/workflow.rs | 112 +++ .../processes/land_use_sealed_area/types.rs | 2 + backend/src/processes/mod.rs | 2 + .../src/processes/parameters/data_resource.rs | 82 +- backend/src/processes/parameters/mod.rs | 67 +- backend/src/processes/parameters/units.rs | 6 + backend/src/profile.rs | 98 ++ backend/src/server.rs | 6 +- .../src/app/create/create.component.spec.ts | 195 +++- frontend/src/app/create/create.component.ts | 23 +- .../app/create/inputs-visualizer.component.ts | 42 +- frontend/src/app/create/schema-info.spec.ts | 150 ++- frontend/src/app/create/schema-info.ts | 142 ++- frontend/src/app/create/simple-form-field.ts | 75 +- .../data-resource-table.component.spec.ts | 154 ++- .../result/data-resource-table.component.ts | 101 +- frontend/src/app/result/result.component.ts | 11 +- 25 files changed, 4014 insertions(+), 90 deletions(-) create mode 100644 backend/src/processes/climate_risk/compute.rs create mode 100644 backend/src/processes/climate_risk/mod.rs create mode 100644 backend/src/processes/climate_risk/tests.rs create mode 100644 backend/src/processes/climate_risk/types.rs create mode 100644 backend/src/processes/climate_risk/workflow.rs create mode 100644 backend/src/profile.rs diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 2168872..b83a7c6 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -139,6 +139,7 @@ impl GeoEngineAuthMiddleware { const_concat!("/processes/", BiodiversitySensitiveAreasProcess::ID), const_concat!("/processes/", HabitatDistanceProcess::ID), const_concat!("/processes/", LandUseSealedAreaProcess::ID), + "/profiles/table-schema/climate-risk/1.0/schema.json", ], prefix: vec!["/api", "/swagger", "/auth/"], }, diff --git a/backend/src/handler.rs b/backend/src/handler.rs index 04a28c4..718fe6b 100644 --- a/backend/src/handler.rs +++ b/backend/src/handler.rs @@ -5,6 +5,7 @@ use axum::{ extract::{Query, State}, http::StatusCode, response::IntoResponse, + routing::get, }; use geoengine_api_client::apis::{ configuration::Configuration, @@ -25,6 +26,13 @@ pub fn auth_router() -> OpenApiRouter { .routes(routes!(auth_request_url_handler)) } +pub fn profile_router() -> OpenApiRouter { + OpenApiRouter::new().route( + "/profiles/table-schema/climate-risk/1.0/schema.json", + get(crate::profile::climate_risk_table_schema_profile), + ) +} + #[utoipa::path(get, path = "/health", responses((status = NO_CONTENT)))] pub async fn health_handler() -> StatusCode { StatusCode::NO_CONTENT diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5a28e91..79af49b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -5,6 +5,7 @@ mod db; mod handler; mod jobs; mod processes; +mod profile; mod server; mod state; mod util; diff --git a/backend/src/processes/biodiversity_sensitive_areas/mod.rs b/backend/src/processes/biodiversity_sensitive_areas/mod.rs index 1ac6d63..fe98af1 100644 --- a/backend/src/processes/biodiversity_sensitive_areas/mod.rs +++ b/backend/src/processes/biodiversity_sensitive_areas/mod.rs @@ -806,6 +806,7 @@ fn site_row_into_output( }, ], primary_key: vec!["location".to_string()].into(), + ..Default::default() }, } } diff --git a/backend/src/processes/climate_risk/compute.rs b/backend/src/processes/climate_risk/compute.rs new file mode 100644 index 0000000..dc43129 --- /dev/null +++ b/backend/src/processes/climate_risk/compute.rs @@ -0,0 +1,819 @@ +use crate::profile::CLIMATE_RISK_TABLE_SCHEMA_PROFILE; +use crate::{ + processes::parameters::{ + BioisDisplayKind, BioisDisplayMetadata, BioisTableSchemaExtension, BoundingBox, + DataResource, Fields, TableSchemaField, TableSchemaType, Year, YearRange, + }, + util::{error_response, to_api_vector_process}, +}; +use anyhow::Result; +use futures::{TryStreamExt, stream::StreamExt}; +use geoengine_api_client::{ + apis::{ + configuration::Configuration, ogcwfs_api::WfsHandlerError, ogcwfs_api::wfs_handler, + workflows_api::register_workflow_handler, + }, + models::{ + ColumnNames, Coordinate2D, FeatureAggregationMethod, GeoJson, MockPointSource, + MockPointSourceParameters, Names, RasterVectorJoin, RasterVectorJoinParameters, + SingleVectorMultipleRasterSources, SpatialBoundsDerive, SpatialBoundsDeriveNone, + TemporalAggregationMethod, VectorOperator, WfsRequest, WfsService, + }, +}; +use geojson::PointType; +use ogcapi::types::processes::{ + ExecuteResult, ExecuteResults, Format, InlineOrRefData, InputValue, Output, QualifiedInputValue, +}; +use std::collections::HashMap; +use tracing::instrument; + +use super::ClimateRiskProcess; +use super::types::*; +pub(crate) fn climate_risk_data_resource( + rows: Vec, + analysis_period: &str, + reference_period: Option<&str>, +) -> DataResource> { + let mut fields = vec![TableSchemaField { + name: "scenario".into(), + r#type: Some(TableSchemaType::String), + title: Some("Scenario".into()), + ..Default::default() + }]; + fields.extend(risk_fields(&rows, reference_period)); + let name = if analysis_period.is_empty() { + "Climate Risk".to_string() + } else { + format!("Climate Risk · {analysis_period}") + }; + let biois = climate_display_extension(&rows); + DataResource { + name, + data: rows, + schema: Fields { + fields, + primary_key: Some(vec!["variable".to_string(), "scenario".to_string()]), + schema: Some(CLIMATE_RISK_TABLE_SCHEMA_PROFILE.to_string()), + biois: Some(biois), + }, + } +} + +pub(crate) fn climate_risk_scenario_data_resource( + scenario_name: &str, + rows: Vec, + analysis_period: &str, + reference_period: Option<&str>, +) -> DataResource> { + let fields = risk_fields(&rows, reference_period); + let name = if analysis_period.is_empty() { + scenario_name.to_string() + } else { + format!("{scenario_name} · {analysis_period}") + }; + let biois = climate_display_extension(&rows); + DataResource { + name, + data: rows, + schema: Fields { + fields, + primary_key: Some(vec!["variable".to_string()]), + schema: Some(CLIMATE_RISK_TABLE_SCHEMA_PROFILE.to_string()), + biois: Some(biois), + }, + } +} + +/// Shared column layout for climate-risk tables, excluding any scenario column. +fn risk_fields(rows: &[ClimateRiskRow], reference_period: Option<&str>) -> Vec { + let mut fields = vec![ + TableSchemaField { + name: "variable".into(), + r#type: Some(TableSchemaType::String), + title: Some("Variable".into()), + ..Default::default() + }, + TableSchemaField { + name: "mean".into(), + r#type: Some(TableSchemaType::Number), + title: Some("Mean (days/year)".into()), + ..Default::default() + }, + TableSchemaField { + name: "min".into(), + r#type: Some(TableSchemaType::Number), + title: Some("Min (days/year)".into()), + ..Default::default() + }, + TableSchemaField { + name: "max".into(), + r#type: Some(TableSchemaType::Number), + title: Some("Max (days/year)".into()), + ..Default::default() + }, + TableSchemaField { + name: "occurrenceProbability".into(), + r#type: Some(TableSchemaType::Number), + title: Some("Occurrence Probability".into()), + ..Default::default() + }, + TableSchemaField { + name: "occurrenceProbabilityLabel".into(), + r#type: Some(TableSchemaType::String), + title: Some("Occurrence Probability Label".into()), + ..Default::default() + }, + TableSchemaField { + name: "occurrenceProbabilityColor".into(), + r#type: Some(TableSchemaType::String), + title: Some("Occurrence Probability Color".into()), + ..Default::default() + }, + ]; + if rows.iter().any(|row| row.anomaly.is_some()) { + fields.extend([ + TableSchemaField { + name: "anomaly".into(), + r#type: Some(TableSchemaType::Number), + title: Some(anomaly_title(reference_period)), + ..Default::default() + }, + TableSchemaField { + name: "anomalyLabel".into(), + r#type: Some(TableSchemaType::String), + title: Some("Anomaly Label".into()), + ..Default::default() + }, + TableSchemaField { + name: "anomalyColor".into(), + r#type: Some(TableSchemaType::String), + title: Some("Anomaly Color".into()), + ..Default::default() + }, + ]); + } + fields +} + +pub(crate) fn raw_ensemble_data_resource( + rows: Vec, +) -> DataResource> { + DataResource { + name: "Raw Ensemble Data".to_string(), + data: rows, + schema: Fields { + fields: vec![ + TableSchemaField { + name: "variable".into(), + r#type: Some(TableSchemaType::String), + title: Some("Variable".into()), + ..Default::default() + }, + TableSchemaField { + name: "scenario".into(), + r#type: Some(TableSchemaType::String), + title: Some("Scenario".into()), + ..Default::default() + }, + TableSchemaField { + name: "model".into(), + r#type: Some(TableSchemaType::String), + title: Some("Model".into()), + ..Default::default() + }, + TableSchemaField { + name: "value".into(), + r#type: Some(TableSchemaType::Number), + title: Some("Value".into()), + ..Default::default() + }, + ], + primary_key: Some(vec![ + "variable".to_string(), + "scenario".to_string(), + "model".to_string(), + ]), + ..Default::default() + }, + } +} + +fn climate_display_extension(rows: &[ClimateRiskRow]) -> BioisTableSchemaExtension { + let has_anomaly = rows.iter().any(|row| row.anomaly.is_some()); + let mut display = HashMap::from([( + "occurrenceProbability".to_string(), + BioisDisplayMetadata { + kind: BioisDisplayKind::RiskProbability, + label_field: Some("occurrenceProbabilityLabel".to_string()), + color_field: Some("occurrenceProbabilityColor".to_string()), + }, + )]); + if has_anomaly { + display.insert( + "anomaly".to_string(), + BioisDisplayMetadata { + kind: BioisDisplayKind::RiskAnomaly, + label_field: Some("anomalyLabel".to_string()), + color_field: Some("anomalyColor".to_string()), + }, + ); + } + BioisTableSchemaExtension { + display, + hidden_fields: [ + "occurrenceProbabilityLabel", + "occurrenceProbabilityColor", + "anomalyLabel", + "anomalyColor", + ] + .into_iter() + // The probability label/color columns are always hidden; the anomaly ones only + // when an anomaly column is actually present. + .filter(|field| { + matches!( + *field, + "occurrenceProbabilityLabel" | "occurrenceProbabilityColor" + ) || (has_anomaly && matches!(*field, "anomalyLabel" | "anomalyColor")) + }) + .map(str::to_string) + .collect(), + } +} + +impl From for ExecuteResults { + fn from(outputs: ClimateRiskOutputs) -> Self { + let mut result = ExecuteResults::default(); + + if let Some(inputs) = outputs.inputs + && let Some(value) = build_inputs_value(&inputs) + { + result.insert("inputs".to_string(), value); + } + + if let Some(climate_risk) = outputs.climate_risk { + let mut rows_by_scenario: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for row in climate_risk.data { + rows_by_scenario + .entry(row.scenario.clone()) + .or_default() + .push(row); + } + for (scenario, rows) in rows_by_scenario { + let analysis_period = outputs.analysis_period.as_deref().unwrap_or(""); + match climate_risk_scenario_data_resource( + &scenario, + rows, + analysis_period, + outputs.reference_period.as_deref(), + ) + .to_input_value() + { + Ok(value) => { + result.insert( + scenario, + ExecuteResult { + output: Output { + format: Some(json_format()), + transmission_mode: Default::default(), + }, + data: InlineOrRefData::QualifiedInputValue(QualifiedInputValue { + value, + format: Format { + media_type: Some( + "application/vnd.dataresource+json".to_string(), + ), + encoding: None, + schema: None, + }, + }), + }, + ); + } + Err(error) => tracing::warn!( + "Failed to serialize the climate-risk output for scenario `{scenario}`: {error}" + ), + } + } + } + + if let Some(raw_ensemble_data) = outputs.raw_ensemble_data { + match raw_ensemble_data.to_input_value() { + Ok(value) => { + result.insert( + "rawEnsembleData".to_string(), + ExecuteResult { + output: Output { + format: Some(json_format()), + transmission_mode: Default::default(), + }, + data: InlineOrRefData::QualifiedInputValue(QualifiedInputValue { + value, + format: Format { + media_type: Some( + "application/vnd.dataresource+json".to_string(), + ), + encoding: None, + schema: None, + }, + }), + }, + ); + } + Err(error) => { + tracing::warn!("Failed to serialize the raw ensemble data output: {error}") + } + } + } + + result + } +} + +fn json_format() -> Format { + Format { + media_type: Some("application/json".to_string()), + encoding: Some("utf-8".to_string()), + schema: None, + } +} + +/// Converts a serialized object into the OGC API's qualified JSON input value. +fn build_qualified_value(object_map: serde_json::Map) -> ExecuteResult { + ExecuteResult { + output: Output { + format: None, + transmission_mode: Default::default(), + }, + data: InlineOrRefData::QualifiedInputValue(QualifiedInputValue { + value: InputValue::Object(object_map), + format: Format { + media_type: Some("application/json".to_string()), + encoding: Some("utf-8".to_string()), + schema: None, + }, + }), + } +} + +/// Serializes typed inputs at the OGC API boundary, warning and dropping on failure. +fn build_inputs_value(inputs: &ClimateRiskInputs) -> Option { + let Ok(value) = serde_json::to_value(inputs) else { + tracing::warn!("Failed to serialize the inputs output"); + return None; + }; + match value { + serde_json::Value::Object(object_map) => Some(build_qualified_value(object_map)), + other => { + tracing::warn!("Unexpected non-object inputs serialization: {other}"); + None + } + } +} +/// One geoengine workflow together with the metadata needed to interpret its results. +struct WorkflowRequest { + models: Vec, + variable: ClimateVariable, + scenario: ClimateScenarioProperties, + workflow: geoengine_api_client::models::Workflow, +} + +/// Builds one workflow per (variable, scenario) pair, using only models that support the scenario. +fn build_workflows( + coordinate: &PointType, + requests: &[(ClimateVariableRequest, ClimateScenarioProperties)], + models: &[CordexModelProperties], + region: &CordexRegionProperties, +) -> Vec { + requests + .iter() + .filter_map(|(var_req, scenario_props)| { + let compatible_models: Vec = models + .iter() + .filter(|model| model.scenarios.contains(&scenario_props.scenario)) + .cloned() + .collect(); + if compatible_models.is_empty() { + return None; + } + + let variable_properties = var_req.variable.properties(); + let raster_sources = compatible_models + .iter() + .map(|model| { + ClimateRiskProcess::build_variable_year_agg_workflow( + &variable_properties, + model, + scenario_props, + region, + ) + }) + .collect::>(); + let model_var_names: Vec = compatible_models + .iter() + .map(|model| model.model.name().to_string()) + .collect(); + + let workflow = to_api_vector_process(&VectorOperator::RasterVectorJoin( + RasterVectorJoin { + r#type: Default::default(), + params: RasterVectorJoinParameters { + names: ColumnNames::Names( + Names { + r#type: Default::default(), + values: model_var_names, + } + .into(), + ) + .into(), + feature_aggregation: FeatureAggregationMethod::First, + feature_aggregation_ignore_no_data: Some(false), + temporal_aggregation: TemporalAggregationMethod::None, + temporal_aggregation_ignore_no_data: Some(false), + } + .into(), + sources: SingleVectorMultipleRasterSources { + vector: vector_source(coordinate).into(), + rasters: raster_sources, + } + .into(), + } + .into(), + )); + Some(WorkflowRequest { + models: compatible_models, + variable: var_req.variable, + scenario: scenario_props.clone(), + workflow, + }) + }) + .collect() +} + +// bounds geoengine fan-out; the request count is finite but a single user +// request can register ~18 workflows and run ~36 WFS queries. +const MAX_CONCURRENT_GEOENGINE_REQUESTS: usize = 8; + +/// Runs async jobs with bounded concurrency, preserving input order. +async fn run_limited(jobs: Vec) -> Result, E> +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + futures::stream::iter(jobs.into_iter().map(|job| job())) + .buffer_unordered(MAX_CONCURRENT_GEOENGINE_REQUESTS) + .try_collect() + .await +} + +/// Formats a geoengine API error, including the response body when available. +fn unpack_join_error(error: &geoengine_api_client::apis::Error, what: &str) -> anyhow::Error { + if let Some(response) = error_response(error) { + anyhow::anyhow!("Failed to {what} `{error}`: {response:?}") + } else { + anyhow::anyhow!("Failed to {what} `{error}`") + } +} + +async fn register_workflows( + configuration: &Configuration, + requests: &[WorkflowRequest], +) -> Result> { + let workflow_ids = run_limited( + requests + .iter() + .map(|request| { + let workflow = &request.workflow; + move || async move { + register_workflow_handler(configuration, workflow.clone()) + .await + .map(|id| id.id.to_string()) + } + }) + .collect(), + ) + .await + .map_err(|error| unpack_join_error(&error, "register a workflow"))?; + Ok(workflow_ids) +} + +async fn query_workflows( + configuration: &Configuration, + workflow_ids: &[String], + bbox_string: &str, + time: &str, +) -> Result> { + run_limited( + workflow_ids + .iter() + .map(|id| move || async move { wfs_query(configuration, id, bbox_string, time).await }) + .collect(), + ) + .await + .map_err(|error| unpack_join_error(&error, "execute a workflow")) +} + +/// Aggregates the per-workflow WFS results into climate-risk and raw-ensemble rows. +fn aggregate_rows( + analysis_results: Vec, + reference_results: Option<&[GeoJson]>, + workflow_requests: &[WorkflowRequest], +) -> Result<(Vec, Vec)> { + let mut rows = Vec::new(); + let mut raw_rows = Vec::new(); + for (i, analysis) in analysis_results.into_iter().enumerate() { + let request = &workflow_requests[i]; + let var_props = request.variable.properties(); + let model_values = outputs_from_feature_collection(&analysis, &request.models)?; + if let Some(aggregated) = aggregate_from_list(&model_values) { + let reference = + reference_results.and_then( + |reference_results| match outputs_from_feature_collection( + &reference_results[i], + &request.models, + ) { + Ok(reference_values) => { + aggregate_from_list(&reference_values).map(|r| r.mean) + } + Err(error) => { + tracing::warn!( + "Failed to compute reference-period values for {}: {error}", + var_props.name_string() + ); + None + } + }, + ); + let anomaly = reference.map(|reference_mean| aggregated.mean - reference_mean); + let anomaly_pct = + reference.map(|reference_mean| anomaly_pct(aggregated.mean, reference_mean)); + rows.push(ClimateRiskRow { + variable: var_props.name_string(), + scenario: request.scenario.name.to_string(), + mean: aggregated.mean, + median: aggregated.median, + min: aggregated.min, + max: aggregated.max, + occurrence_probability: aggregated.occurrence_probability, + anomaly, + occurrence_probability_label: aggregated + .occurrence_probability + .map(probability_label), + occurrence_probability_color: aggregated + .occurrence_probability + .map(probability_color), + anomaly_label: anomaly + .zip(anomaly_pct) + .map(|(days, pct)| anomaly_label(days, pct)), + anomaly_color: anomaly_pct.map(percentage_color), + }); + if let Some(raw_members) = aggregated.raw_members { + for (model_name, value) in raw_members { + raw_rows.push(ClimateRiskRawRow { + variable: var_props.name_string(), + scenario: request.scenario.name.to_string(), + model: model_name, + value, + }); + } + } + } + } + Ok((rows, raw_rows)) +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip(configuration), err(Debug))] +pub(crate) async fn compute_climate( + configuration: &Configuration, + coordinate: &PointType, + Year(start_year): Year, + YearRange(range): YearRange, + reference_year: Option, + requests: &[(ClimateVariableRequest, ClimateScenarioProperties)], + models: &[CordexModelProperties], + region: &CordexRegionProperties, +) -> Result { + const POINT_BBOX_HALF_SPAN: f64 = 0.0001; + if requests.is_empty() || models.is_empty() { + return Ok(ClimateRiskOutputs::default()); + } + + let end_analysis = start_year + range; + let time_str_analysis = + format!("{start_year:04}-01-01T00:00:00Z/{end_analysis:04}-01-01T00:00:00Z"); + let analysis_period = format!("{start_year:04}–{:04}", end_analysis - 1); + let reference_time = reference_year.map(|Year(reference_year)| { + let end_reference = reference_year + range; + format!("{reference_year:04}-01-01T00:00:00Z/{end_reference:04}-01-01T00:00:00Z") + }); + let reference_period = reference_year + .map(|Year(reference_year)| format!("{reference_year:04}–{}", reference_year + range - 1)); + let bbox = BoundingBox::around_point(coordinate, POINT_BBOX_HALF_SPAN); + let bbox_string = bbox.wfs_string(); + + let workflow_requests = build_workflows(coordinate, requests, models, region); + let workflow_ids = register_workflows(configuration, &workflow_requests).await?; + + for (workflow_id, request) in workflow_ids.iter().zip(&workflow_requests) { + tracing::debug!( + "ClimateRisk: registered workflow: variable={:?}, scenario={:?}, workflow_id={}", + request.variable.name(), + request.scenario.scenario.name(), + workflow_id, + ); + } + + let analysis_results = query_workflows( + configuration, + &workflow_ids, + &bbox_string, + &time_str_analysis, + ) + .await?; + + let reference_results = match &reference_time { + Some(reference_time) => { + Some(query_workflows(configuration, &workflow_ids, &bbox_string, reference_time).await?) + } + None => None, + }; + + for (i, analysis) in analysis_results.iter().enumerate() { + for (j, feature) in analysis.features.iter().enumerate() { + if let Some(props) = feature.get("properties") { + let request = &workflow_requests[i]; + tracing::debug!( + "ClimateRisk: WFS result: variable={}, scenario={}, feature={}, properties={}", + request.variable.name(), + request.scenario.scenario.name(), + j, + props, + ); + } + } + } + + let (rows, raw_rows) = aggregate_rows( + analysis_results, + reference_results.as_deref(), + &workflow_requests, + )?; + + let climate_risk = Some(climate_risk_data_resource( + rows, + &analysis_period, + reference_period.as_deref(), + )); + + Ok(ClimateRiskOutputs { + analysis_period: Some(analysis_period), + reference_period, + climate_risk, + raw_ensemble_data: if raw_rows.is_empty() { + None + } else { + Some(raw_ensemble_data_resource(raw_rows)) + }, + inputs: None, + }) +} + +async fn wfs_query( + configuration: &Configuration, + workflow_id: &str, + bbox: &str, + time: &str, +) -> Result> { + wfs_handler( + configuration, + workflow_id, + WfsRequest::GetFeature, + Some(bbox), + None, + None, + None, + None, + None, + Some(WfsService::Wfs), + None, + Some("EPSG:4326"), + Some(time), + None, + None, + ) + .await +} + +pub(crate) fn vector_source(coordinate: &PointType) -> VectorOperator { + VectorOperator::MockPointSource( + MockPointSource { + r#type: Default::default(), + params: MockPointSourceParameters { + points: vec![Coordinate2D::new(coordinate[0], coordinate[1])], + spatial_bounds: SpatialBoundsDerive::None( + SpatialBoundsDeriveNone { + r#type: Default::default(), + } + .into(), + ) + .into(), + } + .into(), + } + .into(), + ) +} + +pub(crate) fn aggregate_from_list( + model_values: &HashMap, +) -> Option { + if model_values.is_empty() { + return None; + } + + let values: Vec = model_values.values().copied().collect(); + let min = values.iter().copied().reduce(f64::min).unwrap_or(0.0); + let max = values.iter().copied().reduce(f64::max).unwrap_or(0.0); + let mean = values.iter().sum::() / values.len() as f64; + + let mut sorted = values.clone(); + sorted.sort_by(f64::total_cmp); + let mid = sorted.len() / 2; + let median = if sorted.len().is_multiple_of(2) { + f64::midpoint(sorted[mid - 1], sorted[mid]) + } else { + sorted[mid] + }; + + let raw_members = model_values + .iter() + .map(|(k, v)| (k.name().to_string(), *v)) + .collect(); + + Some(ClimateVariableResult { + max, + min, + mean, + median, + occurrence_probability: Some(mean / DAYS_PER_JULIAN_YEAR), + raw_members: Some(raw_members), + }) +} + +const NO_MODEL_COVERAGE_ERROR: &str = + "Input coordinate not covered by any of the requested climate models for the given time range."; + +pub(crate) fn outputs_from_feature_collection( + feature_collection: &GeoJson, + variables: &[CordexModelProperties], +) -> Result> { + if feature_collection.features.is_empty() { + anyhow::bail!(NO_MODEL_COVERAGE_ERROR); + } + + // One feature per time step (year); average each model's per-year values to get the + // multi-year mean. + let mut acc: HashMap> = HashMap::new(); + let mut models_without_data = Vec::new(); + let mut models_with_invalid_data = Vec::new(); + + for feature in &feature_collection.features { + let Some(properties) = feature.get("properties") else { + continue; + }; + + for model in variables { + let model_name = model.model.name(); + if let Some(value) = properties.get(model_name) { + if let Some(value) = value.as_f64().or_else(|| value.as_i64().map(|v| v as f64)) { + acc.entry(model.model).or_default().push(value); + } else if !models_with_invalid_data.contains(&model_name) { + models_with_invalid_data.push(model_name); + } + } else if !models_without_data.contains(&model_name) { + models_without_data.push(model_name); + } + } + } + + // Log once per model instead of once per feature × model. + for model_name in models_without_data { + tracing::warn!("No data found for model {model_name} in feature properties."); + } + for model_name in models_with_invalid_data { + tracing::warn!("Invalid data type for model {model_name} in feature properties."); + } + + if acc.is_empty() { + anyhow::bail!(NO_MODEL_COVERAGE_ERROR); + } + + Ok(acc + .into_iter() + .map(|(model, values)| { + let mean = values.iter().sum::() / values.len() as f64; + (model, mean) + }) + .collect()) +} diff --git a/backend/src/processes/climate_risk/mod.rs b/backend/src/processes/climate_risk/mod.rs new file mode 100644 index 0000000..cc69ebd --- /dev/null +++ b/backend/src/processes/climate_risk/mod.rs @@ -0,0 +1,517 @@ +use crate::{ + config::CONFIG, + processes::parameters::{DataResource, PointGeoJsonInput, Year, YearRange}, + state::USER, +}; +use anyhow::{Context, Result}; +use geojson::PointType; +use ogcapi::{ + processes::Processor, + types::{ + common::Link, + processes::{ + Execute, ExecuteResults, JobControlOptions, Process, ProcessSummary, TransmissionMode, + description::{DescriptionType, InputDescription, Metadata, OutputDescription}, + }, + }, +}; +use schemars::generate::SchemaSettings; +use std::collections::HashMap; + +mod compute; +mod types; +mod workflow; + +use self::{compute::*, types::*}; +/// Calculates climate-risk indicators for a given point and time window. +#[derive(Debug, Clone)] +pub struct ClimateRiskProcess; + +#[cfg(test)] +mod tests; +/// Generate the JSON Schema for the `models` input and attach `enumNames` hints +/// that list the scenarios each model is available for. +fn models_schema_with_hints(generator: &mut schemars::SchemaGenerator) -> serde_json::Value { + let mut schema = generator.root_schema_for::>().to_value(); + + if let Some(items) = schema.get_mut("items").and_then(|i| i.as_object_mut()) + && let Some(enum_values) = items.get("enum").and_then(|e| e.as_array()) + { + let enum_names: Vec = enum_values + .iter() + .filter_map(|v| v.as_str()) + .map(|model_value| { + CordexModel::ALL + .iter() + .find(|model| model.name() == model_value) + .map_or_else( + || model_value.to_string(), + |model| { + let props = model.properties(); + let scenarios = props + .scenarios + .iter() + .map(|s| s.properties().name) + .collect::>() + .join(", "); + format!("{} ({})", props.name, scenarios) + }, + ) + }) + .collect(); + items.insert( + "enumNames".to_string(), + serde_json::to_value(enum_names).unwrap_or_default(), + ); + } + + schema +} + +#[async_trait::async_trait] +impl Processor for ClimateRiskProcess { + fn id(&self) -> &'static str { + "climate-risk" + } + + fn version(&self) -> &'static str { + "0.1.0" + } + + #[allow(clippy::too_many_lines)] + fn process(&self) -> Result { + let mut settings = SchemaSettings::default(); + settings.meta_schema = None; + let mut generator = settings.into_generator(); + + let mut reference_year_begin_schema = + generator.root_schema_for::>().to_value(); + reference_year_begin_schema["default"] = serde_json::json!(DATA_START_YEAR); + + let inputs = HashMap::from([ + ( + "coordinate".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Coordinate in WGS84".to_string()), + description: Some("This is a POINT input in WGS84 (EPSG:4326) format.".to_string()), + ..Default::default() + }, + schema: generator.root_schema_for::().to_value(), + ..Default::default() + }, + ), + ( + "yearBegin".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Start year".to_string()), + description: Some("The first year to include in the climate-risk aggregation.".to_string()), + ..Default::default() + }, + schema: generator.root_schema_for::().to_value(), + ..Default::default() + }, + ), + ( + "yearRange".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Range (years)".to_string()), + description: Some( + "Length of the climate-risk aggregation window in years (5-30).".to_string(), + ), + ..Default::default() + }, + schema: generator.root_schema_for::().to_value(), + ..Default::default() + }, + ), + ( + "referenceYearBegin".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Reference period start".to_string()), + description: Some( + "First year of the reference period used to compute anomalies. Uses the same range as the analysis window. Disable the input to turn off anomaly computation.".to_string(), + ), + metadata: vec![Metadata { + title: None, + role: Some("enabled-by-default".to_string()), + href: None, + }], + ..Default::default() + }, + schema: reference_year_begin_schema, + min_occurs: Some(0), + ..Default::default() + }, + ), + ( + "variables".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Climate variables".to_string()), + description: Some( + "The climate indicators to derive from the source dataset. If empty, all available indicators are computed.".to_string(), + ), + ..Default::default() + }, + schema: generator.root_schema_for::>().to_value(), + min_occurs: Some(0), + ..Default::default() + }, + ), + ( + "models".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Climate models".to_string()), + description: Some("The climate-model workflows to execute for each requested variable.".to_string()), + ..Default::default() + }, + schema: models_schema_with_hints(&mut generator), + ..Default::default() + }, + ), + ( + "region".to_string(), + InputDescription { + description_type: DescriptionType { + title: Some("Climate data region".to_string()), + description: Some("The climate-data region to use for the risk aggregation. If not specified, the region will be inferred from the input coordinate.".to_string()), + ..Default::default() + }, + schema: generator.root_schema_for::>().to_value(), + min_occurs: Some(0), + ..Default::default() + }, + ), + ]); + + let mut outputs = HashMap::from([ + ( + "inputs".to_string(), + OutputDescription { + description_type: DescriptionType { + title: Some("Input parameters".to_string()), + description: Some( + "The inputs used to compute the climate-risk summary.".to_string(), + ), + ..Default::default() + }, + schema: generator.root_schema_for::().to_value(), + }, + ), + ( + "rawEnsembleData".to_string(), + OutputDescription { + description_type: DescriptionType { + title: Some("Raw ensemble data".to_string()), + description: Some( + "Per-model raw values for each variable × scenario combination." + .to_string(), + ), + metadata: vec![Metadata { + title: None, + role: Some("default-disabled".to_string()), + href: None, + }], + ..Default::default() + }, + schema: generator + .root_schema_for::>>() + .to_value(), + }, + ), + ]); + + for scenario in ClimateScenario::ALL { + let props = scenario.properties(); + outputs.insert( + scenario.name().to_string(), + OutputDescription { + description_type: DescriptionType { + title: Some(props.name.to_string()), + description: Some(format!( + "A table of climate-risk indicators for the {} scenario.", + props.name + )), + ..Default::default() + }, + schema: generator + .root_schema_for::>>() + .to_value(), + }, + ); + } + + Ok(Process { + summary: ProcessSummary { + id: self.id().into(), + version: self.version().into(), + description: DescriptionType { + title: Some("Climate risk indicators".to_string()), + description: Some( + "This process derives climate-risk indicators such as heat days from CORDEX/CMIP5 climate data for a point location and a time window. The workflow builds a daily threshold mask, aggregates it over the requested years and returns summary statistics for the selected climate variable. An anomaly relative to a reference period (same length) is reported as the difference of the multi-year means. If no models are specified, all models compatible with the region are used. If no region is specified, it is automatically inferred from the coordinate. If no scenario outputs are requested, all scenarios are computed." + .to_string(), + ), + ..Default::default() + }, + job_control_options: vec![ + JobControlOptions::SyncExecute, + JobControlOptions::AsyncExecute, + ], + output_transmission: vec![TransmissionMode::Value], + links: vec![Link::new( + format!("./{}/execution", self.id()), + "http://www.opengis.net/def/rel/ogc/1.0/execute", + ) + .title("Execution endpoint")], + }, + inputs, + outputs, + }) + } + + async fn execute(&self, execute: Execute) -> Result { + let mut inputs = parse_inputs(&execute.inputs)?; + + validate_inputs( + inputs.year_begin, + inputs.year_range, + inputs.reference_year_begin, + )?; + + let point = inputs.coordinate.value.coordinates.clone(); + + let region_props = resolve_region(inputs.region, &point)?; + if inputs.region.is_none() { + inputs.region = Some(region_props.region); + } + + let (filtered_models, model_props, dropped_models) = + resolve_models(&inputs.models, region_props.region); + if !dropped_models.is_empty() { + tracing::warn!( + "Ignoring climate models not available for region {}: {}", + region_props.name, + dropped_models.join(", ") + ); + } + if model_props.is_empty() { + let detail = if dropped_models.is_empty() { + String::new() + } else { + format!( + "; none of the requested models ({}) are available", + dropped_models.join(", ") + ) + }; + anyhow::bail!( + "No climate models valid / available for the specified region: {}{detail}", + region_props.name + ); + } + inputs.models = filtered_models; + + let scenario_props = resolve_available_scenarios(&model_props); + if scenario_props.is_empty() { + anyhow::bail!( + "No climate scenarios valid / available for the specified region and models." + ); + } + + let available_scenarios: Vec = + scenario_props.iter().map(|s| s.scenario).collect(); + + let output_keys: std::collections::BTreeSet = + execute.outputs.keys().cloned().collect(); + let (selected_scenarios, should_reflect_inputs, include_raw_ensemble) = + resolve_requests(&output_keys, &available_scenarios)?; + + let variables = resolve_variables(&inputs.variables); + let requests: Vec<_> = selected_scenarios + .into_iter() + .flat_map(|scenario| { + variables + .iter() + .map(move |v| (ClimateVariableRequest::new(*v), scenario)) + }) + .collect(); + + let request_props: Vec<(ClimateVariableRequest, ClimateScenarioProperties)> = requests + .into_iter() + .map(|(v, s)| (v, s.properties())) + .collect(); + + let mut outputs = compute_climate( + &CONFIG + .geoengine + .api_config(USER.try_get().ok().map(|user| user.session_token)), + &point, + inputs.year_begin, + inputs.year_range, + inputs.reference_year_begin, + &request_props, + &model_props, + ®ion_props, + ) + .await?; + + if should_reflect_inputs { + outputs.inputs = Some(inputs); + } + if !include_raw_ensemble { + outputs.raw_ensemble_data = None; + } + + Ok(outputs.into()) + } +} + +fn parse_inputs( + inputs: &HashMap, +) -> Result { + let value = serde_json::to_value(inputs).context("Failed to serialize process inputs")?; + serde_json::from_value(value).context("Failed to deserialize climate-risk inputs") +} + +fn validate_inputs( + Year(start_year): Year, + YearRange(range): YearRange, + reference_year: Option, +) -> Result<()> { + if !(5..=30).contains(&range) { + anyhow::bail!("Year range must be between 5 and 30 years"); + } + if start_year < DATA_START_YEAR { + anyhow::bail!("Start year must be at least {DATA_START_YEAR}"); + } + if start_year + range > 2100 { + anyhow::bail!("Start year plus range must not exceed 2100"); + } + if let Some(Year(reference_year)) = reference_year { + if reference_year < DATA_START_YEAR { + anyhow::bail!("Reference period start year must be at least {DATA_START_YEAR}"); + } + if reference_year + range > 2100 { + anyhow::bail!("Reference period start year plus range must not exceed 2100"); + } + } + Ok(()) +} + +fn resolve_region( + region: Option, + point: &PointType, +) -> Result { + if let Some(r) = region { + let props = r.properties(); + if !props.bounding_box.contains(point) { + anyhow::bail!( + "Coordinate is outside of the specified CORDEX/CMIP5 region: {}", + props.name + ); + } + Ok(props) + } else { + let region = CordexRegion::point_to_region(point).ok_or_else(|| { + anyhow::anyhow!("Coordinate is outside of the supported CORDEX/CMIP5 regions") + })?; + Ok(region.properties()) + } +} + +/// Filters the requested models down to those of the given region. The third return value +/// lists the user-specified models that were dropped, so callers can report them. +fn resolve_models( + specified_models: &[CordexModel], + region: CordexRegion, +) -> (Vec, Vec, Vec) { + if specified_models.is_empty() { + let (models, props): (Vec<_>, Vec<_>) = CordexModel::ALL + .iter() + .map(|m| (*m, m.properties())) + .filter(|(_, p)| p.region == region) + .unzip(); + (models, props, Vec::new()) + } else { + let mut models = Vec::new(); + let mut props = Vec::new(); + let mut dropped = Vec::new(); + for model in specified_models { + let model_props = model.properties(); + if model_props.region == region { + models.push(*model); + props.push(model_props); + } else { + dropped.push(model_props.name.to_string()); + } + } + (models, props, dropped) + } +} + +fn resolve_available_scenarios( + model_props: &[CordexModelProperties], +) -> Vec { + ClimateScenario::ALL + .iter() + .copied() + .filter(|s| model_props.iter().any(|m| m.scenarios.contains(s))) + .map(ClimateScenario::properties) + .collect() +} + +fn resolve_variables(specified_variables: &[ClimateVariable]) -> Vec { + if specified_variables.is_empty() { + ClimateVariable::ALL.to_vec() + } else { + specified_variables.to_vec() + } +} + +fn resolve_requests( + output_keys: &std::collections::BTreeSet, + available_scenarios: &[ClimateScenario], +) -> Result<(Vec, bool, bool)> { + let mut should_reflect_inputs = output_keys.is_empty(); + let mut include_raw_ensemble = false; + let mut selected_scenarios = Vec::new(); + + // BTreeSet iterates in order, so scenario selection is deterministic. + for output_key in output_keys { + if output_key == "inputs" { + should_reflect_inputs = true; + continue; + } + + if output_key == "rawEnsembleData" { + include_raw_ensemble = true; + continue; + } + + let mut found = false; + for scenario in ClimateScenario::ALL.iter().copied() { + if scenario.name() == output_key && available_scenarios.contains(&scenario) { + selected_scenarios.push(scenario); + found = true; + break; + } + } + if !found { + anyhow::bail!("Unknown output requested: {output_key}"); + } + } + + if selected_scenarios.is_empty() { + selected_scenarios = available_scenarios.to_vec(); + } + + Ok(( + selected_scenarios, + should_reflect_inputs, + include_raw_ensemble, + )) +} diff --git a/backend/src/processes/climate_risk/tests.rs b/backend/src/processes/climate_risk/tests.rs new file mode 100644 index 0000000..26df7f2 --- /dev/null +++ b/backend/src/processes/climate_risk/tests.rs @@ -0,0 +1,933 @@ +use super::*; +use crate::processes::parameters::{BioisDisplayKind, BoundingBox, DataResource, TableSchemaType}; +use crate::profile::CLIMATE_RISK_TABLE_SCHEMA_PROFILE; +use approx::assert_abs_diff_eq; +use geoengine_api_client::models::{ + CollectionType, GeoJson, RasterDataType, RasterOperator, VectorOperator, +}; +use ogcapi::types::processes::{InlineOrRefData, Input}; +use serde_json::json; + +#[test] +fn it_deserializes_the_input() { + let payload = json!({ + "coordinate": { + "value": { + "type": "Point", + "coordinates": [12.34, 56.78] + }, + "mediaType": "application/geo+json" + }, + "yearBegin": 2014, + "yearRange": 20, + "referenceYearBegin": 2020, + "variables": ["heatDays", "iceDays"], + "models": ["MPI-M-MPI-ESM-LR"], + "region": "Eur" + }); + + let inputs: HashMap = serde_json::from_value(payload).unwrap(); + let inputs = parse_inputs(&inputs).unwrap(); + assert_eq!( + inputs.variables, + vec![ClimateVariable::HeatDays, ClimateVariable::IceDays] + ); + assert_eq!(inputs.reference_year_begin, Some(Year(2020))); + assert_eq!(inputs.region, Some(CordexRegion::Eur)); +} + +#[test] +fn it_deserializes_omitted_optional_inputs_as_their_defaults() { + let payload = json!({ + "coordinate": { + "value": { + "type": "Point", + "coordinates": [12.34, 56.78] + }, + "mediaType": "application/geo+json" + } + }); + + let inputs: HashMap = serde_json::from_value(payload).unwrap(); + let inputs = parse_inputs(&inputs).unwrap(); + assert_eq!(inputs.reference_year_begin, None); + assert_eq!(inputs.region, None); +} + +#[test] +fn it_rejects_malformed_inputs_with_context() { + let payload = json!({ "coordinate": { "value": { "type": "Point" }, "mediaType": "application/geo+json" } }); + + let inputs: HashMap = serde_json::from_value(payload).unwrap(); + let error = format!("{:#}", parse_inputs(&inputs).unwrap_err()); + + assert!( + error.contains("Failed to deserialize climate-risk inputs"), + "{error}" + ); + assert!(error.contains("coordinate"), "{error}"); +} + +#[test] +fn it_process_summary_has_expected_inputs_and_outputs() { + let process = ClimateRiskProcess.process().unwrap(); + + assert_eq!(process.summary.id, "climate-risk"); + assert_eq!(process.summary.version, "0.1.0"); + + assert!(!process.inputs.contains_key("scenarios")); + assert!(!process.inputs.contains_key("yearEnd")); + assert!(process.inputs.contains_key("variables")); + assert!(process.inputs.contains_key("yearRange")); + assert!(process.inputs.contains_key("referenceYearBegin")); + + assert!(process.outputs.contains_key("rcp26")); + assert!(process.outputs.contains_key("rcp45")); + assert!(process.outputs.contains_key("rcp85")); + assert!(process.outputs.contains_key("rawEnsembleData")); + assert!(!process.outputs.contains_key("climateRisk")); + assert_eq!( + process.outputs["rcp45"].description_type.title.as_deref(), + Some("RCP 4.5 (Intermediate emissions)") + ); + assert_eq!( + process.outputs["rawEnsembleData"] + .description_type + .metadata + .first() + .and_then(|m| m.role.as_deref()), + Some("default-disabled") + ); +} + +#[test] +fn it_reference_year_begin_schema_is_nullable_with_default() { + let process = ClimateRiskProcess.process().unwrap(); + let input = &process.inputs["referenceYearBegin"]; + + assert_eq!(input.schema["anyOf"][0]["$ref"], json!("#/$defs/Year")); + assert_eq!(input.schema["anyOf"][1]["type"], json!("null")); + assert_eq!(input.schema["default"], json!(DATA_START_YEAR)); + assert_eq!( + input + .description_type + .metadata + .first() + .and_then(|m| m.role.as_deref()), + Some("enabled-by-default") + ); +} + +#[test] +fn it_validate_inputs_rejects_range_below_min() { + assert!(validate_inputs(Year(2014), YearRange(4), Some(Year(2020))).is_err()); +} + +#[test] +fn it_validate_inputs_rejects_range_above_max() { + assert!(validate_inputs(Year(2014), YearRange(31), Some(Year(2020))).is_err()); +} + +#[test] +fn it_validate_inputs_rejects_range_beyond_2100() { + assert!(validate_inputs(Year(2080), YearRange(30), Some(Year(2020))).is_err()); +} + +#[test] +fn it_validate_inputs_rejects_reference_before_data_start() { + assert!(validate_inputs(Year(2014), YearRange(20), Some(Year(2005))).is_err()); +} + +#[test] +fn it_validate_inputs_rejects_start_year_before_data_start() { + assert!(validate_inputs(Year(2005), YearRange(20), Some(Year(2020))).is_err()); + assert!(validate_inputs(Year(2006), YearRange(20), Some(Year(2020))).is_ok()); +} + +#[test] +fn it_validate_inputs_rejects_reference_beyond_2100() { + assert!(validate_inputs(Year(2014), YearRange(20), Some(Year(2090))).is_err()); +} + +#[test] +fn it_validate_inputs_accepts_valid_range() { + assert!(validate_inputs(Year(2014), YearRange(5), Some(Year(2020))).is_ok()); + assert!(validate_inputs(Year(2014), YearRange(30), Some(Year(2020))).is_ok()); + assert!(validate_inputs(Year(2014), YearRange(30), None).is_ok()); +} + +#[test] +fn it_climate_variable_props_values() { + for (var, expected_name, expected_suffix, expected_expr) in [ + (ClimateVariable::HeatDays, "Heat Days", "tasmax", "c >= 30"), + (ClimateVariable::IceDays, "Ice Days", "tasmax", "c < 0"), + ( + ClimateVariable::TropicalNights, + "Tropical Nights", + "tasmin", + "c > 20", + ), + (ClimateVariable::FrostDays, "Frost Days", "tasmin", "c < 0"), + (ClimateVariable::DryDays, "Dry Days", "pr", "86400) < 1"), + ( + ClimateVariable::HeavyRainDays, + "Heavy Rain Days", + "pr", + "86400) > 20", + ), + ] { + let props = var.properties(); + assert_eq!(props.name, expected_name); + assert_eq!(props.dataset_variable_suffix, expected_suffix); + assert!(props.expression.contains(expected_expr)); + } +} + +#[test] +fn it_cordex_model_props_values() { + for (model, expected_name, expected_prefix) in [ + ( + CordexModel::MpiMmpiEsmLr, + "MPI-M-MPI-ESM-LR", + "MPI-M-MPI-ESM-LR", + ), + ( + CordexModel::MohcHadgem2Es, + "MOHC-HadGEM2-ES", + "MOHC-HadGEM2-ES", + ), + ] { + let props = model.properties(); + assert_eq!(props.model, model); + assert_eq!(props.name, expected_name); + assert_eq!(props.dataset_prefix, expected_prefix); + assert_eq!(props.region, CordexRegion::Eur); + assert_eq!( + props.scenarios, + vec![ + ClimateScenario::Rcp26, + ClimateScenario::Rcp45, + ClimateScenario::Rcp85 + ] + ); + } +} + +#[test] +fn it_climate_scenario_props_values() { + for (scenario, expected_name, expected_prefix) in [ + (ClimateScenario::Rcp26, "RCP 2.6 (Low emissions)", "rcp26"), + ( + ClimateScenario::Rcp45, + "RCP 4.5 (Intermediate emissions)", + "rcp45", + ), + (ClimateScenario::Rcp85, "RCP 8.5 (High emissions)", "rcp85"), + ] { + let props = scenario.properties(); + assert_eq!(props.scenario, scenario); + assert_eq!(props.name, expected_name); + assert_eq!(props.dataset_prefix, expected_prefix); + } +} + +#[test] +fn it_cordex_region_props_values() { + let props = CordexRegion::Eur.properties(); + assert_eq!(props.region, CordexRegion::Eur); + assert_eq!(props.name, "Europe"); + assert_eq!(props.dataset_prefix, "EUR11"); + assert_eq!(props.bounding_box.wfs_string(), "-10,34,30,72"); +} + +#[test] +fn it_point_to_region_matches_inside_bbox() { + let point = PointType::from(vec![12.34, 56.78]); + assert_eq!( + CordexRegion::point_to_region(&point), + Some(CordexRegion::Eur) + ); +} + +#[test] +fn it_point_to_region_returns_none_outside() { + let point = PointType::from(vec![0.0, 0.0]); + assert_eq!(CordexRegion::point_to_region(&point), None); +} + +#[test] +fn it_bounding_box_around_point() { + let point = PointType::from(vec![10.0, 50.0]); + let bbox = BoundingBox::around_point(&point, 0.0001); + assert_eq!(bbox.wfs_string(), "9.9999,49.9999,10.0001,50.0001"); + assert!(bbox.contains(&point)); +} + +#[test] +fn it_dataset_raster_source_naming() { + let region = CordexRegion::Eur.properties(); + let scenario = ClimateScenario::Rcp45.properties(); + let model = CordexModel::MpiMmpiEsmLr.properties(); + let var = ClimateVariable::HeatDays.properties(); + + let result = ClimateRiskProcess::dataset_raster_source(&var, &model, &scenario, ®ion); + + assert!(matches!(result, RasterOperator::GdalSource(_))); + + let value = serde_json::to_value(&result).unwrap(); + assert_eq!( + value["params"]["data"], + "cordex_EUR11_rcp45_MPI-M-MPI-ESM-LR_tasmax" + ); +} + +#[test] +fn it_resolves_region_explicit_valid() { + let point = PointType::from(vec![12.0, 50.0]); + let result = resolve_region(Some(CordexRegion::Eur), &point); + assert!(result.is_ok()); + assert_eq!(result.unwrap().region, CordexRegion::Eur); +} + +#[test] +fn it_resolves_region_explicit_invalid() { + let point = PointType::from(vec![0.0, 0.0]); + let result = resolve_region(Some(CordexRegion::Eur), &point); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("outside of the specified") + ); +} + +#[test] +fn it_resolves_region_inferred_inside() { + let point = PointType::from(vec![12.0, 50.0]); + let result = resolve_region(None, &point); + assert!(result.is_ok()); + assert_eq!(result.unwrap().region, CordexRegion::Eur); +} + +#[test] +fn it_resolves_region_inferred_outside() { + let point = PointType::from(vec![0.0, 0.0]); + let result = resolve_region(None, &point); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("outside of the supported") + ); +} + +#[test] +fn it_resolves_models_all_when_empty() { + let (models, props, dropped) = resolve_models(&[], CordexRegion::Eur); + assert_eq!(models.len(), 2); + assert_eq!(props.len(), 2); + assert!(dropped.is_empty()); + assert!(models.contains(&CordexModel::MpiMmpiEsmLr)); + assert!(models.contains(&CordexModel::MohcHadgem2Es)); +} + +#[test] +fn it_resolves_models_filtered() { + let (models, props, dropped) = resolve_models(&[CordexModel::MpiMmpiEsmLr], CordexRegion::Eur); + assert_eq!(models.len(), 1); + assert_eq!(props.len(), 1); + assert!(dropped.is_empty()); + assert_eq!(models[0], CordexModel::MpiMmpiEsmLr); + assert_eq!(props[0].name, "MPI-M-MPI-ESM-LR"); +} + +#[test] +fn it_resolves_available_scenarios_returns_all_supported() { + let model_props = vec![CordexModel::MpiMmpiEsmLr.properties()]; + let result = resolve_available_scenarios(&model_props); + assert_eq!(result.len(), ClimateScenario::ALL.len()); +} + +#[test] +fn it_resolves_available_scenarios_empty_when_no_models() { + let result = resolve_available_scenarios(&[]); + assert!(result.is_empty()); +} + +#[test] +fn it_resolves_variables_empty_means_all() { + assert_eq!(resolve_variables(&[]), ClimateVariable::ALL.to_vec()); + assert_eq!( + resolve_variables(&[ClimateVariable::HeatDays]), + vec![ClimateVariable::HeatDays] + ); +} + +#[test] +fn it_resolves_requests_empty_means_all_and_reflect() { + let keys = std::collections::BTreeSet::new(); + let scenarios = vec![ClimateScenario::Rcp45]; + let (selected, should_reflect, include_raw_ensemble) = + resolve_requests(&keys, &scenarios).unwrap(); + assert_eq!(selected, vec![ClimateScenario::Rcp45]); + assert!(should_reflect); + assert!(!include_raw_ensemble); +} + +#[test] +fn it_resolves_requests_selects_scenarios() { + let mut keys = std::collections::BTreeSet::new(); + keys.insert("rcp45".to_string()); + keys.insert("rcp85".to_string()); + let scenarios = vec![ClimateScenario::Rcp45, ClimateScenario::Rcp85]; + let (selected, should_reflect, _) = resolve_requests(&keys, &scenarios).unwrap(); + assert_eq!( + selected, + vec![ClimateScenario::Rcp45, ClimateScenario::Rcp85] + ); + assert!(!should_reflect); +} + +#[test] +fn it_rejects_unknown_requests() { + let mut keys = std::collections::BTreeSet::new(); + keys.insert("nonexistent".to_string()); + let scenarios = vec![ClimateScenario::Rcp45]; + assert!(resolve_requests(&keys, &scenarios).is_err()); +} + +#[test] +fn it_rejects_legacy_climate_risk_key() { + let mut keys = std::collections::BTreeSet::new(); + keys.insert("climateRisk".to_string()); + let scenarios = vec![ClimateScenario::Rcp45]; + assert!(resolve_requests(&keys, &scenarios).is_err()); +} + +#[test] +fn it_resolves_input_output_defaults() { + let mut keys = std::collections::BTreeSet::new(); + keys.insert("inputs".to_string()); + let scenarios = vec![ClimateScenario::Rcp45]; + let (selected, should_reflect, include_raw_ensemble) = + resolve_requests(&keys, &scenarios).unwrap(); + assert_eq!(selected, vec![ClimateScenario::Rcp45]); + assert!(should_reflect); + assert!(!include_raw_ensemble); +} + +#[test] +fn it_resolves_raw_ensemble_output_defaults() { + let mut keys = std::collections::BTreeSet::new(); + keys.insert("rawEnsembleData".to_string()); + let scenarios = vec![ClimateScenario::Rcp45]; + let (selected, should_reflect, include_raw_ensemble) = + resolve_requests(&keys, &scenarios).unwrap(); + assert_eq!(selected, vec![ClimateScenario::Rcp45]); + assert!(!should_reflect); + assert!(include_raw_ensemble); +} + +#[test] +fn it_execute_results_group_rows_by_scenario() { + fn row(scenario: &str) -> ClimateRiskRow { + ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: scenario.to_string(), + mean: 1.0, + median: 1.0, + min: 1.0, + max: 1.0, + occurrence_probability: Some(1.0), + anomaly: None, + ..Default::default() + } + } + let outputs = ClimateRiskOutputs { + inputs: None, + analysis_period: Some("2041–2070".to_string()), + reference_period: Some("2006–2025".to_string()), + climate_risk: Some(climate_risk_data_resource( + vec![ + row("RCP 2.6 (Low emissions)"), + row("RCP 4.5 (Intermediate emissions)"), + row("RCP 2.6 (Low emissions)"), + ], + "2041–2070", + Some("2006–2025"), + )), + raw_ensemble_data: None, + }; + + let result: ExecuteResults = outputs.into(); + assert!(result.contains_key("RCP 2.6 (Low emissions)")); + assert!(result.contains_key("RCP 4.5 (Intermediate emissions)")); + assert!(!result.contains_key("RCP 8.5 (High emissions)")); + assert!(!result.contains_key("rcp26")); + + let InlineOrRefData::QualifiedInputValue(qualified) = &result["RCP 2.6 (Low emissions)"].data + else { + panic!("expected qualified input value"); + }; + let resource: DataResource> = + serde_json::from_value(serde_json::to_value(&qualified.value).unwrap()).unwrap(); + assert_eq!(resource.data.len(), 2); + assert_eq!(resource.name, "RCP 2.6 (Low emissions) · 2041–2070"); + assert!( + resource + .data + .iter() + .all(|r| r.scenario == "RCP 2.6 (Low emissions)") + ); +} + +#[test] +fn it_aggregate_from_list_aggregates_values() { + let mut values: HashMap = HashMap::new(); + values.insert(CordexModel::MpiMmpiEsmLr, 10.0); + values.insert(CordexModel::MohcHadgem2Es, 20.0); + + let result = aggregate_from_list(&values).unwrap(); + assert_abs_diff_eq!(result.min, 10.0); + assert_abs_diff_eq!(result.max, 20.0); + assert_abs_diff_eq!(result.mean, 15.0); + assert_abs_diff_eq!(result.median, 15.0); + assert_eq!(result.raw_members.as_ref().unwrap().len(), 2); + assert_abs_diff_eq!(result.occurrence_probability.unwrap(), 15.0 / 365.25); + + let empty: HashMap = HashMap::new(); + assert!(aggregate_from_list(&empty).is_none()); +} + +#[test] +fn it_climate_risk_data_resource_declares_display_extension() { + let rows = vec![ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "rcp45".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: Some(0.5), + anomaly: Some(10.0), + ..Default::default() + }]; + let resource = climate_risk_data_resource(rows, "", None); + let probability_field = resource + .schema + .fields + .iter() + .find(|f| f.name == "occurrenceProbability") + .unwrap(); + assert!(matches!( + probability_field.r#type, + Some(TableSchemaType::Number) + )); + assert_eq!( + resource.schema.schema.as_deref(), + Some(CLIMATE_RISK_TABLE_SCHEMA_PROFILE) + ); + assert!(matches!( + resource.schema.biois.as_ref().unwrap().display["occurrenceProbability"].kind, + BioisDisplayKind::RiskProbability + )); + let probability_metadata = + &resource.schema.biois.as_ref().unwrap().display["occurrenceProbability"]; + assert_eq!( + probability_metadata.label_field.as_deref(), + Some("occurrenceProbabilityLabel") + ); + assert_eq!( + probability_metadata.color_field.as_deref(), + Some("occurrenceProbabilityColor") + ); +} + +#[test] +fn it_climate_risk_data_resource_declares_anomaly_display() { + let rows = vec![ + ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "rcp45".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: Some(0.5), + anomaly: Some(10.0), + ..Default::default() + }, + ClimateRiskRow { + variable: "Dry Days".to_string(), + scenario: "rcp45".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: Some(0.5), + anomaly: Some(5.0), + ..Default::default() + }, + ]; + let resource = climate_risk_data_resource(rows, "", None); + + let field_names: std::collections::HashSet<&str> = resource + .schema + .fields + .iter() + .map(|field| field.name.as_str()) + .collect(); + let extension = resource.schema.biois.as_ref().unwrap(); + for metadata in extension.display.values() { + assert!(field_names.contains(metadata.label_field.as_deref().unwrap())); + assert!(field_names.contains(metadata.color_field.as_deref().unwrap())); + } + assert!( + extension + .hidden_fields + .iter() + .all(|field| field_names.contains(field.as_str())) + ); + + assert!(matches!( + resource.schema.biois.as_ref().unwrap().display["anomaly"].kind, + BioisDisplayKind::RiskAnomaly + )); + let anomaly_metadata = &resource.schema.biois.as_ref().unwrap().display["anomaly"]; + assert_eq!( + anomaly_metadata.label_field.as_deref(), + Some("anomalyLabel") + ); + assert_eq!( + anomaly_metadata.color_field.as_deref(), + Some("anomalyColor") + ); +} + +#[test] +fn it_climate_risk_data_resource_omits_anomaly_field_when_absent() { + let rows = vec![ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "rcp45".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: Some(0.5), + anomaly: None, + ..Default::default() + }]; + let resource = climate_risk_data_resource(rows, "", None); + let field_names: Vec<&str> = resource + .schema + .fields + .iter() + .map(|f| f.name.as_str()) + .collect(); + assert!(!field_names.contains(&"anomaly")); + assert!(field_names.contains(&"occurrenceProbability")); + assert!(field_names.contains(&"occurrenceProbabilityLabel")); + assert!(field_names.contains(&"occurrenceProbabilityColor")); + assert!( + !resource + .schema + .biois + .as_ref() + .unwrap() + .display + .contains_key("anomaly") + ); +} + +#[test] +fn it_climate_risk_scenario_data_resource_annotates_periods() { + let rows = vec![ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "RCP 2.6 (Low emissions)".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: Some(0.5), + anomaly: Some(10.0), + ..Default::default() + }]; + let resource = climate_risk_scenario_data_resource( + "RCP 2.6 (Low emissions)", + rows, + "2041–2070", + Some("2006–2025"), + ); + + assert_eq!(resource.name, "RCP 2.6 (Low emissions) · 2041–2070"); + let title = |name: &str| { + resource + .schema + .fields + .iter() + .find(|f| f.name == name) + .unwrap() + .title + .clone() + }; + assert_eq!(title("mean").as_deref(), Some("Mean (days/year)")); + assert_eq!( + title("anomaly").as_deref(), + Some("Anomaly (days/year compared to 2006–2025)") + ); + + let resource = climate_risk_scenario_data_resource( + "RCP 2.6 (Low emissions)", + vec![ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "RCP 2.6 (Low emissions)".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: Some(0.5), + anomaly: None, + ..Default::default() + }], + "", + None, + ); + assert_eq!(resource.name, "RCP 2.6 (Low emissions)"); + assert!(resource.schema.fields.iter().all(|f| f.name != "anomaly")); +} + +#[test] +fn it_climate_variable_properties_accessors() { + let props = ClimateVariable::HeatDays.properties(); + assert_eq!(props.name_string(), "Heat Days"); + assert_eq!(ClimateVariableProperties::measurement_unit(), "Days"); + assert_eq!(ClimateVariableProperties::measurement_string(), "Days"); + assert_eq!( + ClimateVariableProperties::expression_dtype(), + RasterDataType::I8 + ); + assert_eq!( + ClimateVariableProperties::year_agg_dtype(), + RasterDataType::U16 + ); +} + +#[test] +fn it_outputs_to_execute_results() { + let rows = vec![ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "rcp45".to_string(), + max: 100.0, + min: 0.0, + mean: 50.0, + median: 50.0, + occurrence_probability: None, + anomaly: Some(10.0), + ..Default::default() + }]; + let raw_rows = vec![ClimateRiskRawRow { + variable: "Heat Days".to_string(), + scenario: "rcp45".to_string(), + model: "MPI-M-MPI-ESM-LR".to_string(), + value: 42.0, + }]; + let outputs = ClimateRiskOutputs { + inputs: None, + analysis_period: None, + reference_period: None, + climate_risk: Some(climate_risk_data_resource(rows, "", None)), + raw_ensemble_data: Some(raw_ensemble_data_resource(raw_rows)), + }; + let results: ExecuteResults = outputs.into(); + assert!(results.contains_key("rcp45")); + assert!(!results.contains_key("rcp26")); + assert!(!results.contains_key("rcp85")); + assert!(results.contains_key("rawEnsembleData")); +} + +#[test] +fn it_outputs_from_feature_collection_ok() { + let geo_json = GeoJson { + features: vec![ + serde_json::json!({ + "type": "Feature", + "properties": { + "MPI-M-MPI-ESM-LR": 42.0, + "MOHC-HadGEM2-ES": 10.0 + } + }), + serde_json::json!({ + "type": "Feature", + "properties": { + "MPI-M-MPI-ESM-LR": 58.0, + "MOHC-HadGEM2-ES": 30.0 + } + }), + ], + r#type: CollectionType::FeatureCollection, + }; + + let models = vec![ + CordexModel::MpiMmpiEsmLr.properties(), + CordexModel::MohcHadgem2Es.properties(), + ]; + + let result = outputs_from_feature_collection(&geo_json, &models).unwrap(); + assert_eq!(result.len(), 2); + assert_abs_diff_eq!(result[&CordexModel::MpiMmpiEsmLr], 50.0); + assert_abs_diff_eq!(result[&CordexModel::MohcHadgem2Es], 20.0); +} + +#[test] +fn it_outputs_from_feature_collection_empty() { + let geo_json = GeoJson::default(); + let models = vec![CordexModel::MpiMmpiEsmLr.properties()]; + assert!(outputs_from_feature_collection(&geo_json, &models).is_err()); +} + +#[test] +fn it_outputs_from_feature_collection_no_properties() { + let geo_json = GeoJson { + features: vec![serde_json::json!({ "type": "Feature" })], + r#type: CollectionType::FeatureCollection, + }; + let models = vec![CordexModel::MpiMmpiEsmLr.properties()]; + assert!(outputs_from_feature_collection(&geo_json, &models).is_err()); +} + +#[test] +fn it_vector_source_creates_mock_point_source() { + let point = PointType::from(vec![12.0, 34.0]); + let result = vector_source(&point); + assert!(matches!(result, VectorOperator::MockPointSource(_))); +} + +#[test] +fn it_build_variable_workflows_chain() { + let region = CordexRegion::Eur.properties(); + let scenario = ClimateScenario::Rcp45.properties(); + let model = CordexModel::MpiMmpiEsmLr.properties(); + let var = ClimateVariable::HeatDays.properties(); + + let day_expr = + ClimateRiskProcess::build_variable_day_expression(&var, &model, &scenario, ®ion); + assert!(matches!(day_expr, RasterOperator::Expression(_))); + + let year_agg = + ClimateRiskProcess::build_variable_year_agg_workflow(&var, &model, &scenario, ®ion); + assert!(matches!( + year_agg, + RasterOperator::TemporalRasterAggregation(_) + )); +} + +#[test] +fn it_probability_label_maps_classes() { + assert_eq!(probability_label(0.0), "1 · extremely low (0 %)"); + assert_eq!(probability_label(0.0001), "1 · extremely low (0 %)"); + assert_eq!(probability_label(0.0005), "2 · very low (0.1 %)"); + assert_eq!(probability_label(0.001), "3 · low (0.1 %)"); + assert_eq!(probability_label(0.005), "5 · moderate (0.5 %)"); + assert_eq!(probability_label(0.02), "7 · high (2 %)"); + assert_eq!(probability_label(0.03), "7 · high (3 %)"); + assert_eq!(probability_label(0.1), "9 · extremely high (10 %)"); + assert_eq!(probability_label(0.2), "10 · extreme (20 %)"); + assert_eq!(probability_label(0.9), "10 · extreme (90 %)"); +} + +#[test] +fn it_probability_class_uses_existing_boundaries() { + let boundaries = [ + (0.00005, ProbabilityClass::ExtremelyLow), + (0.0002, ProbabilityClass::VeryLow), + (0.001, ProbabilityClass::Low), + (0.002, ProbabilityClass::ModeratelyLow), + (0.004, ProbabilityClass::Moderate), + (0.01, ProbabilityClass::ModeratelyHigh), + (0.02, ProbabilityClass::High), + (0.05, ProbabilityClass::VeryHigh), + (0.1, ProbabilityClass::ExtremelyHigh), + (0.2, ProbabilityClass::Extreme), + ]; + for (probability, expected) in boundaries { + assert_eq!(ProbabilityClass::from_probability(probability), expected); + } + assert_eq!( + ProbabilityClass::from_probability(0.0), + ProbabilityClass::ExtremelyLow + ); + assert_eq!( + ProbabilityClass::from_probability(1.0), + ProbabilityClass::Extreme + ); + assert_eq!(ProbabilityClass::High.return_period_years(), 50); +} + +#[test] +fn it_probability_color_maps_classes() { + assert_eq!(probability_color(0.0), "#66bb6a"); + assert_eq!(probability_color(0.02), "#e53935"); + assert_eq!(probability_color(0.03), "#e53935"); + assert_eq!(probability_color(0.2), "#4a148c"); + assert_eq!(probability_color(0.9), "#4a148c"); +} + +#[test] +fn it_anomaly_pct_maps_change_and_zero_reference() { + assert_abs_diff_eq!(anomaly_pct(120.0, 100.0), 20.0); + assert_abs_diff_eq!(anomaly_pct(50.0, 100.0), -50.0); + assert_abs_diff_eq!(anomaly_pct(10.0, 0.0), 100.0); + assert_abs_diff_eq!(anomaly_pct(-10.0, 0.0), -100.0); + assert_abs_diff_eq!(anomaly_pct(0.0, 0.0), 0.0); +} + +#[test] +fn it_anomaly_label_maps_days_and_raw_pct() { + assert_eq!(anomaly_label(10.0, 20.0), "+10 days (+20 %)"); + assert_eq!(anomaly_label(-5.0, -10.0), "-5 days (-10 %)"); + assert_eq!(anomaly_label(0.0, 0.0), "0 days (0 %)"); + assert_eq!(anomaly_label(10.5, 33.3), "+10.5 days (+33.3 %)"); + assert_eq!( + anomaly_label(10.0, 250.0), + "+10 days (+250 %)", + "label shows the raw percentage, the color clamps separately" + ); +} + +#[test] +fn it_percentage_color_maps_and_clamps() { + assert_eq!(percentage_color(-100.0), "#2166ac"); + assert_eq!(percentage_color(-67.0), "#67a9cf"); + assert_eq!(percentage_color(-33.0), "#d1e5f0"); + assert_eq!(percentage_color(0.0), "#f7f7f7"); + assert_eq!(percentage_color(33.0), "#fddbc7"); + assert_eq!(percentage_color(67.0), "#ef8a62"); + assert_eq!(percentage_color(100.0), "#b2182b"); + assert_eq!(percentage_color(999.0), "#b2182b"); + assert_eq!(percentage_color(-999.0), "#2166ac"); +} + +#[test] +fn it_climate_risk_row_serializes_display_fields() { + let row = ClimateRiskRow { + variable: "Heat Days".to_string(), + scenario: "rcp45".to_string(), + mean: 50.0, + median: 50.0, + min: 0.0, + max: 100.0, + occurrence_probability: Some(0.03), + anomaly: Some(10.0), + occurrence_probability_label: Some("7 · high (3 %)".to_string()), + occurrence_probability_color: Some("#e53935".to_string()), + anomaly_label: Some("+10 days (+20 %)".to_string()), + anomaly_color: Some("#fddbc7".to_string()), + }; + let json = serde_json::to_value(&row).unwrap(); + assert_eq!(json["occurrenceProbabilityLabel"], "7 · high (3 %)"); + assert_eq!(json["occurrenceProbabilityColor"], "#e53935"); + assert_eq!(json["anomalyLabel"], "+10 days (+20 %)"); + assert_eq!(json["anomalyColor"], "#fddbc7"); +} diff --git a/backend/src/processes/climate_risk/types.rs b/backend/src/processes/climate_risk/types.rs new file mode 100644 index 0000000..f3ce3db --- /dev/null +++ b/backend/src/processes/climate_risk/types.rs @@ -0,0 +1,556 @@ +use crate::processes::parameters::{ + BoundingBox, DataResource, DataResourceSchema, PointGeoJsonInput, Year, YearRange, + nearest_containing, +}; +use geoengine_api_client::models::RasterDataType; +use geojson::PointType; +use ogcapi::types::common::Crs; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use utoipa::ToSchema; + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone, PartialEq, Eq, Hash)] +#[schema(title = "CordexRegion")] +pub enum CordexRegion { + Eur, +} + +impl CordexRegion { + pub const ALL: &'static [Self] = &[Self::Eur]; + + pub fn name(self) -> &'static str { + match self { + Self::Eur => "Eur", + } + } + pub fn properties(self) -> CordexRegionProperties { + match self { + CordexRegion::Eur => CordexRegionProperties { + name: "Europe", + dataset_prefix: "EUR11", + bounding_box: BoundingBox::new(-10.0, 34.0, 30.0, 72.0, Crs::from_epsg(4326)), + region: CordexRegion::Eur, + }, + } + } + + pub fn point_to_region(point: &PointType) -> Option { + nearest_containing( + point, + Self::ALL + .iter() + .map(|region| (*region, region.properties().bounding_box)), + ) + } +} + +#[derive(Debug)] +pub struct CordexRegionProperties { + pub name: &'static str, + pub dataset_prefix: &'static str, + pub bounding_box: BoundingBox, + pub region: CordexRegion, +} + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone, PartialEq, Eq, Hash)] +#[schema(title = "ClimateVariable")] +#[serde(rename_all = "camelCase")] +pub enum ClimateVariable { + HeatDays, + IceDays, + TropicalNights, + FrostDays, + DryDays, + HeavyRainDays, +} + +impl ClimateVariable { + pub const ALL: &'static [Self] = &[ + Self::HeatDays, + Self::IceDays, + Self::TropicalNights, + Self::FrostDays, + Self::DryDays, + Self::HeavyRainDays, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::HeatDays => "heatDays", + Self::IceDays => "iceDays", + Self::TropicalNights => "tropicalNights", + Self::FrostDays => "frostDays", + Self::DryDays => "dryDays", + Self::HeavyRainDays => "heavyRainDays", + } + } + + pub fn properties(self) -> ClimateVariableProperties { + match self { + ClimateVariable::HeatDays => ClimateVariableProperties { + name: "Heat Days", + dataset_variable_suffix: "tasmax", + expression: "let c = (A - 273.15); if c >= 30 { 1 } else { 0 }", + }, + ClimateVariable::IceDays => ClimateVariableProperties { + name: "Ice Days", + dataset_variable_suffix: "tasmax", + expression: "let c = (A - 273.15); if c < 0 { 1 } else { 0 }", + }, + ClimateVariable::TropicalNights => ClimateVariableProperties { + name: "Tropical Nights", + dataset_variable_suffix: "tasmin", + expression: "let c = (A - 273.15); if c > 20 { 1 } else { 0 }", + }, + ClimateVariable::FrostDays => ClimateVariableProperties { + name: "Frost Days", + dataset_variable_suffix: "tasmin", + expression: "let c = (A - 273.15); if c < 0 { 1 } else { 0 }", + }, + ClimateVariable::DryDays => ClimateVariableProperties { + name: "Dry Days", + dataset_variable_suffix: "pr", + expression: "if (A * 86400) < 1 { 1 } else { 0 }", + }, + ClimateVariable::HeavyRainDays => ClimateVariableProperties { + name: "Heavy Rain Days", + dataset_variable_suffix: "pr", + expression: "if (A * 86400) > 20 { 1 } else { 0 }", + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct ClimateVariableRequest { + pub variable: ClimateVariable, +} + +impl ClimateVariableRequest { + pub fn new(variable: ClimateVariable) -> Self { + Self { variable } + } +} + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Clone, PartialEq, Eq, Copy, Hash)] +#[schema(title = "ClimateModel")] +pub enum CordexModel { + #[serde(rename = "MPI-M-MPI-ESM-LR")] + MpiMmpiEsmLr, + #[serde(rename = "MOHC-HadGEM2-ES")] + MohcHadgem2Es, +} + +impl CordexModel { + pub const ALL: &'static [Self] = &[Self::MpiMmpiEsmLr, Self::MohcHadgem2Es]; + + pub fn name(self) -> &'static str { + match self { + Self::MpiMmpiEsmLr => "MPI-M-MPI-ESM-LR", + Self::MohcHadgem2Es => "MOHC-HadGEM2-ES", + } + } + pub fn properties(self) -> CordexModelProperties { + match self { + CordexModel::MpiMmpiEsmLr => CordexModelProperties { + name: "MPI-M-MPI-ESM-LR", + dataset_prefix: "MPI-M-MPI-ESM-LR", + region: CordexRegion::Eur, + scenarios: vec![ + ClimateScenario::Rcp26, + ClimateScenario::Rcp45, + ClimateScenario::Rcp85, + ], + model: CordexModel::MpiMmpiEsmLr, + }, + CordexModel::MohcHadgem2Es => CordexModelProperties { + name: "MOHC-HadGEM2-ES", + dataset_prefix: "MOHC-HadGEM2-ES", + region: CordexRegion::Eur, + scenarios: vec![ + ClimateScenario::Rcp26, + ClimateScenario::Rcp45, + ClimateScenario::Rcp85, + ], + model: CordexModel::MohcHadgem2Es, + }, + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct CordexModelProperties { + pub name: &'static str, + pub dataset_prefix: &'static str, + pub region: CordexRegion, + pub scenarios: Vec, + pub model: CordexModel, +} + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone, PartialEq, Eq, Hash)] +#[schema(title = "ClimateScenario")] +#[serde(rename_all = "lowercase")] +pub enum ClimateScenario { + Rcp26, + Rcp45, + Rcp85, +} + +impl ClimateScenario { + pub const ALL: &'static [Self] = &[Self::Rcp26, Self::Rcp45, Self::Rcp85]; + + pub fn name(self) -> &'static str { + match self { + Self::Rcp26 => "rcp26", + Self::Rcp45 => "rcp45", + Self::Rcp85 => "rcp85", + } + } + pub fn properties(self) -> ClimateScenarioProperties { + match self { + ClimateScenario::Rcp26 => ClimateScenarioProperties { + name: "RCP 2.6 (Low emissions)", + dataset_prefix: "rcp26", + scenario: ClimateScenario::Rcp26, + }, + ClimateScenario::Rcp45 => ClimateScenarioProperties { + name: "RCP 4.5 (Intermediate emissions)", + dataset_prefix: "rcp45", + scenario: ClimateScenario::Rcp45, + }, + ClimateScenario::Rcp85 => ClimateScenarioProperties { + name: "RCP 8.5 (High emissions)", + dataset_prefix: "rcp85", + scenario: ClimateScenario::Rcp85, + }, + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ClimateScenarioProperties { + pub name: &'static str, + pub dataset_prefix: &'static str, + pub scenario: ClimateScenario, +} + +fn default_year_begin() -> Year { + Year(2014) +} + +pub(crate) const DATA_START_YEAR: u16 = 2006; +// Climate values are aggregated using a Julian year. +pub(crate) const DAYS_PER_JULIAN_YEAR: f64 = 365.25; + +#[allow(clippy::unnecessary_wraps)] +fn default_reference_year_begin() -> Option { + Some(Year(DATA_START_YEAR)) +} + +fn default_year_range() -> YearRange { + YearRange(20) +} + +fn default_variables() -> Vec { + ClimateVariable::ALL.to_vec() +} + +fn default_models() -> Vec { + CordexModel::ALL.to_vec() +} + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ClimateRiskInputs { + pub coordinate: PointGeoJsonInput, + #[serde(default = "default_year_begin")] + #[schema(minimum = 2006, maximum = 2100)] + pub year_begin: Year, + #[serde(default = "default_year_range")] + #[schemars(default = "default_year_range")] + pub year_range: YearRange, + #[serde(default)] + #[schemars(default = "default_reference_year_begin")] + pub reference_year_begin: Option, + #[serde(default = "default_variables")] + pub variables: Vec, + #[serde(default = "default_models")] + pub models: Vec, + #[serde(default)] + pub region: Option, +} + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Clone)] +pub struct ClimateVariableResult { + pub max: f64, + pub min: f64, + pub mean: f64, + pub median: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub occurrence_probability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_members: Option>, +} + +/// A single row in the climate risk `DataResource` output. +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ClimateRiskRow { + pub variable: String, + pub scenario: String, + /// Mean number of days per year in the analysis period. + pub mean: f64, + /// Median number of days per year in the analysis period. + pub median: f64, + /// Minimum number of days per year across models. + pub min: f64, + /// Maximum number of days per year across models. + pub max: f64, + /// Occurrence probability as a ratio from 0 to 1. + #[serde(skip_serializing_if = "Option::is_none")] + pub occurrence_probability: Option, + /// Difference in days per year from the reference period. + #[serde(skip_serializing_if = "Option::is_none")] + pub anomaly: Option, + /// Ready-to-display occurrence-probability label, e.g. "7 · high (3 %)". + #[serde(skip_serializing_if = "Option::is_none")] + pub occurrence_probability_label: Option, + /// Cell color for the occurrence probability. + #[serde(skip_serializing_if = "Option::is_none")] + pub occurrence_probability_color: Option, + /// Ready-to-display anomaly label, e.g. "+10 days (+20 %)". + #[serde(skip_serializing_if = "Option::is_none")] + pub anomaly_label: Option, + /// Cell color for the anomaly. + #[serde(skip_serializing_if = "Option::is_none")] + pub anomaly_color: Option, +} + +/// A single row in the raw ensemble data output. +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ClimateRiskRawRow { + pub variable: String, + pub scenario: String, + pub model: String, + pub value: f64, +} + +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Default)] +#[serde(rename_all = "camelCase")] +pub struct ClimateRiskOutputs { + pub inputs: Option, + /// Analysis window as `"2041–2070"`, used for display in result headlines. + #[serde(skip_serializing_if = "Option::is_none")] + pub analysis_period: Option, + /// Reference window used for anomalies as `"2006–2025"`, `None` when no reference period. + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_period: Option, + #[schema(value_type = Option, inline)] + pub climate_risk: Option>>, + #[schema(value_type = Option, inline)] + pub raw_ensemble_data: Option>>, +} + +/// Column title for the anomaly: "Anomaly (days/year compared to 2006–2025)". +/// The analysis period lives in the resource name, so it is not repeated here. +pub(crate) fn anomaly_title(reference_period: Option<&str>) -> String { + match reference_period { + Some(period) => format!("Anomaly (days/year compared to {period})"), + None => "Anomaly (days/year)".to_string(), + } +} + +/// Existing FMEA/ISO-inspired occurrence-probability classes, ordered from +/// rarest to most likely. The thresholds and presentation values are kept +/// together so labels and colors cannot drift apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProbabilityClass { + ExtremelyLow, + VeryLow, + Low, + ModeratelyLow, + Moderate, + ModeratelyHigh, + High, + VeryHigh, + ExtremelyHigh, + Extreme, +} + +impl ProbabilityClass { + const ALL: [Self; 10] = [ + Self::ExtremelyLow, + Self::VeryLow, + Self::Low, + Self::ModeratelyLow, + Self::Moderate, + Self::ModeratelyHigh, + Self::High, + Self::VeryHigh, + Self::ExtremelyHigh, + Self::Extreme, + ]; + + pub(crate) fn from_probability(probability: f64) -> Self { + Self::ALL + .iter() + .rev() + .find(|class| probability >= class.threshold()) + .copied() + .unwrap_or(Self::ExtremelyLow) + } + + pub(crate) fn number(self) -> u8 { + match self { + Self::ExtremelyLow => 1, + Self::VeryLow => 2, + Self::Low => 3, + Self::ModeratelyLow => 4, + Self::Moderate => 5, + Self::ModeratelyHigh => 6, + Self::High => 7, + Self::VeryHigh => 8, + Self::ExtremelyHigh => 9, + Self::Extreme => 10, + } + } + + pub(crate) fn threshold(self) -> f64 { + 1.0 / f64::from(self.return_period_years()) + } + + /// Return period represented by this existing FMEA/ISO-inspired class. + pub(crate) fn return_period_years(self) -> u32 { + match self { + Self::ExtremelyLow => 20_000, + Self::VeryLow => 5_000, + Self::Low => 1_000, + Self::ModeratelyLow => 500, + Self::Moderate => 250, + Self::ModeratelyHigh => 100, + Self::High => 50, + Self::VeryHigh => 20, + Self::ExtremelyHigh => 10, + Self::Extreme => 5, + } + } + + fn label(self) -> &'static str { + match self { + Self::ExtremelyLow => "extremely low", + Self::VeryLow => "very low", + Self::Low => "low", + Self::ModeratelyLow => "moderately low", + Self::Moderate => "moderate", + Self::ModeratelyHigh => "moderately high", + Self::High => "high", + Self::VeryHigh => "very high", + Self::ExtremelyHigh => "extremely high", + Self::Extreme => "extreme", + } + } + + fn color(self) -> &'static str { + match self { + Self::ExtremelyLow => "#66bb6a", + Self::VeryLow => "#2e7d32", + Self::Low => "#fdd835", + Self::ModeratelyLow => "#f9a825", + Self::Moderate => "#fb8c00", + Self::ModeratelyHigh => "#ef6c00", + Self::High => "#e53935", + Self::VeryHigh => "#c62828", + Self::ExtremelyHigh => "#8e24aa", + Self::Extreme => "#4a148c", + } + } +} + +/// Ready-to-display occurrence-probability label, e.g. "7 · high (3 %)". +pub(crate) fn probability_label(p: f64) -> String { + let class = ProbabilityClass::from_probability(p); + let pct = format!("{:.1}", p * 100.0) + .trim_end_matches(".0") + .to_string(); + format!("{} · {} ({pct} %)", class.number(), class.label()) +} + +pub(crate) fn probability_color(p: f64) -> String { + ProbabilityClass::from_probability(p).color().to_string() +} + +/// Shared 7-stop divergent gradient for the anomaly color scale, ordered from -100 % to +100 %. +const ANOMALY_PALETTE: [&str; 7] = [ + "#2166ac", "#67a9cf", "#d1e5f0", "#f7f7f7", "#fddbc7", "#ef8a62", "#b2182b", +]; + +/// Quantizes a percentage change (-100..=+100) to the nearest of the 7 shared class stops. +/// Values outside the range clamp to the extremes; negative = blue, zero = white, positive = red. +pub(crate) fn percentage_color(pct: f64) -> String { + let t = ((3.0 * pct / 100.0).round() as i32).clamp(-3, 3); + ANOMALY_PALETTE[(t + 3) as usize].to_string() +} + +/// Percentage change of the analysis mean relative to the reference mean, unclamped. +/// A missing/zero reference mean maps to the extreme percentages by sign of the anomaly. +pub(crate) fn anomaly_pct(analysis_mean: f64, reference_mean: f64) -> f64 { + if (analysis_mean - reference_mean).abs() <= f64::EPSILON { + 0.0 + } else if reference_mean.abs() <= f64::EPSILON { + (analysis_mean - reference_mean).signum() * 100.0 + } else { + (analysis_mean - reference_mean) / reference_mean * 100.0 + } +} + +/// Formats a signed value with `decimals` places, trailing zeros trimmed, e.g. 10.0 -> "+10". +fn format_signed(value: f64, decimals: usize) -> String { + let sign = if value > 0.0 { + "+" + } else if value < 0.0 { + "-" + } else { + "" + }; + let digits = format!("{:.decimals$}", value.abs(), decimals = decimals); + let trimmed = digits.trim_end_matches('0').trim_end_matches('.'); + format!("{sign}{trimmed}") +} + +/// Ready-to-display anomaly label, e.g. "+10 days (+20 %)". +pub(crate) fn anomaly_label(anomaly_days: f64, pct: f64) -> String { + format!( + "{} days ({} %)", + format_signed(anomaly_days, 2), + format_signed(pct, 1) + ) +} + +pub struct ClimateVariableProperties { + pub(crate) name: &'static str, + pub(crate) dataset_variable_suffix: &'static str, + pub(crate) expression: &'static str, +} + +impl ClimateVariableProperties { + pub fn name_string(&self) -> String { + self.name.to_string() + } + pub fn measurement_string() -> String { + "Days".to_string() + } + pub fn measurement_unit() -> String { + "Days".to_string() + } + pub fn expression_string(&self) -> String { + self.expression.to_string() + } + pub fn expression_dtype() -> RasterDataType { + RasterDataType::I8 + } + pub fn year_agg_dtype() -> RasterDataType { + RasterDataType::U16 + } +} diff --git a/backend/src/processes/climate_risk/workflow.rs b/backend/src/processes/climate_risk/workflow.rs new file mode 100644 index 0000000..f243062 --- /dev/null +++ b/backend/src/processes/climate_risk/workflow.rs @@ -0,0 +1,112 @@ +use geoengine_api_client::models::{ + Aggregation, ContinuousMeasurement, Expression, ExpressionParameters, GdalSource, + GdalSourceParameters, Measurement, RasterBandDescriptor, RasterOperator, SingleRasterSource, + SumAggregation, TemporalRasterAggregation, TemporalRasterAggregationParameters, + TimeGranularity, TimeStep, +}; +use tracing::instrument; + +use super::{ClimateRiskProcess, types::*}; +impl ClimateRiskProcess { + #[instrument(skip(var, model, scenario, region))] + pub(crate) fn dataset_raster_source( + var: &ClimateVariableProperties, + model: &CordexModelProperties, + scenario: &ClimateScenarioProperties, + region: &CordexRegionProperties, + ) -> RasterOperator { + let dataset_name = format!( + "cordex_{}_{}_{}_{}", + region.dataset_prefix, + scenario.dataset_prefix, + model.dataset_prefix, + var.dataset_variable_suffix + ); + RasterOperator::GdalSource( + GdalSource { + r#type: Default::default(), + params: GdalSourceParameters { + data: dataset_name, + overview_level: None, + } + .into(), + } + .into(), + ) + } + + pub(crate) fn build_variable_day_expression( + var: &ClimateVariableProperties, + model: &CordexModelProperties, + scenario: &ClimateScenarioProperties, + region: &CordexRegionProperties, + ) -> RasterOperator { + RasterOperator::Expression( + Expression { + r#type: Default::default(), + params: ExpressionParameters { + expression: var.expression_string(), + output_type: ClimateVariableProperties::expression_dtype(), + output_band: Some( + RasterBandDescriptor { + name: var.name_string(), + measurement: Measurement::Continuous( + ContinuousMeasurement { + measurement: ClimateVariableProperties::measurement_string(), + r#type: Default::default(), + unit: Some(Some(ClimateVariableProperties::measurement_unit())), + } + .into(), + ) + .into(), + } + .into(), + ), + map_no_data: false, + } + .into(), + sources: SingleRasterSource { + raster: Self::dataset_raster_source(var, model, scenario, region).into(), + } + .into(), + } + .into(), + ) + } + + pub(crate) fn build_variable_year_agg_workflow( + var: &ClimateVariableProperties, + model: &CordexModelProperties, + scenario: &ClimateScenarioProperties, + region: &CordexRegionProperties, + ) -> RasterOperator { + RasterOperator::TemporalRasterAggregation( + TemporalRasterAggregation { + r#type: Default::default(), + params: TemporalRasterAggregationParameters { + aggregation: Aggregation::SumAggregation(Box::new(SumAggregation { + ignore_no_data: true, + r#type: Default::default(), + })) + .into(), + output_type: Some(Some(ClimateVariableProperties::year_agg_dtype())), + window: TimeStep { + granularity: TimeGranularity::Years, + step: 1, + } + .into(), + // No reference: yearly windows are anchored at the epoch, i.e. calendar-aligned, + // so one workflow serves both the analysis and the reference time range. + window_reference: None, + } + .into(), + sources: SingleRasterSource { + raster: Self::build_variable_day_expression(var, model, scenario, region) + .into(), + } + .into(), + } + .into(), + ) + } +} diff --git a/backend/src/processes/land_use_sealed_area/types.rs b/backend/src/processes/land_use_sealed_area/types.rs index e776a26..effeb7a 100644 --- a/backend/src/processes/land_use_sealed_area/types.rs +++ b/backend/src/processes/land_use_sealed_area/types.rs @@ -247,6 +247,7 @@ pub fn summary_to_data_resource( }, ], primary_key: vec!["landUseType".to_string()].into(), + ..Default::default() }, data: vec![ land_use_summary_row_to_output(site_rows.total_sealed_area, unit_for_area), @@ -291,6 +292,7 @@ pub fn site_to_data_resource( }, ], primary_key: vec!["location".to_string()].into(), + ..Default::default() }, data: site_rows .into_iter() diff --git a/backend/src/processes/mod.rs b/backend/src/processes/mod.rs index 0af0290..a957a12 100644 --- a/backend/src/processes/mod.rs +++ b/backend/src/processes/mod.rs @@ -1,4 +1,5 @@ mod biodiversity_sensitive_areas; +mod climate_risk; mod habitat_distance; mod land_use_sealed_area; mod ndvi; @@ -7,6 +8,7 @@ mod path_info; mod util; pub use biodiversity_sensitive_areas::BiodiversitySensitiveAreasProcess; +pub use climate_risk::ClimateRiskProcess; pub use habitat_distance::HabitatDistanceProcess; pub use land_use_sealed_area::LandUseSealedAreaProcess; pub use ndvi::NDVIProcess; diff --git a/backend/src/processes/parameters/data_resource.rs b/backend/src/processes/parameters/data_resource.rs index 65dae4d..d74c662 100644 --- a/backend/src/processes/parameters/data_resource.rs +++ b/backend/src/processes/parameters/data_resource.rs @@ -8,7 +8,7 @@ pub struct DataResourceSchema; /// Data resources for outputting tabular data with JSON. /// Based on . -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct DataResource { pub name: String, pub data: R, @@ -21,11 +21,16 @@ impl DataResource { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all = "camelCase")] +#[allow(clippy::struct_field_names)] pub struct Fields { + #[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")] + pub schema: Option, pub fields: Vec, pub primary_key: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub biois: Option, } /// Field specification for Table Schema, based on . @@ -69,6 +74,35 @@ pub trait HasTableSchemaType { fn table_schema_type() -> TableSchemaType; } +/// BioIS-specific metadata for rendering a standard Table Schema field. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct BioisTableSchemaExtension { + pub display: std::collections::HashMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hidden_fields: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BioisDisplayMetadata { + /// Semantic category of the rendered value, e.g. a risk probability. + pub kind: BioisDisplayKind, + /// Name of a row property carrying the complete display label for this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label_field: Option, + /// Name of a row property carrying the CSS color for this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_field: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum BioisDisplayKind { + RiskProbability, + RiskAnomaly, +} + #[cfg(test)] mod tests { use super::*; @@ -113,6 +147,8 @@ mod tests { }, ], primary_key: Some(vec!["id".to_string()]), + schema: None, + biois: None, }; let json = serde_json::to_value(&fields).unwrap(); @@ -136,6 +172,8 @@ mod tests { item_type: None, }], primary_key: None, + schema: None, + biois: None, }, }; @@ -182,4 +220,44 @@ mod tests { assert_eq!(json.as_str(), Some(expected_str)); } } + + #[test] + fn it_serializes_display_metadata_with_label_and_color_fields() { + let metadata = BioisDisplayMetadata { + kind: BioisDisplayKind::RiskProbability, + label_field: Some("occurrenceProbabilityLabel".into()), + color_field: Some("occurrenceProbabilityColor".into()), + }; + let json = serde_json::to_value(&metadata).unwrap(); + assert_eq!(json["kind"], "riskProbability"); + assert_eq!(json["labelField"], "occurrenceProbabilityLabel"); + assert_eq!(json["colorField"], "occurrenceProbabilityColor"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + metadata + ); + } + + #[test] + fn it_omits_absent_label_and_color_fields() { + let json = serde_json::to_value(BioisDisplayMetadata { + kind: BioisDisplayKind::RiskAnomaly, + label_field: None, + color_field: None, + }) + .unwrap(); + assert_eq!(json["kind"], "riskAnomaly"); + assert!(json.get("labelField").is_none()); + assert!(json.get("colorField").is_none()); + } + + #[test] + fn it_serializes_hidden_fields() { + let json = serde_json::to_value(BioisTableSchemaExtension { + display: std::collections::HashMap::new(), + hidden_fields: vec!["helperLabel".into()], + }) + .unwrap(); + assert_eq!(json["hiddenFields"], serde_json::json!(["helperLabel"])); + } } diff --git a/backend/src/processes/parameters/mod.rs b/backend/src/processes/parameters/mod.rs index 115b902..9ca6939 100644 --- a/backend/src/processes/parameters/mod.rs +++ b/backend/src/processes/parameters/mod.rs @@ -1,5 +1,5 @@ use geoengine_api_client::models::{BoundingBox2D, Coordinate2D, ProvenanceEntry}; -use geojson::Position; +use geojson::{PointType, Position}; use ogcapi::types::{ common::Crs, processes::description::{DescriptionType, InputDescription, Metadata, OutputDescription}, @@ -10,8 +10,9 @@ use std::collections::HashMap; use utoipa::ToSchema; pub use data_resource::{ - DataResource, DataResourceSchema, Fields, HasTableSchemaType, TableSchemaField, - TableSchemaItemType, TableSchemaType, + BioisDisplayKind, BioisDisplayMetadata, BioisTableSchemaExtension, DataResource, + DataResourceSchema, Fields, HasTableSchemaType, TableSchemaField, TableSchemaItemType, + TableSchemaType, }; #[cfg(test)] pub use geo_json::GeoJsonInputMediaType; @@ -21,7 +22,7 @@ pub use geo_json::{ }; #[cfg(test)] pub use units::Hectare; -pub use units::{Area, Kilometers, Month, Percentage, SquareMeter, UnitForArea, Year}; +pub use units::{Area, Kilometers, Month, Percentage, SquareMeter, UnitForArea, Year, YearRange}; mod data_resource; mod geo_json; @@ -155,6 +156,7 @@ impl From> for DataResource> { }, ], primary_key: vec![DocumentationSource::DATA_FIELD_NAME.to_string()].into(), + ..Default::default() }, } } @@ -252,6 +254,7 @@ impl ToOutputHashMap for [OutputSpec; N] { } } +/// A 2D bounding box in WGS84 coordinates. #[derive(Debug, Clone, PartialEq)] pub struct BoundingBox { minx: f64, @@ -261,6 +264,20 @@ pub struct BoundingBox { crs: Crs, } +pub fn nearest_containing( + point: &PointType, + candidates: impl IntoIterator, +) -> Option { + candidates + .into_iter() + .filter(|(_, bounding_box)| bounding_box.contains(point)) + .min_by(|(_, left), (_, right)| { + left.distance_to_center_squared(point) + .total_cmp(&right.distance_to_center_squared(point)) + }) + .map(|(candidate, _)| candidate) +} + impl BoundingBox { pub fn new(minx: f64, miny: f64, maxx: f64, maxy: f64, crs: Crs) -> Self { Self { @@ -282,6 +299,29 @@ impl BoundingBox { } } + pub fn contains(&self, point: &PointType) -> bool { + let x = point[0]; + let y = point[1]; + x >= self.minx && x <= self.maxx && y >= self.miny && y <= self.maxy + } + + fn distance_to_center_squared(&self, point: &PointType) -> f64 { + let center_x = f64::midpoint(self.minx, self.maxx); + let center_y = f64::midpoint(self.miny, self.maxy); + (point[0] - center_x).powi(2) + (point[1] - center_y).powi(2) + } + + /// Create a small bounding box around a point with the given half-span. + pub fn around_point(point: &PointType, half_span: f64) -> Self { + Self::new( + point[0] - half_span, + point[1] - half_span, + point[0] + half_span, + point[1] + half_span, + Crs::from_epsg(4326), + ) + } + pub fn enlarge_by_positions<'p>(&mut self, other: impl Iterator) { for position in other { self.minx = self.minx.min(position[0]); @@ -415,6 +455,25 @@ mod tests { assert_abs_diff_eq!(bbox_2d.upper_right_coordinate.y, 4.0); } + #[test] + fn it_selects_the_nearest_containing_bounding_box() { + let point = PointType::from(vec![5.0, 5.0]); + let selected = nearest_containing( + &point, + [ + ( + "left", + BoundingBox::new(0.0, 0.0, 10.0, 10.0, Crs::default2d()), + ), + ( + "right", + BoundingBox::new(4.0, 0.0, 20.0, 10.0, Crs::default2d()), + ), + ], + ); + assert_eq!(selected, Some("left")); + } + #[test] fn it_enlarges_bounding_box_with_positions() { let crs = Crs::default2d(); diff --git a/backend/src/processes/parameters/units.rs b/backend/src/processes/parameters/units.rs index 6ffefb8..95ec38e 100644 --- a/backend/src/processes/parameters/units.rs +++ b/backend/src/processes/parameters/units.rs @@ -100,6 +100,12 @@ impl std::fmt::Display for Year { } } +/// Length of a time window in years (e.g., 5 years). +#[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone, PartialEq)] +#[serde(transparent)] +#[schemars(example = YearRange(20))] +pub struct YearRange(#[schemars(range(min = 5, max = 30))] pub u16); + #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema, Copy, Clone)] #[serde(transparent)] #[schemars(example = Month(1))] diff --git a/backend/src/profile.rs b/backend/src/profile.rs new file mode 100644 index 0000000..77743be --- /dev/null +++ b/backend/src/profile.rs @@ -0,0 +1,98 @@ +use axum::{ + http::{HeaderValue, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; + +pub const CLIMATE_RISK_TABLE_SCHEMA_PROFILE: &str = + "/profiles/table-schema/climate-risk/1.0/schema.json"; + +const CLIMATE_RISK_TABLE_SCHEMA: &str = r##"{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "BioIS climate-risk Table Schema extension", + "allOf": [ + { "$ref": "https://datapackage.org/profiles/2.0/tableschema.json" }, + { + "type": "object", + "properties": { + "biois": { + "type": "object", + "required": ["display"], + "properties": { + "display": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/display" + } + }, + "hiddenFields": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + } + } + ], + "$defs": { + "display": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": ["riskProbability", "riskAnomaly"] + }, + "labelField": { + "type": "string", + "description": "Name of a row property carrying the complete display label for this field." + }, + "colorField": { + "type": "string", + "description": "Name of a row property carrying the CSS color for this field." + } + }, + "additionalProperties": false + } + } +}"##; + +pub async fn climate_risk_table_schema_profile() -> Response { + ( + [( + CONTENT_TYPE, + HeaderValue::from_static("application/schema+json"), + )], + CLIMATE_RISK_TABLE_SCHEMA, + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + + #[tokio::test] + async fn it_serves_the_climate_risk_profile() { + let response = climate_risk_table_schema_profile().await; + assert_eq!(response.headers()[CONTENT_TYPE], "application/schema+json"); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let profile: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + profile["title"], + "BioIS climate-risk Table Schema extension" + ); + let display = &profile["$defs"]["display"]; + assert_eq!(display["properties"]["kind"]["enum"][0], "riskProbability"); + assert_eq!(display["properties"]["kind"]["enum"][1], "riskAnomaly"); + assert_eq!(display["properties"]["labelField"]["type"], "string"); + assert_eq!(display["properties"]["colorField"]["type"], "string"); + assert_eq!( + profile["allOf"][1]["properties"]["biois"]["properties"]["hiddenFields"]["type"], + "array" + ); + assert_eq!(display["additionalProperties"], false); + } +} diff --git a/backend/src/server.rs b/backend/src/server.rs index 38342ce..35c6e0e 100644 --- a/backend/src/server.rs +++ b/backend/src/server.rs @@ -6,8 +6,8 @@ use crate::{ handler, jobs::JobHandler, processes::{ - BiodiversitySensitiveAreasProcess, HabitatDistanceProcess, LandUseSealedAreaProcess, - NDVIProcess, ProcessesOpenApiSpec, + BiodiversitySensitiveAreasProcess, ClimateRiskProcess, HabitatDistanceProcess, + LandUseSealedAreaProcess, NDVIProcess, ProcessesOpenApiSpec, }, state::spawn_with_user, }; @@ -32,6 +32,7 @@ pub async fn server() -> anyhow::Result { let mut misc_router = OpenApiRouter::new() .routes(routes!(handler::health_handler)) .nest("/auth", handler::auth_router()) + .merge(handler::profile_router()) .with_state(CONFIG.geoengine.api_config(None)); misc_router @@ -41,6 +42,7 @@ pub async fn server() -> anyhow::Result { let mut processors: Vec> = vec![ Box::new(Echo), Box::new(NDVIProcess), + Box::new(ClimateRiskProcess), Box::new(LandUseSealedAreaProcess), ]; add_habitat_distance_process(&mut processors, db_pool.clone()).await; diff --git a/frontend/src/app/create/create.component.spec.ts b/frontend/src/app/create/create.component.spec.ts index 9c641f8..9572287 100644 --- a/frontend/src/app/create/create.component.spec.ts +++ b/frontend/src/app/create/create.component.spec.ts @@ -1,9 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { vi } from 'vitest'; import { Process, ProcessesApi } from '@geoengine/biois'; -import { CreateComponent } from './create.component'; +import { CreateComponent, inputsForRequest } from './create.component'; import { inputBinding } from '@angular/core'; import { mockResizeObserverClass } from '../util/resize-signal.spec'; +import { FieldType } from './schema-info'; describe('CreateComponent', () => { let component: CreateComponent; @@ -12,7 +14,6 @@ describe('CreateComponent', () => { beforeEach(async () => { globalThis.ResizeObserver = mockResizeObserverClass([]); - // mock ProcessesApi.process early so resource loaders in the component don't perform real network fetches vi.spyOn(ProcessesApi.prototype, 'process').mockResolvedValue(ndviProcess()); await TestBed.configureTestingModule({ @@ -27,9 +28,144 @@ describe('CreateComponent', () => { }); it('should create', () => { - // fixture.componentRef.setInput('processId', 'ndvi'); expect(component).toBeTruthy(); }); + + it('computes the process name from fallback', () => { + expect(component.processName()).toBe('Ndvi'); + }); + + it('parses inputs into typed descriptors', () => { + const inputs = component.inputs(); + expect(inputs.length).toBe(3); + expect(inputs.find((i) => i.key === 'coordinate')?.type).toBe(FieldType.Coordinate); + expect(inputs.find((i) => i.key === 'year')?.type).toBe(FieldType.Integer); + expect(inputs.find((i) => i.key === 'month')?.type).toBe(FieldType.IntegerWithSmallRange); + }); + + it('parses outputs', () => { + expect(component.outputs().length).toBe(2); + }); + + it('sets default form values from constructor effects', () => { + const inputs = component.formModel().inputs; + expect((inputs['coordinate'] as Record)['value']).toBeDefined(); + expect(inputs['year']).toBe(2020); + }); + + it('enables outputs that are not disabled by default', () => { + expect(component.formModel().outputs['ndvi']).toBe(true); + expect(component.formModel().outputs['kNdvi']).toBeUndefined(); + }); + + it('toggleOutput adds and removes outputs', () => { + component.toggleOutput('ndvi', true); + expect(component.formModel().outputs['ndvi']).toBe(true); + component.toggleOutput('ndvi', false); + expect(component.formModel().outputs['ndvi']).toBeUndefined(); + }); + + it('toggleOutput preserves other outputs', () => { + component.toggleOutput('ndvi', true); + component.toggleOutput('kNdvi', true); + component.toggleOutput('ndvi', false); + expect(component.formModel().outputs['kNdvi']).toBe(true); + }); + + it('inputsForRequest omits undefined and null values', () => { + expect( + inputsForRequest({ + year: 2020, + region: null, + referenceYearBegin: undefined, + }), + ).toEqual({ year: 2020 }); + }); + + it('renders title and fieldsets', () => { + fixture.detectChanges(); + const titleEl = fixture.debugElement.query(By.css('app-page-title')); + expect(titleEl).toBeTruthy(); + const fieldsets = fixture.debugElement.queryAll(By.css('fieldset')); + expect(fieldsets.length).toBe(2); + }); + + it('renders output checkboxes', () => { + fixture.detectChanges(); + const checkboxes = fixture.debugElement.queryAll(By.css('mat-checkbox')); + expect(checkboxes.length).toBe(2); + }); + + it('submit button disabled when form invalid', () => { + component.toggleOutput('ndvi', false); + component.toggleOutput('kNdvi', false); + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button[type="submit"]')); + expect((button.nativeElement as HTMLButtonElement).disabled).toBe(true); + }); + + it('renders all default outputs as selected', () => { + fixture.detectChanges(); + expect(component.outputs().length).toBeGreaterThan(0); + const outputKeys = Object.keys(component.formModel().outputs); + expect(outputKeys.length).toBeGreaterThan(0); + }); + + it('renders input number fields for coordinate and integer types', () => { + fixture.detectChanges(); + const numberInputs = fixture.debugElement.queryAll(By.css('input[type="number"]')); + expect(numberInputs.length).toBe(3); + }); +}); + +describe('CreateComponent with diverse input types', () => { + let component: CreateComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + globalThis.ResizeObserver = mockResizeObserverClass([]); + + vi.spyOn(ProcessesApi.prototype, 'process').mockResolvedValue(allTypesProcess()); + + await TestBed.configureTestingModule({ + imports: [CreateComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CreateComponent, { + bindings: [inputBinding('processId', () => 'all-types')], + }); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('parses boolean input', () => { + expect(component.inputs().find((i) => i.key === 'boolInput')?.type).toBe(FieldType.Boolean); + expect(component.formModel().inputs['boolInput']).toBe(false); + }); + + it('parses string array input', () => { + expect(component.inputs().find((i) => i.key === 'stringArrayInput')?.type).toBe( + FieldType.StringArray, + ); + expect(component.formModel().inputs['stringArrayInput']).toEqual(['x', 'y', 'z']); + }); + + it('parses optional string array input as null', () => { + const input = component.inputs().find((i) => i.key === 'optionalStringArrayInput'); + expect(input?.type).toBe(FieldType.StringArray); + expect(input?.optional).toBe(true); + expect(component.formModel().inputs['optionalStringArrayInput']).toBeNull(); + }); + + it('parses GeoJson input', () => { + expect(component.inputs().find((i) => i.key === 'geoJsonInput')?.type).toBe(FieldType.GeoJson); + expect(component.formModel().inputs['geoJsonInput']).toBeInstanceOf(Error); + }); + + it('parses number input', () => { + expect(component.inputs().find((i) => i.key === 'numberInput')?.type).toBe(FieldType.Number); + expect(component.formModel().inputs['numberInput']).toBe(0); + }); }); function ndviProcess(): Process { @@ -124,8 +260,61 @@ function ndviProcess(): Process { kNdvi: { title: 'kNDVI', description: 'The calculated kNDVI value', + metadata: [{ title: '', role: 'default-disabled', href: '' }], schema: null, }, }; return process; } + +function allTypesProcess(): Process { + const process = new Process(); + process.id = 'all-types'; + process.inputs = { + boolInput: { + title: 'Boolean Input', + schema: { type: 'boolean' }, + }, + stringArrayInput: { + title: 'String Array Input', + schema: { + type: 'array', + items: { $ref: '#/$defs/MyEnum' }, + $defs: { + MyEnum: { type: 'string', enum: ['x', 'y', 'z'] }, + }, + }, + }, + optionalStringArrayInput: { + title: 'Optional String Array Input', + schema: { + anyOf: [ + { + type: 'array', + items: { $ref: '#/$defs/MyEnum' }, + $defs: { + MyEnum: { type: 'string', enum: ['x', 'y', 'z'] }, + }, + }, + { type: 'null' }, + ], + }, + }, + geoJsonInput: { + title: 'GeoJSON Input', + schema: { + type: 'object', + title: 'FeatureCollectionGeoJsonInput', + properties: {}, + }, + }, + numberInput: { + title: 'Number Input', + schema: { type: 'number' }, + }, + }; + process.outputs = { + output1: { title: 'Output 1', schema: null }, + }; + return process; +} diff --git a/frontend/src/app/create/create.component.ts b/frontend/src/app/create/create.component.ts index f8811c7..90bbcc9 100644 --- a/frontend/src/app/create/create.component.ts +++ b/frontend/src/app/create/create.component.ts @@ -30,7 +30,7 @@ import { jsonSchemaToZod, defaultInputs, } from './schema-info'; -import { assertNever, isNullOrUndefined } from '../util/assertions'; +import { assertNever } from '../util/assertions'; import { InfoIconComponent } from '../util/info-icon.component'; import { MatError } from '@angular/material/form-field'; @@ -127,6 +127,7 @@ export class CreateComponent { key, title: processOutput.title ?? this.fieldName(key), description: processOutput.description, + defaultEnabled: !processOutput.metadata?.some((meta) => meta.role === 'default-disabled'), })); }); @@ -145,10 +146,14 @@ export class CreateComponent { this.formModel.update((current) => ({ ...current, inputs })); }); - // initially, set all outputs + // initially, set all outputs that are not disabled by default effect(() => { const outputDescriptions = this.outputs(); - const outputs = Object.fromEntries(outputDescriptions.map(({ key }) => [key, true])); + const outputs = Object.fromEntries( + outputDescriptions + .filter(({ defaultEnabled }) => defaultEnabled) + .map(({ key }) => [key, true]), + ); this.formModel.update((current) => ({ ...current, outputs })); }); } @@ -203,15 +208,18 @@ function outputsForRequest(outputs: Record): Record): Record { +export function inputsForRequest(inputs: Record): Record { return Object.fromEntries( - Object.entries(inputs).filter(([_, value]) => !isNullOrUndefined(value)), + Object.entries(inputs).filter(([_, value]) => value !== undefined && value !== null), ); } @@ -267,6 +275,7 @@ function compareInputDescriptionsForSorting( case FieldType.String: case FieldType.RelativeJsonPointer: case FieldType.StringEnum: + case FieldType.StringArray: return 0; case FieldType.Coordinate: case FieldType.GeoJson: diff --git a/frontend/src/app/create/inputs-visualizer.component.ts b/frontend/src/app/create/inputs-visualizer.component.ts index 0f767a2..7f8e7da 100644 --- a/frontend/src/app/create/inputs-visualizer.component.ts +++ b/frontend/src/app/create/inputs-visualizer.component.ts @@ -6,7 +6,13 @@ import { input, output, } from '@angular/core'; -import { InputDescription, FieldType, defaultInput } from './schema-info'; +import { + InputDescription, + FieldType, + defaultInput, + resolveArrayEnumSchema, + resolveSingleEnumSchema, +} from './schema-info'; import { CommonModule } from '@angular/common'; import { FormField, FieldTree, MaybeFieldTree } from '@angular/forms/signals'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -66,6 +72,16 @@ import { InfoIconComponent } from '../util/info-icon.component'; } + @case (FieldType.StringArray) { + + {{ input.title }} + + @for (option of stringArrayOptions(input.schema); track option) { + {{ option }} + } + + + } @case (FieldType.IntegerWithSmallRange) { {{ input.title }} @@ -180,6 +196,7 @@ export class InputsFormComponent { readonly FieldType = FieldType; readonly enumOptions = enumOptions; readonly integerRangeList = integerRangeList; + readonly stringArrayOptions = stringArrayOptions; readonly isFieldSet = computed>(() => { const form = this.form(); @@ -208,6 +225,10 @@ export class InputsFormComponent { return formInput as FieldTree; } + asStringArrayInput(formInput: MaybeFieldTree): FieldTree { + return formInput as FieldTree; + } + asGeoJsonInput( formInput: MaybeFieldTree, ): FieldTree { @@ -234,17 +255,12 @@ export class InputsFormComponent { } } +/** Returns the options for a single string-enum input. */ export function enumOptions(schema: JSONSchema | undefined): string[] { - if (!schema || typeof schema === 'boolean' || !schema.enum || !Array.isArray(schema.enum)) - return []; - - const options = []; - for (const value of schema.enum) { - if (typeof value === 'string') options.push(value); - } - return options; + return resolveSingleEnumSchema(schema) ?? []; } +/** Expands a small bounded integer schema into select options. */ export function integerRangeList(schema: JSONSchema | undefined): number[] { if ( !schema || @@ -261,3 +277,11 @@ export function integerRangeList(schema: JSONSchema | undefined): number[] { } return range; } + +/** Returns all enum values for a string-array input. */ +export function stringArrayOptions(schema: JSONSchema | undefined): string[] { + if (!schema || typeof schema === 'boolean') return []; + + const items = resolveArrayEnumSchema(schema); + return items ? enumOptions(items) : []; +} diff --git a/frontend/src/app/create/schema-info.spec.ts b/frontend/src/app/create/schema-info.spec.ts index 55898ef..e45bc06 100644 --- a/frontend/src/app/create/schema-info.spec.ts +++ b/frontend/src/app/create/schema-info.spec.ts @@ -1,5 +1,12 @@ import { InputDescription as ApiInputDescription } from '@geoengine/biois'; -import { retrieveInputDescription, FieldType, jsonSchemaToZod } from './schema-info'; +import { + retrieveInputDescription, + FieldType, + jsonSchemaToZod, + defaultInput, + defaultInputs, +} from './schema-info'; +import { enumOptions } from './inputs-visualizer.component'; const testInputs: { sites: ApiInputDescription; @@ -7,7 +14,10 @@ const testInputs: { unitForArea: ApiInputDescription; previousYearData: ApiInputDescription; year: ApiInputDescription; + yearRange: ApiInputDescription; + referenceYearBegin: ApiInputDescription; siteTypeField: ApiInputDescription; + region: ApiInputDescription; } = { sites: { title: 'Sites', @@ -194,6 +204,65 @@ const testInputs: { type: 'integer', }, }, + yearRange: { + title: 'Range (years)', + description: 'Length of the climate-risk aggregation window in years (5-30).', + schema: { + $defs: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GeoJSON FeatureCollection': { + $ref: 'https://geojson.org/schema/FeatureCollection.json', + }, + GeoJsonInputMediaType: { + enum: ['application/geo+json'], + type: 'string', + }, + }, + default: 20, + description: 'Length of the climate-risk aggregation window in years (5-30).', + examples: [20], + maximum: 30, + minimum: 5, + title: 'YearRange', + type: 'integer', + }, + }, + referenceYearBegin: { + title: 'Reference period start', + description: + 'First year of the reference period used to compute anomalies. Uses the same range as the analysis window. Set to null to disable anomaly computation.', + schema: { + $defs: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GeoJSON FeatureCollection': { + $ref: 'https://geojson.org/schema/FeatureCollection.json', + }, + GeoJsonInputMediaType: { + enum: ['application/geo+json'], + type: 'string', + }, + Year: { + description: 'Year of reporting or change (e.g., 2023, 2024, etc.)', + examples: [2020], + format: 'uint16', + maximum: 2100, + minimum: 2000, + title: 'Year', + type: 'integer', + }, + }, + anyOf: [ + { + $ref: '#/$defs/Year', + }, + { + type: 'null', + }, + ], + default: 2006, + title: 'Nullable_Year', + }, + }, siteTypeField: { title: 'Site Type Field', description: @@ -224,6 +293,28 @@ const testInputs: { type: 'string', }, }, + region: { + title: 'CORDEX/CMIP5 region', + description: 'The CORDEX/CMIP5 region to use for the climate-risk aggregation.', + schema: { + $defs: { + CordexRegion: { + title: 'CordexRegion', + type: 'string', + enum: ['Eur'], + }, + }, + anyOf: [ + { + $ref: '#/$defs/CordexRegion', + }, + { + type: 'null', + }, + ], + title: 'Nullable_CordexRegion', + }, + }, } as const; describe('retrieveInputDescription', () => { @@ -274,6 +365,46 @@ describe('retrieveInputDescription', () => { }); }); + it('should process IntegerWithSmallRange input (yearRange)', () => { + const result = retrieveInputDescription('yearRange', testInputs.yearRange); + + expect(result).toMatchObject({ + key: 'yearRange', + title: 'Range (years)', + type: FieldType.IntegerWithSmallRange, + optional: false, + }); + }); + + it('should process nullable Integer input with default (referenceYearBegin)', () => { + const result = retrieveInputDescription('referenceYearBegin', testInputs.referenceYearBegin); + + expect(result).toMatchObject({ + key: 'referenceYearBegin', + title: 'Reference period start', + type: FieldType.Integer, + optional: true, + }); + + expect(defaultInput(result)).toBeNull(); + expect(defaultInput(result, { ignoreOptional: true })).toBe(2006); + }); + + it('should process nullable StringEnum input (region) with a usable default', () => { + const result = retrieveInputDescription('region', testInputs.region); + + expect(result).toMatchObject({ + key: 'region', + title: 'CORDEX/CMIP5 region', + type: FieldType.StringEnum, + optional: true, + }); + + expect(defaultInput(result)).toBeNull(); + expect(defaultInput(result, { ignoreOptional: true })).toBe('Eur'); + expect(enumOptions(result.schema)).toEqual(['Eur']); + }); + it('should process nullable input (previousYearData)', () => { const result = retrieveInputDescription('previousYearData', testInputs.previousYearData); @@ -310,6 +441,23 @@ describe('retrieveInputDescription', () => { }); }); +describe('defaultInputs', () => { + it('enables an optional input with a default only when it opts in via metadata', () => { + const input = retrieveInputDescription('referenceYearBegin', { + ...testInputs.referenceYearBegin, + metadata: [{ title: '', role: 'enabled-by-default', href: '' }], + }); + const result = defaultInputs([input]); + expect(result['referenceYearBegin']).toBe(2006); + }); + + it('keeps an optional input with a schema default disabled without the metadata role', () => { + const input = retrieveInputDescription('referenceYearBegin', testInputs.referenceYearBegin); + const result = defaultInputs([input]); + expect(result['referenceYearBegin']).toBeNull(); + }); +}); + describe('jsonSchemaToZod', () => { it('should convert GeoJSON input schema (sites) to Zod schema', () => { const zodSchema = jsonSchemaToZod(retrieveInputDescription('sites', testInputs.sites).schema); diff --git a/frontend/src/app/create/schema-info.ts b/frontend/src/app/create/schema-info.ts index adf149b..19f7cc5 100644 --- a/frontend/src/app/create/schema-info.ts +++ b/frontend/src/app/create/schema-info.ts @@ -36,9 +36,13 @@ export enum FieldType { RelativeJsonPointer = 'relativeJsonPointer', String = 'string', StringEnum = 'stringEnum', + StringArray = 'stringArray', NestedJson = 'nestedJson', } +// UI-only cutoff: bounded integer inputs with at most 40 choices use a select. +const SMALL_INTEGER_RANGE = 40; + export function retrieveInputDescription( key: string, processInput: ApiInputDescription, @@ -98,6 +102,32 @@ function typeFromSchema(schema: JSONSchema | undefined): FieldType { type = type.find((t) => t !== 'null'); } + // Resolve nullable primitives like {"anyOf": [{"$ref": ...}, {"type": "null"}]} to their type + if (!type && (schema.anyOf || schema.oneOf)) { + const branches = (schema.anyOf ?? schema.oneOf) as JSONSchema[]; + const nonNull = branches.find( + (branch) => + typeof branch !== 'object' || + branch === null || + (branch as BaseJSONSchema)['type'] !== 'null', + ); + if (nonNull) { + const resolved = resolveSchemaRef(schema, nonNull); + const resolvedType = + typeof resolved === 'object' && resolved !== null + ? (resolved as BaseJSONSchema)['type'] + : undefined; + if ( + resolvedType === 'string' || + resolvedType === 'number' || + resolvedType === 'integer' || + resolvedType === 'boolean' + ) { + return typeFromSchema(resolved); + } + } + } + if (type === 'string') { if (schema.format === 'relative-json-pointer') return FieldType.RelativeJsonPointer; if (schema.enum) return FieldType.StringEnum; @@ -109,7 +139,7 @@ function typeFromSchema(schema: JSONSchema | undefined): FieldType { if ( typeof schema.maximum === 'number' && typeof schema.minimum === 'number' && - schema.maximum - schema.minimum <= 12 + schema.maximum - schema.minimum <= SMALL_INTEGER_RANGE ) { return FieldType.IntegerWithSmallRange; } @@ -122,6 +152,8 @@ function typeFromSchema(schema: JSONSchema | undefined): FieldType { if (schema.title === 'FeatureCollectionGeoJsonInput') return FieldType.GeoJson; } + if (resolveArrayEnumSchema(schema)) return FieldType.StringArray; + // nested types (for now) if (!type) { return FieldType.NestedJson; @@ -130,6 +162,81 @@ function typeFromSchema(schema: JSONSchema | undefined): FieldType { return FieldType.String; // fallback to string if type cannot be determined } +/** + * Resolve the items schema from an array schema, following `$ref` through `$defs`. + */ +function resolveItemsSchema( + schema: Record, + rootSchema: JSONSchema, +): Record | undefined { + const items = schema['items']; + if (!items || typeof items !== 'object' || Array.isArray(items)) return undefined; + + const itemsObj = items as Record; + if (!('$ref' in itemsObj)) return itemsObj; + const refRoot = '$defs' in schema ? (schema as JSONSchema) : rootSchema; + return resolveSchemaRef(refRoot, itemsObj) as Record; +} + +/** + * Type guard for an array items schema describing a string enum. + */ +function isStringEnumArray( + items: Record | undefined, +): items is { type: 'string'; enum: unknown[] } { + return !!items && items['type'] === 'string' && Array.isArray(items['enum']); +} + +/** Resolves a string-enum array, including nullable and `$ref`-wrapped schemas. */ +export function resolveArrayEnumSchema( + schema: JSONSchema | undefined, +): Record | undefined { + if (!schema || typeof schema === 'boolean') return undefined; + + const schemaRecord = schema as Record; + + const direct = resolveItemsSchema(schemaRecord, schema); + if (isStringEnumArray(direct)) return direct; + + const branches = schemaRecord['anyOf'] ?? schemaRecord['oneOf']; + if (Array.isArray(branches)) { + for (const branch of branches) { + if (typeof branch !== 'object' || branch === null) continue; + const items = resolveItemsSchema(branch as Record, schema); + if (isStringEnumArray(items)) return items; + } + } + + return undefined; +} + +/** Resolves the string enum for one input, including nullable `$ref` branches. */ +export function resolveSingleEnumSchema(schema: JSONSchema | undefined): string[] | undefined { + if (!schema || typeof schema === 'boolean') return undefined; + + const schemaRecord = schema as Record; + + const direct = schemaRecord['enum']; + if (Array.isArray(direct)) + return direct.filter((value): value is string => typeof value === 'string'); + + const branches = schemaRecord['anyOf'] ?? schemaRecord['oneOf']; + if (Array.isArray(branches)) { + for (const branch of branches) { + if (typeof branch !== 'object' || branch === null) continue; + const resolved = resolveSchemaRef(schema, branch as JSONSchema); + if (resolved && typeof resolved === 'object') { + const enumValue = (resolved as Record)['enum']; + if (Array.isArray(enumValue)) { + return enumValue.filter((value): value is string => typeof value === 'string'); + } + } + } + } + + return undefined; +} + function isOptional(schema: JSONSchema | undefined): boolean { function anySubSchemaIsNull(subSchemas: JSONSchema[] | undefined): boolean { if (!subSchemas) return false; @@ -288,16 +395,26 @@ export function jsonSchemaToZod(jsonSchema: JSONSchema): z.ZodTypeAny { throw new Error('Failed to convert JSON Schema to Zod schema.', { cause: errors }); } +/** Metadata role a process must opt into for an optional input to be enabled by default. */ +const ENABLED_BY_DEFAULT_ROLE = 'enabled-by-default'; + +/** Creates initial form values, keeping optional inputs disabled unless they opt in via metadata. */ export function defaultInputs(inputDescriptions: Array): Record { const inputs: Record = {}; for (const input of inputDescriptions) { - // `Input` consists of `any` type + // Only optional inputs whose process description carries the `enabled-by-default` role + // start enabled; this keeps climate-risk anomaly calculation on while letting every + // other process keep its optional inputs off by default. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - inputs[input.key] = defaultInput(input); + inputs[input.key] = defaultInput(input, { ignoreOptional: enabledByDefault(input) }); } return inputs; } +function enabledByDefault(input: InputDescription): boolean { + return !!input.metadata?.some((meta) => meta.role === ENABLED_BY_DEFAULT_ROLE); +} + export function defaultInput( { type, schema, children, optional }: InputDescription, { ignoreOptional }: { ignoreOptional?: boolean } = { ignoreOptional: false }, @@ -322,6 +439,8 @@ export function defaultInput( case FieldType.RelativeJsonPointer: case FieldType.StringEnum: return defaultString(schema, ''); + case FieldType.StringArray: + return stringArrayValues(schema); case FieldType.NestedJson: return { value: defaultInputs(Object.values(children ?? {})), @@ -353,15 +472,30 @@ function defaultString(schema: JSONSchema, fallback: string = ''): string { const defaultValue = schema.default; if (typeof defaultValue === 'string') return defaultValue; - if (!schema.examples || !Array.isArray(schema.examples)) return fallback; + if (!schema.examples || !Array.isArray(schema.examples)) + return firstEnumOrFallback(schema, fallback); for (const example of schema.examples ?? []) { if (typeof example === 'string') return example; } + return firstEnumOrFallback(schema, fallback); +} + +function firstEnumOrFallback(schema: JSONSchema, fallback: string): string { + const firstEnum = resolveSingleEnumSchema(schema)?.[0]; + if (firstEnum !== undefined) return firstEnum; return fallback; } +function stringArrayValues(schema: JSONSchema): string[] { + const items = resolveArrayEnumSchema(schema); + const enumValues = items?.['enum']; + if (!Array.isArray(enumValues)) return []; + + return enumValues.filter((value: unknown): value is string => typeof value === 'string'); +} + function defaultCoordinate(schema: JSONSchema, fallback: [number, number] = [0, 0]): GeoJSONPoint { if (!schema || typeof schema === 'boolean') return geoJsonPointFeature(fallback); diff --git a/frontend/src/app/create/simple-form-field.ts b/frontend/src/app/create/simple-form-field.ts index bbea5d8..a1549f7 100644 --- a/frontend/src/app/create/simple-form-field.ts +++ b/frontend/src/app/create/simple-form-field.ts @@ -16,43 +16,50 @@ import { FieldType } from './schema-info'; @Component({ selector: 'app-simple-form-field', template: ` - - {{ title() }} + + @if (type() === FieldType.Boolean) { + True/False + } @else { + + {{ title() }} - @switch (type()) { - @case (FieldType.String) - @default { - + @switch (type()) { + @case (FieldType.Integer) { + + } + @case (FieldType.Number) { + + } + @case (FieldType.String) + @default { + + } } - @case (FieldType.Integer) { - - } - @case (FieldType.Number) { - - } - @case (FieldType.Boolean) { - True/False - } - } - @for (error of errors(); track error) { - {{ error.message }} - } - + @for (error of errors(); track error) { + {{ error.message }} + } + + } `, styles: ``, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/frontend/src/app/result/data-resource-table.component.spec.ts b/frontend/src/app/result/data-resource-table.component.spec.ts index 1573448..c565363 100644 --- a/frontend/src/app/result/data-resource-table.component.spec.ts +++ b/frontend/src/app/result/data-resource-table.component.spec.ts @@ -47,7 +47,11 @@ describe('DataResourceTableComponent', () => { fields: [ { name: 'title', type: 'string', title: 'Title' }, { name: 'reference', type: 'string', title: 'Reference' }, - { name: 'score', type: 'number', title: 'Score' }, + { + name: 'score', + type: 'number', + title: 'Score', + }, { name: 'active', type: 'boolean', title: 'Active' }, { name: 'tags', type: 'list' }, ], @@ -67,12 +71,83 @@ describe('DataResourceTableComponent', () => { expect(columns).toEqual([ { name: 'Title', key: 'title', type: ColumnType.String, isPrimaryKey: true }, { name: 'Reference', key: 'reference', type: ColumnType.Url, isPrimaryKey: false }, - { name: 'Score', key: 'score', type: ColumnType.Number, isPrimaryKey: false }, + { + name: 'Score', + key: 'score', + type: ColumnType.Number, + isPrimaryKey: false, + }, { name: 'Active', key: 'active', type: ColumnType.Boolean, isPrimaryKey: false }, { name: 'tags', key: 'tags', type: ColumnType.List, isPrimaryKey: false }, ]); }); + it('maps display metadata labelField and colorField onto columns', () => { + const columns = tableColumnInfoFromValue( + { + fields: [ + { name: 'occurrenceProbability', type: 'number', title: 'Occurrence Probability' }, + { name: 'anomaly', type: 'number', title: 'Anomaly' }, + { name: 'occurrenceProbabilityLabel', type: 'string' }, + { name: 'occurrenceProbabilityColor', type: 'string' }, + { name: 'anomalyLabel', type: 'string' }, + { name: 'anomalyColor', type: 'string' }, + ], + biois: { + hiddenFields: [ + 'occurrenceProbabilityLabel', + 'occurrenceProbabilityColor', + 'anomalyLabel', + 'anomalyColor', + ], + display: { + occurrenceProbability: { + kind: 'riskProbability', + labelField: 'occurrenceProbabilityLabel', + colorField: 'occurrenceProbabilityColor', + }, + anomaly: { + kind: 'riskAnomaly', + labelField: 'anomalyLabel', + colorField: 'anomalyColor', + }, + }, + }, + }, + [ + { + occurrenceProbability: 0.03, + occurrenceProbabilityLabel: '7 · high (3 %)', + occurrenceProbabilityColor: '#e53935', + anomaly: 10, + anomalyLabel: '+10 days (+20 %)', + anomalyColor: '#fddbc7', + }, + ], + ); + + expect(columns).toEqual([ + { + name: 'Occurrence Probability', + key: 'occurrenceProbability', + type: ColumnType.Number, + isPrimaryKey: false, + displayKind: 'riskProbability', + labelField: 'occurrenceProbabilityLabel', + colorField: 'occurrenceProbabilityColor', + }, + { + name: 'Anomaly', + key: 'anomaly', + type: ColumnType.Number, + isPrimaryKey: false, + displayKind: 'riskAnomaly', + labelField: 'anomalyLabel', + colorField: 'anomalyColor', + }, + ]); + }); + it('renders typed columns for row values', async () => { const columns: Column[] = [ { name: 'Title', key: 'title', type: ColumnType.String, isPrimaryKey: true }, @@ -121,4 +196,79 @@ describe('DataResourceTableComponent', () => { expect(root.textContent).toContain('forest'); expect(root.textContent).toContain('protected'); }); + + it('renders extension display metadata as a colored chip', async () => { + const columns: Column[] = [ + { + name: 'Occurrence Probability', + key: 'occurrenceProbability', + type: ColumnType.Number, + isPrimaryKey: false, + displayKind: 'riskProbability', + labelField: 'occurrenceProbabilityLabel', + colorField: 'occurrenceProbabilityColor', + }, + { + name: 'Anomaly', + key: 'anomaly', + type: ColumnType.Number, + isPrimaryKey: false, + displayKind: 'riskAnomaly', + labelField: 'anomalyLabel', + colorField: 'anomalyColor', + }, + ]; + + fixture.componentRef.setInput('columns', columns); + fixture.componentRef.setInput('rows', [ + { + occurrenceProbability: 0.13, + occurrenceProbabilityLabel: '8 · very high (13 %)', + occurrenceProbabilityColor: '#c62828', + anomaly: 10, + anomalyLabel: '+10 days (+20 %)', + anomalyColor: '#fddbc7', + }, + ]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const cells = Array.from(root.querySelectorAll('tbody td')); + + expect(cells.length).toBe(2); + const [probability, anomaly] = cells; + expect(probability.querySelector('.color-dot')).not.toBeNull(); + expect(probability.textContent).toContain('8 · very high (13 %)'); + expect(probability.textContent).not.toContain('0.13'); + + const chip = anomaly.querySelector('mat-chip'); + expect(chip).not.toBeNull(); + expect(chip?.textContent).toContain('+10 days (+20 %)'); + const dot = anomaly.querySelector('.color-dot'); + expect(dot?.style.backgroundColor).toBe('rgb(253, 219, 199)'); + }); + + it('falls back to the raw value when display metadata is incomplete', async () => { + fixture.componentRef.setInput('columns', [ + { + name: 'Anomaly', + key: 'anomaly', + type: ColumnType.Number, + isPrimaryKey: false, + displayKind: 'riskAnomaly', + }, + ]); + fixture.componentRef.setInput('rows', [{ anomaly: 10 }]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const cell = root.querySelector('tbody td'); + expect(cell?.textContent).toContain('10'); + }); }); diff --git a/frontend/src/app/result/data-resource-table.component.ts b/frontend/src/app/result/data-resource-table.component.ts index c97a5e3..828fb8e 100644 --- a/frontend/src/app/result/data-resource-table.component.ts +++ b/frontend/src/app/result/data-resource-table.component.ts @@ -25,9 +25,21 @@ import { RowOverflowDirective } from './row-overflow.directive'; } @case (ColumnType.Number) { - - {{ element[column.key] | number: '1.0-2' }} - + @if (column.displayKind) { + + + + {{ displayValue(column, element) }} + + + } @else { + + {{ formatValue(column, element) }} + + } } @case (ColumnType.Boolean) { @@ -94,6 +106,12 @@ import { RowOverflowDirective } from './row-overflow.directive'; padding-bottom: 1rem; vertical-align: top; /* Keeps text nicely aligned at the top during expansion */ + mat-chip:has(.color-dot) { + min-width: 5rem; + width: max-content; + justify-content: center; + } + .cell-content { max-height: calc(2 * 1.4em); /* Limits text to roughly 2 lines */ line-height: 1.4; @@ -104,6 +122,15 @@ import { RowOverflowDirective } from './row-overflow.directive'; transition: max-height 0.25s ease-out; } + + .color-dot { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + margin-right: 0.5rem; + border: 1px solid var(--mat-sys-outline); + border-radius: 50%; + } } tr { @@ -164,6 +191,23 @@ export class DataResourceTableComponent { toggleRow(element: Row): void { this.expandedElement.set(this.isExpanded(element) ? null : element); } + + formatValue(column: Column, element: Row): string { + const value = element[column.key]; + if (typeof value !== 'number') return String(value); + return new Intl.NumberFormat('en', { maximumFractionDigits: 2 }).format(value); + } + + displayValue(column: Column, element: Row): string { + const label = column.labelField ? element[column.labelField] : undefined; + if (typeof label === 'string') return label; + return this.formatValue(column, element); + } + + displayColor(column: Column, element: Row): string { + const color = column.colorField ? element[column.colorField] : undefined; + return typeof color === 'string' ? color : 'transparent'; + } } export type Row = Record; @@ -173,8 +217,13 @@ export interface Column { key: string; type: ColumnType; isPrimaryKey: boolean; + displayKind?: DisplayKind; + labelField?: string; + colorField?: string; } +export type DisplayKind = 'riskProbability' | 'riskAnomaly'; + export enum ColumnType { String = 'string', Number = 'number', @@ -221,13 +270,18 @@ export function tableColumnInfoFromValue( ): Array { if (!('fields' in schema)) return []; - const fields = schema['fields'] as [ - { - name: string; - type?: 'string' | 'number' | 'integer' | 'boolean' | 'list'; - title?: string; - }, - ]; + const fields = schema['fields'] as Array<{ + name: string; + type?: 'string' | 'number' | 'integer' | 'boolean' | 'list'; + title?: string; + }>; + + const display = schema['biois'] as + | { + display?: Record; + hiddenFields?: string[]; + } + | undefined; const primaryKey = new Array(); if ('primaryKey' in schema) { @@ -241,14 +295,21 @@ export function tableColumnInfoFromValue( } } - return fields.map((field) => { - const sampleValue = data[0]?.[field.name]; - const columnType = columnTypeOfField(field.type, sampleValue); - return { - name: field.title ?? field.name, - key: field.name, - type: columnType, - isPrimaryKey: primaryKey.includes(field.name), - }; - }); + const hiddenFields = new Set(display?.hiddenFields ?? []); + return fields + .filter((field) => !hiddenFields.has(field.name)) + .map((field) => { + const sampleValue = data[0]?.[field.name]; + const columnType = columnTypeOfField(field.type, sampleValue); + const metadata = display?.display?.[field.name]; + return { + name: field.title ?? field.name, + key: field.name, + type: columnType, + isPrimaryKey: primaryKey.includes(field.name), + displayKind: metadata?.kind, + labelField: metadata?.labelField, + colorField: metadata?.colorField, + }; + }); } diff --git a/frontend/src/app/result/result.component.ts b/frontend/src/app/result/result.component.ts index d92f94d..abc1867 100644 --- a/frontend/src/app/result/result.component.ts +++ b/frontend/src/app/result/result.component.ts @@ -94,10 +94,11 @@ export class ResultComponent { .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) // TODO: get order from process description when available .map(([key, rawValue]) => { const value = fixDataValue(rawValue) as unknown; + const innerValue = value instanceof QualifiedInputValue ? (value.value as unknown) : value; return { key, - title: this.fieldName(key), - value: value instanceof QualifiedInputValue ? (value.value as unknown) : value, + title: dataResourceTitle(innerValue) ?? this.fieldName(key), + value: innerValue, type: this.typeOfValue(value), }; }); @@ -221,6 +222,12 @@ export class ResultComponent { } } +/** Returns the Data Resource name when a result value contains one. */ +function dataResourceTitle(value: unknown): string | undefined { + if (typeof value !== 'object' || value === null || !('name' in value)) return undefined; + return typeof value.name === 'string' ? value.name : undefined; +} + enum ResultType { Boolean = 'boolean', Errors = 'errors', From a5b4f2e0886803dfd8a41621cb5c865999208d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Fri, 14 Aug 2026 08:11:01 +0200 Subject: [PATCH 2/5] fix variables order --- backend/src/processes/climate_risk/compute.rs | 13 +++++++++---- k8s/pod.yaml | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/src/processes/climate_risk/compute.rs b/backend/src/processes/climate_risk/compute.rs index dc43129..68bfd7f 100644 --- a/backend/src/processes/climate_risk/compute.rs +++ b/backend/src/processes/climate_risk/compute.rs @@ -156,8 +156,11 @@ fn risk_fields(rows: &[ClimateRiskRow], reference_period: Option<&str>) -> Vec, + mut rows: Vec, ) -> DataResource> { + rows.sort_by(|a, b| { + (&a.variable, &a.scenario, &a.model).cmp(&(&b.variable, &b.scenario, &b.model)) + }); DataResource { name: "Raw Ensemble Data".to_string(), data: rows, @@ -454,14 +457,16 @@ fn build_workflows( // request can register ~18 workflows and run ~36 WFS queries. const MAX_CONCURRENT_GEOENGINE_REQUESTS: usize = 8; -/// Runs async jobs with bounded concurrency, preserving input order. +/// Runs async jobs with bounded concurrency, yielding results in input order. async fn run_limited(jobs: Vec) -> Result, E> where F: FnOnce() -> Fut, Fut: std::future::Future>, { + // `buffered`: results must stay aligned with the input requests, + // otherwise a workflow's data is attributed to the wrong (variable, scenario) pair. futures::stream::iter(jobs.into_iter().map(|job| job())) - .buffer_unordered(MAX_CONCURRENT_GEOENGINE_REQUESTS) + .buffered(MAX_CONCURRENT_GEOENGINE_REQUESTS) .try_collect() .await } @@ -699,7 +704,7 @@ async fn wfs_query( None, Some("EPSG:4326"), Some(time), - None, + Some(workflow_id), None, ) .await diff --git a/k8s/pod.yaml b/k8s/pod.yaml index 489bcdd..f9daa2c 100644 --- a/k8s/pod.yaml +++ b/k8s/pod.yaml @@ -80,7 +80,7 @@ spec: periodSeconds: 10 volumeMounts: - name: pgdata - mountPath: /var/lib/postgresql/data + mountPath: /var/lib/postgresql volumes: - name: pgdata persistentVolumeClaim: From 7667534c4384d2b6d729597ef8a221efdd5b83f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Fri, 14 Aug 2026 08:21:17 +0200 Subject: [PATCH 3/5] fix lints --- backend/src/processes/climate_risk/compute.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/processes/climate_risk/compute.rs b/backend/src/processes/climate_risk/compute.rs index 68bfd7f..c4fc8bb 100644 --- a/backend/src/processes/climate_risk/compute.rs +++ b/backend/src/processes/climate_risk/compute.rs @@ -324,7 +324,7 @@ impl From for ExecuteResults { ); } Err(error) => { - tracing::warn!("Failed to serialize the raw ensemble data output: {error}") + tracing::warn!("Failed to serialize the raw ensemble data output: {error}"); } } } From 16deb935acd29978090a815d23d972c73acdc908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Fri, 14 Aug 2026 09:55:22 +0200 Subject: [PATCH 4/5] simplify anomaly selection logic --- backend/src/processes/climate_risk/compute.rs | 30 ++++----- backend/src/processes/climate_risk/mod.rs | 29 +++------ backend/src/processes/climate_risk/tests.rs | 65 ++++++++++++------- backend/src/processes/climate_risk/types.rs | 9 ++- frontend/src/app/create/create.component.ts | 3 +- frontend/src/app/create/schema-info.spec.ts | 59 +++++------------ frontend/src/app/create/schema-info.ts | 14 +--- 7 files changed, 89 insertions(+), 120 deletions(-) diff --git a/backend/src/processes/climate_risk/compute.rs b/backend/src/processes/climate_risk/compute.rs index c4fc8bb..73dd75b 100644 --- a/backend/src/processes/climate_risk/compute.rs +++ b/backend/src/processes/climate_risk/compute.rs @@ -594,7 +594,7 @@ pub(crate) async fn compute_climate( coordinate: &PointType, Year(start_year): Year, YearRange(range): YearRange, - reference_year: Option, + Year(reference_year): Year, requests: &[(ClimateVariableRequest, ClimateScenarioProperties)], models: &[CordexModelProperties], region: &CordexRegionProperties, @@ -608,12 +608,10 @@ pub(crate) async fn compute_climate( let time_str_analysis = format!("{start_year:04}-01-01T00:00:00Z/{end_analysis:04}-01-01T00:00:00Z"); let analysis_period = format!("{start_year:04}–{:04}", end_analysis - 1); - let reference_time = reference_year.map(|Year(reference_year)| { - let end_reference = reference_year + range; - format!("{reference_year:04}-01-01T00:00:00Z/{end_reference:04}-01-01T00:00:00Z") - }); - let reference_period = reference_year - .map(|Year(reference_year)| format!("{reference_year:04}–{}", reference_year + range - 1)); + let end_reference = reference_year + range; + let reference_time = + format!("{reference_year:04}-01-01T00:00:00Z/{end_reference:04}-01-01T00:00:00Z"); + let reference_period = format!("{reference_year:04}–{}", reference_year + range - 1); let bbox = BoundingBox::around_point(coordinate, POINT_BBOX_HALF_SPAN); let bbox_string = bbox.wfs_string(); @@ -637,12 +635,13 @@ pub(crate) async fn compute_climate( ) .await?; - let reference_results = match &reference_time { - Some(reference_time) => { - Some(query_workflows(configuration, &workflow_ids, &bbox_string, reference_time).await?) - } - None => None, - }; + let reference_results = Some(query_workflows( + configuration, + &workflow_ids, + &bbox_string, + &reference_time, + ) + .await?); for (i, analysis) in analysis_results.iter().enumerate() { for (j, feature) in analysis.features.iter().enumerate() { @@ -668,12 +667,11 @@ pub(crate) async fn compute_climate( let climate_risk = Some(climate_risk_data_resource( rows, &analysis_period, - reference_period.as_deref(), + Some(reference_period.as_str()), )); - Ok(ClimateRiskOutputs { analysis_period: Some(analysis_period), - reference_period, + reference_period: Some(reference_period), climate_risk, raw_ensemble_data: if raw_rows.is_empty() { None diff --git a/backend/src/processes/climate_risk/mod.rs b/backend/src/processes/climate_risk/mod.rs index cc69ebd..eb21d88 100644 --- a/backend/src/processes/climate_risk/mod.rs +++ b/backend/src/processes/climate_risk/mod.rs @@ -75,7 +75,7 @@ impl Processor for ClimateRiskProcess { } fn version(&self) -> &'static str { - "0.1.0" + "0.2.0" } #[allow(clippy::too_many_lines)] @@ -84,9 +84,8 @@ impl Processor for ClimateRiskProcess { settings.meta_schema = None; let mut generator = settings.into_generator(); - let mut reference_year_begin_schema = - generator.root_schema_for::>().to_value(); - reference_year_begin_schema["default"] = serde_json::json!(DATA_START_YEAR); + let mut reference_year_begin_schema = generator.root_schema_for::().to_value(); + reference_year_begin_schema["default"] = serde_json::json!(default_reference_year_begin().0); let inputs = HashMap::from([ ( @@ -133,17 +132,11 @@ impl Processor for ClimateRiskProcess { description_type: DescriptionType { title: Some("Reference period start".to_string()), description: Some( - "First year of the reference period used to compute anomalies. Uses the same range as the analysis window. Disable the input to turn off anomaly computation.".to_string(), + "First year of the reference period used to compute anomalies. Uses the same range as the analysis window.".to_string(), ), - metadata: vec![Metadata { - title: None, - role: Some("enabled-by-default".to_string()), - href: None, - }], ..Default::default() }, schema: reference_year_begin_schema, - min_occurs: Some(0), ..Default::default() }, ), @@ -380,7 +373,7 @@ fn parse_inputs( fn validate_inputs( Year(start_year): Year, YearRange(range): YearRange, - reference_year: Option, + Year(reference_year): Year, ) -> Result<()> { if !(5..=30).contains(&range) { anyhow::bail!("Year range must be between 5 and 30 years"); @@ -391,13 +384,11 @@ fn validate_inputs( if start_year + range > 2100 { anyhow::bail!("Start year plus range must not exceed 2100"); } - if let Some(Year(reference_year)) = reference_year { - if reference_year < DATA_START_YEAR { - anyhow::bail!("Reference period start year must be at least {DATA_START_YEAR}"); - } - if reference_year + range > 2100 { - anyhow::bail!("Reference period start year plus range must not exceed 2100"); - } + if reference_year < DATA_START_YEAR { + anyhow::bail!("Reference period start year must be at least {DATA_START_YEAR}"); + } + if reference_year + range > 2100 { + anyhow::bail!("Reference period start year plus range must not exceed 2100"); } Ok(()) } diff --git a/backend/src/processes/climate_risk/tests.rs b/backend/src/processes/climate_risk/tests.rs index 26df7f2..523d03d 100644 --- a/backend/src/processes/climate_risk/tests.rs +++ b/backend/src/processes/climate_risk/tests.rs @@ -32,7 +32,7 @@ fn it_deserializes_the_input() { inputs.variables, vec![ClimateVariable::HeatDays, ClimateVariable::IceDays] ); - assert_eq!(inputs.reference_year_begin, Some(Year(2020))); + assert_eq!(inputs.reference_year_begin, Year(2020)); assert_eq!(inputs.region, Some(CordexRegion::Eur)); } @@ -45,15 +45,34 @@ fn it_deserializes_omitted_optional_inputs_as_their_defaults() { "coordinates": [12.34, 56.78] }, "mediaType": "application/geo+json" - } + }, + "referenceYearBegin": 2020 }); let inputs: HashMap = serde_json::from_value(payload).unwrap(); let inputs = parse_inputs(&inputs).unwrap(); - assert_eq!(inputs.reference_year_begin, None); + assert_eq!(inputs.reference_year_begin, Year(2020)); assert_eq!(inputs.region, None); } +#[test] +fn it_rejects_missing_reference_year_begin() { + let payload = json!({ + "coordinate": { + "value": { + "type": "Point", + "coordinates": [12.34, 56.78] + }, + "mediaType": "application/geo+json" + } + }); + + let inputs: HashMap = serde_json::from_value(payload).unwrap(); + let error = format!("{:#}", parse_inputs(&inputs).unwrap_err()); + + assert!(error.contains("referenceYearBegin"), "{error}"); +} + #[test] fn it_rejects_malformed_inputs_with_context() { let payload = json!({ "coordinate": { "value": { "type": "Point" }, "mediaType": "application/geo+json" } }); @@ -73,7 +92,7 @@ fn it_process_summary_has_expected_inputs_and_outputs() { let process = ClimateRiskProcess.process().unwrap(); assert_eq!(process.summary.id, "climate-risk"); - assert_eq!(process.summary.version, "0.1.0"); + assert_eq!(process.summary.version, "0.2.0"); assert!(!process.inputs.contains_key("scenarios")); assert!(!process.inputs.contains_key("yearEnd")); @@ -101,59 +120,57 @@ fn it_process_summary_has_expected_inputs_and_outputs() { } #[test] -fn it_reference_year_begin_schema_is_nullable_with_default() { +fn it_reference_year_begin_schema_is_required_with_default() { let process = ClimateRiskProcess.process().unwrap(); let input = &process.inputs["referenceYearBegin"]; - assert_eq!(input.schema["anyOf"][0]["$ref"], json!("#/$defs/Year")); - assert_eq!(input.schema["anyOf"][1]["type"], json!("null")); - assert_eq!(input.schema["default"], json!(DATA_START_YEAR)); + assert_eq!(input.schema["type"], json!("integer")); + assert!(input.schema.get("anyOf").is_none()); + assert_eq!(input.schema["default"], json!(2020)); assert_eq!( - input - .description_type - .metadata - .first() - .and_then(|m| m.role.as_deref()), - Some("enabled-by-default") + input.description_type.metadata.len(), + 0, + "no metadata should be present: {:#?}", + input.description_type.metadata ); + assert_eq!(input.min_occurs.unwrap_or(1), 1); } #[test] fn it_validate_inputs_rejects_range_below_min() { - assert!(validate_inputs(Year(2014), YearRange(4), Some(Year(2020))).is_err()); + assert!(validate_inputs(Year(2014), YearRange(4), Year(2020)).is_err()); } #[test] fn it_validate_inputs_rejects_range_above_max() { - assert!(validate_inputs(Year(2014), YearRange(31), Some(Year(2020))).is_err()); + assert!(validate_inputs(Year(2014), YearRange(31), Year(2020)).is_err()); } #[test] fn it_validate_inputs_rejects_range_beyond_2100() { - assert!(validate_inputs(Year(2080), YearRange(30), Some(Year(2020))).is_err()); + assert!(validate_inputs(Year(2080), YearRange(30), Year(2020)).is_err()); } #[test] fn it_validate_inputs_rejects_reference_before_data_start() { - assert!(validate_inputs(Year(2014), YearRange(20), Some(Year(2005))).is_err()); + assert!(validate_inputs(Year(2014), YearRange(20), Year(2005)).is_err()); } #[test] fn it_validate_inputs_rejects_start_year_before_data_start() { - assert!(validate_inputs(Year(2005), YearRange(20), Some(Year(2020))).is_err()); - assert!(validate_inputs(Year(2006), YearRange(20), Some(Year(2020))).is_ok()); + assert!(validate_inputs(Year(2005), YearRange(20), Year(2020)).is_err()); + assert!(validate_inputs(Year(2006), YearRange(20), Year(2020)).is_ok()); } #[test] fn it_validate_inputs_rejects_reference_beyond_2100() { - assert!(validate_inputs(Year(2014), YearRange(20), Some(Year(2090))).is_err()); + assert!(validate_inputs(Year(2014), YearRange(20), Year(2090)).is_err()); } #[test] fn it_validate_inputs_accepts_valid_range() { - assert!(validate_inputs(Year(2014), YearRange(5), Some(Year(2020))).is_ok()); - assert!(validate_inputs(Year(2014), YearRange(30), Some(Year(2020))).is_ok()); - assert!(validate_inputs(Year(2014), YearRange(30), None).is_ok()); + assert!(validate_inputs(Year(2014), YearRange(5), Year(2020)).is_ok()); + assert!(validate_inputs(Year(2014), YearRange(30), Year(2020)).is_ok()); } #[test] diff --git a/backend/src/processes/climate_risk/types.rs b/backend/src/processes/climate_risk/types.rs index f3ce3db..59a3d61 100644 --- a/backend/src/processes/climate_risk/types.rs +++ b/backend/src/processes/climate_risk/types.rs @@ -243,9 +243,8 @@ pub(crate) const DATA_START_YEAR: u16 = 2006; // Climate values are aggregated using a Julian year. pub(crate) const DAYS_PER_JULIAN_YEAR: f64 = 365.25; -#[allow(clippy::unnecessary_wraps)] -fn default_reference_year_begin() -> Option { - Some(Year(DATA_START_YEAR)) +pub(crate) fn default_reference_year_begin() -> Year { + Year(2020) } fn default_year_range() -> YearRange { @@ -270,9 +269,9 @@ pub struct ClimateRiskInputs { #[serde(default = "default_year_range")] #[schemars(default = "default_year_range")] pub year_range: YearRange, - #[serde(default)] #[schemars(default = "default_reference_year_begin")] - pub reference_year_begin: Option, + #[schema(minimum = 2006, maximum = 2100)] + pub reference_year_begin: Year, #[serde(default = "default_variables")] pub variables: Vec, #[serde(default = "default_models")] diff --git a/frontend/src/app/create/create.component.ts b/frontend/src/app/create/create.component.ts index 90bbcc9..4a2c385 100644 --- a/frontend/src/app/create/create.component.ts +++ b/frontend/src/app/create/create.component.ts @@ -211,8 +211,7 @@ function outputsForRequest(outputs: Record): Record { }); }); - it('should process nullable Integer input with default (referenceYearBegin)', () => { + it('should process non-nullable Integer input with a default (referenceYearBegin)', () => { const result = retrieveInputDescription('referenceYearBegin', testInputs.referenceYearBegin); expect(result).toMatchObject({ key: 'referenceYearBegin', title: 'Reference period start', type: FieldType.Integer, - optional: true, + optional: false, }); - expect(defaultInput(result)).toBeNull(); - expect(defaultInput(result, { ignoreOptional: true })).toBe(2006); + expect(defaultInput(result)).toBe(2020); }); it('should process nullable StringEnum input (region) with a usable default', () => { @@ -442,19 +420,16 @@ describe('retrieveInputDescription', () => { }); describe('defaultInputs', () => { - it('enables an optional input with a default only when it opts in via metadata', () => { - const input = retrieveInputDescription('referenceYearBegin', { - ...testInputs.referenceYearBegin, - metadata: [{ title: '', role: 'enabled-by-default', href: '' }], - }); + it('keeps optional inputs disabled by default', () => { + const input = retrieveInputDescription('region', testInputs.region); const result = defaultInputs([input]); - expect(result['referenceYearBegin']).toBe(2006); + expect(result['region']).toBeNull(); }); - it('keeps an optional input with a schema default disabled without the metadata role', () => { + it('enables required inputs with their schema default', () => { const input = retrieveInputDescription('referenceYearBegin', testInputs.referenceYearBegin); const result = defaultInputs([input]); - expect(result['referenceYearBegin']).toBeNull(); + expect(result['referenceYearBegin']).toBe(2020); }); }); diff --git a/frontend/src/app/create/schema-info.ts b/frontend/src/app/create/schema-info.ts index 19f7cc5..fc0d6c6 100644 --- a/frontend/src/app/create/schema-info.ts +++ b/frontend/src/app/create/schema-info.ts @@ -395,26 +395,16 @@ export function jsonSchemaToZod(jsonSchema: JSONSchema): z.ZodTypeAny { throw new Error('Failed to convert JSON Schema to Zod schema.', { cause: errors }); } -/** Metadata role a process must opt into for an optional input to be enabled by default. */ -const ENABLED_BY_DEFAULT_ROLE = 'enabled-by-default'; - -/** Creates initial form values, keeping optional inputs disabled unless they opt in via metadata. */ +/** Creates initial form values, keeping optional inputs disabled unless they are enabled in the UI. */ export function defaultInputs(inputDescriptions: Array): Record { const inputs: Record = {}; for (const input of inputDescriptions) { - // Only optional inputs whose process description carries the `enabled-by-default` role - // start enabled; this keeps climate-risk anomaly calculation on while letting every - // other process keep its optional inputs off by default. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - inputs[input.key] = defaultInput(input, { ignoreOptional: enabledByDefault(input) }); + inputs[input.key] = defaultInput(input); } return inputs; } -function enabledByDefault(input: InputDescription): boolean { - return !!input.metadata?.some((meta) => meta.role === ENABLED_BY_DEFAULT_ROLE); -} - export function defaultInput( { type, schema, children, optional }: InputDescription, { ignoreOptional }: { ignoreOptional?: boolean } = { ignoreOptional: false }, From d935a92b24d04f3950e2d0f3e43cdb8692bba988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Dr=C3=B6nner?= Date: Fri, 14 Aug 2026 10:00:36 +0200 Subject: [PATCH 5/5] fix lints --- backend/src/processes/climate_risk/compute.rs | 9 ++------- backend/src/processes/climate_risk/mod.rs | 3 ++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/backend/src/processes/climate_risk/compute.rs b/backend/src/processes/climate_risk/compute.rs index 73dd75b..b04d26f 100644 --- a/backend/src/processes/climate_risk/compute.rs +++ b/backend/src/processes/climate_risk/compute.rs @@ -635,13 +635,8 @@ pub(crate) async fn compute_climate( ) .await?; - let reference_results = Some(query_workflows( - configuration, - &workflow_ids, - &bbox_string, - &reference_time, - ) - .await?); + let reference_results = + Some(query_workflows(configuration, &workflow_ids, &bbox_string, &reference_time).await?); for (i, analysis) in analysis_results.iter().enumerate() { for (j, feature) in analysis.features.iter().enumerate() { diff --git a/backend/src/processes/climate_risk/mod.rs b/backend/src/processes/climate_risk/mod.rs index eb21d88..9696a09 100644 --- a/backend/src/processes/climate_risk/mod.rs +++ b/backend/src/processes/climate_risk/mod.rs @@ -85,7 +85,8 @@ impl Processor for ClimateRiskProcess { let mut generator = settings.into_generator(); let mut reference_year_begin_schema = generator.root_schema_for::().to_value(); - reference_year_begin_schema["default"] = serde_json::json!(default_reference_year_begin().0); + reference_year_begin_schema["default"] = + serde_json::json!(default_reference_year_begin().0); let inputs = HashMap::from([ (