//! Endpoints for the Python models in code/. Region models (flood_tool, timeline, pipeline) run //! on request as subprocesses, like the scorer in /region; models trained on the CEMS AOIs //! (road_model, interp_model, evaluate, report) serve their stored outputs. use anyhow::{bail, Context, Result}; use crate::extract::{JsonBody, PathArg}; use crate::{blocking, NotFound}; use axum::{body::Body, extract::State, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router}; use serde::Deserialize; use serde_json::{json, Map, Value}; use std::{path::PathBuf, process::Command}; #[derive(Clone)] pub struct Models { pub python: PathBuf, /// code/ folder: scripts, and their results in code/outputs pub code: PathBuf, pub data: PathBuf, } const ROAD_DAMAGE_NOTE: &str = "Road damage model trained on CEMS grading maps, leave-one-area-out; \ damage_prob is not calibrated. Impassable = EFAS depth >= 0.3 m."; /// Output / zone names: letters, digits, _ or - pub fn check_name(name: &str) -> Result<()> { if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || "_-".contains(c)) { bail!("name must be letters, digits, _ or -"); } Ok(()) } /// Region as the Python tools take it: "w,s,e,n", a CEMS AOI name (e.g. EMSR517_AOI15_GRA), or a /// GeoJSON polygon written to a file #[derive(Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] enum Region { Bbox { bbox: [f64; 4] }, Aoi { aoi: String }, Polygon { geometry: Value }, } impl Models { fn out(&self) -> PathBuf { self.code.join("outputs") } fn public(&self, v: Value) -> Value { public(v, &self.out()) } fn region_arg(&self, r: &Region, name: &str) -> Result { Ok(match r { Region::Bbox { bbox: [w, s, e, n] } => { // same shape rule as /region; Python would silently reorder an inverted bbox if !([w, s, e, n].iter().all(|v| v.is_finite()) && w < e && s < n && w.abs() <= 180.0 && e.abs() <= 180.0 && s.abs() <= 90.0 && n.abs() <= 90.0) { bail!("bbox must be [west, south, east, north] in degrees with west < east and south < north"); } format!("{w},{s},{e},{n}") } Region::Aoi { aoi } => { self.cems_folder(aoi)?; // 422 if malformed, 404 if unknown, before Python aoi.clone() } Region::Polygon { geometry } => { let dir = self.out().join("api_regions"); std::fs::create_dir_all(&dir)?; let path = dir.join(format!("{name}.geojson")); let fc = json!({"type": "FeatureCollection", "crs": {"type": "name", "properties": {"name": "EPSG:4326"}}, "features": [{"type": "Feature", "geometry": geometry, "properties": {}}]}); std::fs::write(&path, fc.to_string())?; path.to_string_lossy().into() } }) } /// Run a script in code/; on failure return only its last stderr line (the scripts exit with a /// one-line message; for an uncaught exception that is the exception line, not the traceback) fn python(&self, script: &str, args: &[String]) -> Result { let out = Command::new(&self.python).arg(self.code.join(script)).args(args).current_dir(&self.code) .output().with_context(|| format!("run {script}"))?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); let last = err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no error message"); bail!("{script}: {}", last.trim()); } Ok(String::from_utf8_lossy(&out.stdout).into()) } /// CEMS PRODUCT vector folder of an AOI with its product type, e.g. EMSR517_AOI15_GRA fn cems_folder(&self, aoi: &str) -> Result { check_aoi(aoi)?; if aoi.split('_').count() != 3 { bail!("AOI {aoi:?} needs its product type here, e.g. {aoi}_DEL or {aoi}_GRA"); } for d in std::fs::read_dir(&self.data)?.flatten().filter(|d| d.file_name().to_string_lossy().starts_with("ems_")) { for f in std::fs::read_dir(d.path())?.flatten() { let n = f.file_name().to_string_lossy().into_owned(); if n.starts_with(&format!("{aoi}_PRODUCT_")) && n.ends_with("_vector") { return Ok(f.path()); } } } Err(NotFound(format!("unknown AOI {aoi}: no CEMS PRODUCT folder")).into()) } } /// Replace every absolute path under `outputs` (code/outputs) in a response with its /files/ URL pub fn public(mut v: Value, outputs: &std::path::Path) -> Value { fn walk(v: &mut Value, root: &str) { match v { Value::String(s) if s.starts_with(root) => *s = format!("/files/{}", s[root.len()..].trim_start_matches('/')), Value::Array(a) => a.iter_mut().for_each(|x| walk(x, root)), Value::Object(o) => o.values_mut().for_each(|x| walk(x, root)), _ => {} } } walk(&mut v, &outputs.to_string_lossy()); v } /// CSV as a list of objects; numbers parsed, empty cells null fn csv_rows(path: &std::path::Path, keep: impl Fn(&csv::StringRecord, &csv::StringRecord) -> bool) -> Result> { let mut rd = csv::Reader::from_path(path).with_context(|| format!("{} (run the model first)", path.display()))?; let head = rd.headers()?.clone(); let mut rows = vec![]; for rec in rd.records() { let rec = rec?; if !keep(&head, &rec) { continue; } let obj: Map = head.iter().zip(rec.iter()).map(|(k, v)| { let val = if v.is_empty() { Value::Null } else { v.parse::().map_or_else(|_| json!(v), |x| json!(x)) }; (k.to_string(), val) }).collect(); rows.push(Value::Object(obj)); } Ok(rows) } /// AOI code: EMSR<3 digits>_AOI<2 digits>, optionally _DEL or _GRA fn check_aoi(aoi: &str) -> Result<()> { let p: Vec<&str> = aoi.split('_').collect(); let digits = |s: Option<&str>, n: usize| s.is_some_and(|s| s.len() == n && s.bytes().all(|b| b.is_ascii_digit())); let ok = (p.len() == 2 || p.len() == 3) && digits(p[0].strip_prefix("EMSR"), 3) && digits(p[1].strip_prefix("AOI"), 2) && p.get(2).map_or(true, |t| matches!(*t, "DEL" | "GRA")); if !ok { bail!("AOI {aoi:?} is not a code like EMSR517_AOI15 or EMSR517_AOI15_GRA"); } Ok(()) } /// Rows whose `col` is the AOI, or the AOI followed by `_` (product type, observation pair) fn by_aoi<'a>(col: &'a str, aoi: &'a str) -> impl Fn(&csv::StringRecord, &csv::StringRecord) -> bool + 'a { move |h, r| h.iter().position(|c| c == col).and_then(|i| r.get(i)) .is_some_and(|v| v == aoi || v.strip_prefix(aoi).is_some_and(|rest| rest.starts_with('_'))) } #[derive(Deserialize)] struct FloodReq { name: String, region: Region, /// rp10 | rp100 | rp500 | risk>= | observed (AOI regions only) #[serde(default = "rp100")] floods: Vec, #[serde(default)] allow_outside: bool, } fn rp100() -> Vec { vec!["rp100".into()] } fn flood(m: &Models, req: FloodReq) -> Result { check_name(&req.name)?; if !matches!(req.region, Region::Aoi { .. }) && req.floods.iter().any(|f| f == "observed") { bail!("flood 'observed' needs a CEMS AOI region (type aoi)"); } for f in &req.floods { let ok = matches!(f.as_str(), "rp10" | "rp100" | "rp500" | "observed") || f.strip_prefix("risk>=").is_some_and(|t| t.parse::().is_ok_and(|t| (0.0..=1.0).contains(&t))); if !ok { bail!("flood {f:?}: use rp10, rp100, rp500, observed or risk>=<0..1>"); } } let mut args = vec!["--region".into(), m.region_arg(&req.region, &req.name)?, "--name".into(), req.name.clone(), "--flood".into()]; args.extend(req.floods.iter().cloned()); if req.allow_outside { args.push("--allow-outside".into()); } m.python("flood_tool.py", &args)?; let dir = m.out().join("regions").join(&req.name); let mut summary: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("summary.json"))?)?; // per-flood tables of THIS request (the folder may hold older runs under the same name): // danger_