diff --git a/docs/architecture/boundaries.md b/docs/architecture/boundaries.md index 2f314b0..50f9cab 100644 --- a/docs/architecture/boundaries.md +++ b/docs/architecture/boundaries.md @@ -4,7 +4,7 @@ |---|---|---| | `siderust::formats` | Standard-format parsing and writing | Service orchestration and transport. | | `siderust::pod` | Force models, propagation, observations, estimation, QC, products, and run metadata. | Service configuration and transport. | -| `spod::service` | Configuration loading, job orchestration, service-owned adapters, artifact layout, and manifest finalisation using `siderust::pod`. | Reusable numerical algorithms. | +| `spod::service` | Configuration and validation, workflow dispatch, run orchestration, input boundaries, artifact layout, and provenance finalisation using `siderust::pod`. | Reusable numerical algorithms and scientific compatibility facades. | | `spod` binaries | `clap`-based commands and the REST server; delegates to `spod::service`. | Any numerical logic. | The full set of allowed and forbidden dependency edges is in diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e597f78..5becbb0 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,28 +1,33 @@ # Architecture Overview -`spod` is a service/application built on Siderust's canonical POD APIs. -It owns configuration, external interfaces, orchestration, and artifacts; +`spod` is an application/service built on Siderust's canonical POD APIs. +Its public boundary is the service runner, which owns configuration, +workflow dispatch, job/run orchestration, input validation, artifacts, +and provenance; reusable dynamics, observations, estimation, QC, products, and run metadata remain in `siderust::pod`. +The current `synthetic` workflow is the deterministic reference/integration +workflow. Real-data inputs are rejected explicitly until a real-data workflow +is introduced. Full Vallado SGP4/SDP4 remains a temporary local exception +while semantic parity is tracked by spod #29 and Siderust #98. + ## Dependency structure -The crate consumes `qtty`, `tempoch`, `affn`, `cheby`, `principia`, and -`siderust` from crates.io. Siderust owns foundational astronomy and -astrodynamics. Principia owns reusable numerical mechanics. This repository -adds POD-specific composition and applications; it does not vendor or patch -those upstream crates. +The crate directly depends on `siderust` for reusable POD/scientific +functionality and on `sgp4` for the temporary full Vallado compatibility +exception tracked by spod #29 and Siderust #98. Runtime dependencies support +the service, CLI, and REST surfaces. Lower-level ecosystem crates such as +`qtty`, `tempoch`, `affn`, `cheby`, and `principia` may be transitive +dependencies of Siderust, but are not direct architectural dependencies of +`spod`. ```mermaid flowchart TD - QTTY[qtty] --> POD[spod] - TEMPOCH[tempoch] --> POD - AFFN[affn] --> POD - CHEBY[cheby] --> POD - PRINCIPIA[principia] --> POD - SIDERUST[siderust] --> POD - - POD --> SVC[service / CLI / REST] + SIDERUST[siderust 0.12] --> SPOD[spod service/application] + SGP4[sgp4 temporary exception] --> SPOD + RUNTIME[service / CLI / REST dependencies] --> SPOD + SPOD --> SVC[Runner / CLI / REST] SIDERUST --> SCIENCE[siderust::pod] SCIENCE --> SVC ``` @@ -44,6 +49,7 @@ flowchart LR POD --> OUT[Service artifacts and manifest] ``` -The CLI and REST binaries are thin front ends over the service module. The +The CLI and REST binaries are thin front ends over `spod::service::Runner`. +They do not assemble pipelines or call scientific components directly. Siderust's scientific APIs remain independently testable, while the service boundary is covered by synthetic end-to-end execution. diff --git a/examples/configs/leo_gnss_mvp1.yaml b/examples/configs/leo_gnss_mvp1.yaml index d6c5df8..12ea0d2 100644 --- a/examples/configs/leo_gnss_mvp1.yaml +++ b/examples/configs/leo_gnss_mvp1.yaml @@ -1,5 +1,6 @@ schema_version: "1.0.0" run_id: leo-gnss-mvp1-demo +workflow: synthetic output_dir: ./out/leo-gnss-mvp1 inputs: sp3: null diff --git a/src/bin/spod.rs b/src/bin/spod.rs index e6a0276..74fda8f 100644 --- a/src/bin/spod.rs +++ b/src/bin/spod.rs @@ -72,7 +72,7 @@ fn main() -> anyhow::Result<()> { match cli.command { Cmd::ValidateConfig { config } => { let cfg = spod::service::RunConfig::from_yaml_file(&config)?; - cfg.validate().map_err(|e| anyhow::anyhow!(e))?; + cfg.validate()?; println!("OK: {} validates", config); } Cmd::Run { config } => { @@ -86,7 +86,7 @@ fn main() -> anyhow::Result<()> { .epoch .to::() .value(), - report.manifest_path.display() + report.artifacts.manifest.path.display() ); } Cmd::InspectManifest { manifest } => { diff --git a/src/service/artifacts.rs b/src/service/artifacts.rs new file mode 100644 index 0000000..f512af3 --- /dev/null +++ b/src/service/artifacts.rs @@ -0,0 +1,68 @@ +//! Explicit outputs produced by a service run. + +use serde::Serialize; +use siderust::pod::run::dataset::DatasetRef; +use std::path::Path; + +/// Canonical on-disk layout for a service run. +#[derive(Debug, Clone)] +pub(crate) struct ArtifactLayout { + pub(crate) root: std::path::PathBuf, + pub(crate) orbit_sp3: std::path::PathBuf, + pub(crate) orbit_oem: std::path::PathBuf, + pub(crate) residuals_csv: std::path::PathBuf, + pub(crate) qc_json: std::path::PathBuf, + pub(crate) manifest: std::path::PathBuf, +} + +impl ArtifactLayout { + pub(crate) fn new(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + orbit_sp3: root.join("products/orbit.sp3"), + orbit_oem: root.join("products/orbit.oem"), + residuals_csv: root.join("residuals/residuals.csv"), + qc_json: root.join("qc/qc.json"), + manifest: root.join("run.manifest.json"), + } + } +} + +/// The deterministic artifact set produced by the synthetic workflow. +#[derive(Debug, Clone, Serialize)] +pub struct Artifacts { + /// SP3 orbit product. + pub orbit_sp3: DatasetRef, + /// CCSDS OEM orbit product. + pub orbit_oem: DatasetRef, + /// Residual records. + pub residuals_csv: DatasetRef, + /// Quality-control document. + pub qc_json: DatasetRef, + /// Run manifest. + pub manifest: DatasetRef, +} + +impl Artifacts { + pub(crate) fn from_layout(layout: &ArtifactLayout) -> Result { + let dataset = |path: &Path, kind: &'static str| DatasetRef::from_file(path, kind); + Ok(Self { + orbit_sp3: dataset(&layout.orbit_sp3, "orbit-sp3")?, + orbit_oem: dataset(&layout.orbit_oem, "orbit-oem")?, + residuals_csv: dataset(&layout.residuals_csv, "residuals")?, + qc_json: dataset(&layout.qc_json, "qc")?, + manifest: dataset(&layout.manifest, "manifest")?, + }) + } + + /// Return all generated artifacts in manifest order. + pub fn all(&self) -> [&DatasetRef; 5] { + [ + &self.orbit_sp3, + &self.orbit_oem, + &self.residuals_csv, + &self.qc_json, + &self.manifest, + ] + } +} diff --git a/src/service/config.rs b/src/service/config.rs index 927124a..5bd32ed 100644 --- a/src/service/config.rs +++ b/src/service/config.rs @@ -1,53 +1,33 @@ -//! # Run configuration schema -//! -//! ## Scientific scope -//! -//! A POD run needs a stable description of its input files, selected force -//! toggles, and output location before any scientific computation begins. -//! This module defines that configuration envelope for the current MVP -//! service path. -//! -//! The schema is intentionally rigid so integration tests can rely on -//! deterministic behaviour. Scientific interpretation enters later, when -//! the selected inputs and force switches are bound into an actual -//! estimation run. -//! -//! ## Technical scope -//! -//! The main public types are `RunConfig`, `InputsConfig`, and -//! `ForcesConfig`, along with `RunConfig::from_yaml_file` and -//! `RunConfig::validate`. The schema uses filesystem paths and booleans -//! rather than typed orbit quantities because it describes workflow wiring -//! rather than physical state. -//! -//! It does not execute the pipeline or hash artifacts; those -//! responsibilities belong to the runner and manifest modules. -//! -//! ## References -//! -//! - Ben-Kiki, O., Evans, C., & d'Otremont, I. (2021). YAML Ain't Markup -//! Language (YAML) Version 1.2.2. -//! - Bray, T. (2017). The JavaScript Object Notation (JSON) Data -//! Interchange Format. RFC 8259. +//! Stable YAML configuration for service runs. + use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use super::error::ServiceError; +use super::workflow::Workflow; /// Top-level run configuration. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RunConfig { - /// Schema version (semver-ish). Must be `"1.0.0"` for MVP-1. + /// Schema version. Must be `"1.0.0"` for the current service API. pub schema_version: String, /// Free-form run identifier. pub run_id: String, - /// Inputs. + /// Workflow selected for this run. + #[serde(default)] + pub workflow: Workflow, + /// Input datasets. Real-data inputs are currently unsupported. pub inputs: InputsConfig, - /// Output directory (absolute or relative to the config file). + /// Output directory, absolute or relative to the configuration file. pub output_dir: String, - /// Force-model toggles. + /// Force-model toggles supported by the selected workflow. pub forces: ForcesConfig, } /// Input file paths. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct InputsConfig { /// Precise GNSS satellite ephemeris (SP3). pub sp3: Option, @@ -61,39 +41,64 @@ pub struct InputsConfig { /// Which forces are enabled. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ForcesConfig { /// Two-body central gravity. pub two_body: bool, /// J2 oblateness. pub j2: bool, - /// Sun + Moon third-body gravity. Currently rejected by the synthetic-only runner. + /// Sun + Moon third-body gravity, currently unsupported. pub third_body: bool, } impl RunConfig { - /// Load from a YAML file. - pub fn from_yaml_file(path: &str) -> Result { - let text = std::fs::read_to_string(path)?; - serde_yaml::from_str(&text) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())) + /// Load and parse a YAML configuration. + pub fn from_yaml_file(path: impl AsRef) -> Result { + let text = std::fs::read_to_string(path).map_err(ServiceError::input)?; + serde_yaml::from_str(&text).map_err(|e| ServiceError::configuration(e.to_string())) } - /// Validate semantic invariants (paths exist, schema version known, …). - pub fn validate(&self) -> Result<(), String> { + /// Validate semantic invariants before scientific execution. + pub fn validate(&self) -> Result<(), ServiceError> { if self.schema_version != "1.0.0" { - return Err(format!( + return Err(ServiceError::configuration(format!( "unsupported schema_version: {}", self.schema_version - )); + ))); } - if !(self.forces.two_body) { - return Err("two_body force must be enabled".into()); + if self.run_id.trim().is_empty() { + return Err(ServiceError::configuration("run_id must not be empty")); + } + if !self.forces.two_body { + return Err(ServiceError::configuration( + "two_body force must be enabled", + )); } if self.forces.third_body { - return Err( - "third_body force is not implemented in the current synthetic-only service".into(), - ); + return Err(ServiceError::unsupported( + "third_body force is not implemented in the current synthetic-only service", + )); + } + if self.workflow != Workflow::Synthetic { + return Err(ServiceError::unsupported(format!( + "workflow `{}` is not implemented", + self.workflow + ))); } Ok(()) } + + /// Resolve the configured output directory relative to the configuration file. + pub fn output_dir_relative_to(&self, config_path: impl AsRef) -> PathBuf { + let output = PathBuf::from(&self.output_dir); + if output.is_absolute() { + output + } else { + config_path + .as_ref() + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(output) + } + } } diff --git a/src/service/error.rs b/src/service/error.rs new file mode 100644 index 0000000..f0d4cdc --- /dev/null +++ b/src/service/error.rs @@ -0,0 +1,62 @@ +//! Errors exposed at the service boundary. + +use std::error::Error; +use std::io; +use thiserror::Error; + +/// Categorized failure from configuration, orchestration, or execution. +#[derive(Debug, Error)] +pub enum ServiceError { + /// Configuration could not be parsed or violates the service schema. + #[error("invalid configuration: {message}")] + Configuration { + /// Human-readable validation detail. + message: String, + }, + /// A requested workflow or force/input combination is not supported. + #[error("unsupported workflow or input: {message}")] + Unsupported { + /// Unsupported workflow or input detail. + message: String, + }, + /// A configured input or configuration file could not be read. + #[error("input resolution failed: {source}")] + Input { + /// Underlying filesystem error. + source: io::Error, + }, + /// Generated output files could not be created or finalized. + #[error("artifact handling failed: {source}")] + Artifact { + /// Underlying filesystem error. + source: io::Error, + }, + /// Siderust-backed execution failed. + #[error("scientific execution failed: {source}")] + Scientific { + /// Underlying Siderust-backed pipeline error. + source: Box, + }, +} + +impl ServiceError { + pub(crate) fn configuration(message: impl Into) -> Self { + Self::Configuration { + message: message.into(), + } + } + + pub(crate) fn unsupported(message: impl Into) -> Self { + Self::Unsupported { + message: message.into(), + } + } + + pub(crate) fn input(error: io::Error) -> Self { + Self::Input { source: error } + } + + pub(crate) fn artifact(error: io::Error) -> Self { + Self::Artifact { source: error } + } +} diff --git a/src/service/job.rs b/src/service/job.rs new file mode 100644 index 0000000..ea74281 --- /dev/null +++ b/src/service/job.rs @@ -0,0 +1,44 @@ +//! Application-level run requests and results. + +use super::artifacts::Artifacts; +use super::config::RunConfig; +use super::provenance::RunProvenance; +use super::workflow::Workflow; +use siderust::astro::dynamics::OrbitState; +use std::path::PathBuf; + +/// Stable identifier assigned by a caller to a run. +pub type JobId = String; + +/// Inputs to the high-level service runner. +#[derive(Debug, Clone)] +pub struct RunRequest { + /// Parsed run configuration. + pub config: RunConfig, + /// Source YAML file used to establish configuration provenance. + pub config_path: PathBuf, +} + +/// Structured outcome of a completed service run. +#[derive(Debug)] +pub struct RunResult { + /// Identifier from the run configuration. + pub run_id: JobId, + /// Workflow that executed. + pub workflow: Workflow, + /// Final estimated state. + pub final_state: OrbitState, + /// Number of integration steps. + pub n_steps: usize, + /// Number of estimator iterations. + pub estimator_iterations: usize, + /// Final reduced chi-squared statistic. + pub reduced_chi2: f64, + /// Generated files and their hashes. + pub artifacts: Artifacts, + /// Inputs and configuration identity used by the run. + pub provenance: RunProvenance, +} + +/// Application result name retained for callers of the previous service API. +pub type RunReport = RunResult; diff --git a/src/service/mod.rs b/src/service/mod.rs index b561461..b2cb74f 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -15,15 +15,16 @@ //! //! ## Technical scope //! -//! The public surface exposes run configuration, synthetic-arc generation, -//! the batch pipeline, and the top-level `run` helper. Reusable scientific -//! types are imported directly from Siderust rather than re-exported through -//! this service namespace. Inputs are configuration objects and filesystem -//! roots; outputs are reports and artifact paths. +//! The public surface is the [`Runner`], [`RunRequest`], and [`RunResult`] +//! service API. Reusable scientific types are imported directly from Siderust +//! rather than re-exported through this service namespace. Inputs are +//! configuration objects and filesystem roots; outputs are structured +//! artifact and provenance records. //! //! HTTP transport and command-line dispatch live in the `spod-rest` and -//! `spod` binaries, while -//! reusable numerical estimation remains in `siderust::pod::estimation`. +//! `spod` binaries, while reusable numerical estimation remains in +//! `siderust::pod::estimation`. The synthetic workflow is a reference +//! integration workflow, not the definition of the whole service. //! //! ## References //! @@ -34,15 +35,21 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod artifacts; pub mod config; -pub mod pipeline; +pub mod error; +pub mod job; +mod pipeline; +pub mod provenance; pub mod rest; pub mod runner; -pub mod synth; +mod synth; +pub mod workflow; -pub use config::RunConfig; -pub use pipeline::{ - run_synth, ArcEpoch, GpsSatellite, PipelineError, PipelineReport, RunProvenance, -}; -pub use runner::{run, RunReport}; -pub use synth::{generate, SyntheticArc, SyntheticArcConfig}; +pub use artifacts::Artifacts; +pub use config::{ForcesConfig, InputsConfig, RunConfig}; +pub use error::ServiceError; +pub use job::{JobId, RunReport, RunRequest, RunResult}; +pub use provenance::RunProvenance; +pub use runner::{run, Runner}; +pub use workflow::Workflow; diff --git a/src/service/pipeline.rs b/src/service/pipeline.rs index 9dbcf2f..6554def 100644 --- a/src/service/pipeline.rs +++ b/src/service/pipeline.rs @@ -16,10 +16,8 @@ //! //! ## Technical scope //! -//! The public items are `GpsSatellite`, `ArcEpoch`, `PipelineError`, -//! `PipelineReport`, and `run_synth`. Inputs are synthetic arc -//! descriptions, initial orbit guesses, and output paths; outputs are -//! written artifacts plus a structured run report. +//! This is an internal implementation module. It assembles the synthetic arc +//! and Siderust execution after the public runner has validated a request. //! //! Observation physics, linear algebra, file-format serialization, and //! manifest encoding are delegated to their respective crates and only @@ -31,6 +29,8 @@ //! Determination. Elsevier Academic Press. //! - Consultative Committee for Space Data Systems. (2010). Orbit Data //! Messages, CCSDS 502.0-B-2 / 502.0-B-3. +use super::artifacts::ArtifactLayout; +use super::provenance::RunProvenance; use super::synth::SyntheticArc; use super::synth::SyntheticProviders; use chrono::{SecondsFormat, Utc}; @@ -51,30 +51,29 @@ use siderust::pod::run::dataset::DatasetRef; use siderust::pod::run::manifest::RunManifest; use siderust::qtty::Second; use std::fs; -use std::path::{Path, PathBuf}; use thiserror::Error; /// Identifier for a simulated GPS satellite. #[derive(Debug, Clone)] -pub struct GpsSatellite { +pub(super) struct GpsSatellite { /// Three-character ID (e.g. `G01`). - pub id: String, + pub(super) id: String, /// Slot index used by the synthetic ephemeris. - pub slot: usize, + pub(super) slot: usize, } /// Per-epoch bundle of observations. #[derive(Debug, Clone)] -pub struct ArcEpoch { +pub(super) struct ArcEpoch { /// Index of the corresponding state in the propagated series. - pub state_index: usize, + pub(super) state_index: usize, /// Code observations. - pub code: Vec<( + pub(super) code: Vec<( GpsSatellite, siderust::pod::observation::gnss_obs::GnssPseudorangeObs, )>, /// Carrier observations. - pub carrier: Vec<( + pub(super) carrier: Vec<( GpsSatellite, siderust::pod::observation::gnss_obs::GnssCarrierPhaseObs, )>, @@ -82,61 +81,36 @@ pub struct ArcEpoch { /// Pipeline errors. #[derive(Debug, Error)] -pub enum PipelineError { - /// I/O failure. +pub(super) enum PipelineError { + /// Filesystem failure while creating or writing service artifacts. #[error("I/O error: {0}")] Io(#[from] std::io::Error), /// Estimation failure. #[error("estimation: {0}")] Estimation(#[from] NonlinearError), + /// Propagation failure. + #[error("propagation: {0}")] + Propagation(#[from] siderust::pod::propagation::PodDynamicsError), + /// Product or manifest serialization failure. + #[error("product serialization: {0}")] + Product(String), } /// Outcome of an MVP-1 pipeline run. #[derive(Debug)] -pub struct PipelineReport { +pub(super) struct PipelineReport { /// Estimator convergence report. - pub estimator: NonlinearReport, - /// Estimated initial state (epoch t0). - pub estimated_initial: OrbitState, - /// Estimated receiver clock bias, metres. - pub estimated_clock_bias_m: f64, + pub(super) estimator: NonlinearReport, /// Final state at end of arc. - pub estimated_final: OrbitState, - /// Path to the manifest written. - pub manifest_path: PathBuf, - /// Output directory. - pub output_dir: PathBuf, -} - -/// Service provenance supplied to a synthetic run. -#[derive(Debug, Clone)] -pub struct RunProvenance { - /// SHA-256 of the configuration document used for the run. - pub config_sha256: String, - /// Configuration and external input references consumed by the run. - pub inputs: Vec, -} - -impl RunProvenance { - /// Build provenance from the canonical configuration dataset reference. - pub fn from_config(config: DatasetRef, inputs: Vec) -> Self { - let config_sha256 = config.sha256.clone(); - let mut all_inputs = Vec::with_capacity(inputs.len() + 1); - all_inputs.push(config); - all_inputs.extend(inputs); - Self { - config_sha256, - inputs: all_inputs, - } - } + pub(super) estimated_final: OrbitState, } /// Run MVP-1 against a synthetic arc and write all artifacts to `output_dir`. -pub fn run_synth( +pub(super) fn run_synth( arc: &SyntheticArc, initial_guess: OrbitState, initial_clock_guess_m: f64, - output_dir: &Path, + layout: &ArtifactLayout, run_id: &str, enable_j2: bool, provenance: RunProvenance, @@ -185,9 +159,7 @@ pub fn run_synth( report.parameters[5], ), ); - let clk = report.parameters[6]; - let estimated_arc = propagate_with_stm(&force, estimated_initial, dt_s, n_steps) - .map_err(|e| std::io::Error::other(format!("propagation failed: {e}")))?; + let estimated_arc = propagate_with_stm(&force, estimated_initial, dt_s, n_steps)?; let estimated_states = states_with_initial(estimated_initial, &estimated_arc); let estimated_final = *estimated_states.last().unwrap_or(&estimated_initial); @@ -201,31 +173,31 @@ pub fn run_synth( .map(|r| (r.obs_type.as_str(), r.residual_m)), ); - fs::create_dir_all(output_dir.join("products"))?; - fs::create_dir_all(output_dir.join("residuals"))?; - fs::create_dir_all(output_dir.join("qc"))?; + fs::create_dir_all(layout.root.join("products"))?; + fs::create_dir_all(layout.root.join("residuals"))?; + fs::create_dir_all(layout.root.join("qc"))?; // SP3. { - let mut f = fs::File::create(output_dir.join("products/orbit.sp3"))?; + let mut f = fs::File::create(&layout.orbit_sp3)?; write_sp3_from_states(&mut f, "L01", &estimated_states) - .map_err(|e| PipelineError::Io(std::io::Error::other(e.to_string())))?; + .map_err(|e| PipelineError::Product(e.to_string()))?; } // OEM. { - let mut f = fs::File::create(output_dir.join("products/orbit.oem"))?; + let mut f = fs::File::create(&layout.orbit_oem)?; write_oem_from_states(&mut f, "1900-001A", "POD-LEO", &estimated_states) - .map_err(|e| PipelineError::Io(std::io::Error::other(e.to_string())))?; + .map_err(|e| PipelineError::Product(e.to_string()))?; } // Residuals CSV. { - let mut f = fs::File::create(output_dir.join("residuals/residuals.csv"))?; + let mut f = fs::File::create(&layout.residuals_csv)?; let mut writer = - ResidualCsvWriter::new(&mut f).map_err(|e| std::io::Error::other(e.to_string()))?; + ResidualCsvWriter::new(&mut f).map_err(|e| PipelineError::Product(e.to_string()))?; for row in &residual_rows { writer .write_record(row) - .map_err(|e| std::io::Error::other(e.to_string()))?; + .map_err(|e| PipelineError::Product(e.to_string()))?; } } // qc.json. @@ -240,8 +212,8 @@ pub fn run_synth( iterations: report.iterations, residuals: groups, }; - let mut f = fs::File::create(output_dir.join("qc/qc.json"))?; - write_qc_json(&mut f, &doc)?; + let mut f = fs::File::create(&layout.qc_json)?; + write_qc_json(&mut f, &doc).map_err(|e| PipelineError::Product(e.to_string()))?; } // Manifest. @@ -251,28 +223,25 @@ pub fn run_synth( config_sha256: provenance.config_sha256, inputs: provenance.inputs, outputs: vec![ - DatasetRef::from_file(output_dir.join("products/orbit.sp3"), "orbit-sp3")?, - DatasetRef::from_file(output_dir.join("products/orbit.oem"), "orbit-oem")?, - DatasetRef::from_file(output_dir.join("residuals/residuals.csv"), "residuals")?, - DatasetRef::from_file(output_dir.join("qc/qc.json"), "qc")?, + DatasetRef::from_file(layout.orbit_sp3.clone(), "orbit-sp3")?, + DatasetRef::from_file(layout.orbit_oem.clone(), "orbit-oem")?, + DatasetRef::from_file(layout.residuals_csv.clone(), "residuals")?, + DatasetRef::from_file(layout.qc_json.clone(), "qc")?, ], started_at, finished_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), }; manifest.canonicalize(); - let manifest_path = output_dir.join("run.manifest.json"); fs::write( - &manifest_path, - manifest.to_json_pretty().map_err(std::io::Error::other)?, + &layout.manifest, + manifest + .to_json_pretty() + .map_err(|e| PipelineError::Product(e.to_string()))?, )?; Ok(PipelineReport { estimator: report, - estimated_initial, - estimated_clock_bias_m: clk, estimated_final, - manifest_path, - output_dir: output_dir.to_path_buf(), }) } diff --git a/src/service/provenance.rs b/src/service/provenance.rs new file mode 100644 index 0000000..c8381b8 --- /dev/null +++ b/src/service/provenance.rs @@ -0,0 +1,27 @@ +//! Service-owned provenance records. + +use serde::Serialize; +use siderust::pod::run::dataset::DatasetRef; + +/// Inputs and identity recorded for a run. +#[derive(Debug, Clone, Serialize)] +pub struct RunProvenance { + /// SHA-256 of the configuration document. + pub config_sha256: String, + /// Configuration and external datasets consumed by the run. + pub inputs: Vec, +} + +impl RunProvenance { + /// Build provenance from the configuration dataset and other inputs. + pub fn from_config(config: DatasetRef, inputs: Vec) -> Self { + let config_sha256 = config.sha256.clone(); + let mut all_inputs = Vec::with_capacity(inputs.len() + 1); + all_inputs.push(config); + all_inputs.extend(inputs); + Self { + config_sha256, + inputs: all_inputs, + } + } +} diff --git a/src/service/rest.rs b/src/service/rest.rs index 58856bb..739a227 100644 --- a/src/service/rest.rs +++ b/src/service/rest.rs @@ -34,14 +34,13 @@ use std::fs; use std::path::{Path as FsPath, PathBuf}; use std::sync::{Arc, Mutex}; -use crate::service::{generate, run_synth, SyntheticArcConfig}; +use crate::service::{ForcesConfig, InputsConfig, RunConfig, RunRequest, Runner, Workflow}; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; -use siderust::astro::dynamics::{OrbitState, Position, Velocity}; use siderust::pod::run::dataset::DatasetRef; use uuid::Uuid; @@ -150,22 +149,6 @@ fn run_job(state: AppState, id: String, req: JobRequest) { let mut t = state.inner.lock().unwrap(); t.insert(id.clone(), JobStatus::Running); } - let cfg = SyntheticArcConfig::default(); - let arc = generate(&cfg); - let t0 = arc.truth_states[0]; - let init = OrbitState::new( - t0.epoch, - Position::new( - t0.position.x().value() + 0.05, - t0.position.y().value() - 0.05, - t0.position.z().value() + 0.05, - ), - Velocity::new( - t0.velocity.x().value() + 5e-5, - t0.velocity.y().value() - 5e-5, - t0.velocity.z().value() + 5e-5, - ), - ); let out = state.output_root.join(&id); let request_ref = match persist_request(&out, &req) { Ok(request_ref) => request_ref, @@ -174,22 +157,34 @@ fn run_job(state: AppState, id: String, req: JobRequest) { return; } }; - let result = run_synth( - &arc, - init, - 0.0, - &out, - &id, - req.enable_j2, - crate::service::RunProvenance::from_config(request_ref, Vec::new()), - ); + let config = RunConfig { + schema_version: "1.0.0".into(), + run_id: id.clone(), + workflow: Workflow::Synthetic, + inputs: InputsConfig { + sp3: None, + rinex_obs: None, + rinex_nav: None, + antex: None, + }, + output_dir: ".".into(), + forces: ForcesConfig { + two_body: true, + j2: req.enable_j2, + third_body: false, + }, + }; + let result = Runner.run(RunRequest { + config, + config_path: request_ref.path, + }); let mut t = state.inner.lock().unwrap(); match result { Ok(report) => { t.insert( id, JobStatus::Finished { - manifest_path: report.manifest_path.display().to_string(), + manifest_path: report.artifacts.manifest.path.display().to_string(), }, ); } @@ -247,4 +242,33 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + + #[test] + fn relative_job_output_resolves_to_the_job_directory() { + let root = + std::env::temp_dir().join(format!("spod-rest-relative-output-{}", std::process::id())); + let request_path = root.join("job-id/request.json"); + let config = RunConfig { + schema_version: "1.0.0".into(), + run_id: "job-id".into(), + workflow: Workflow::Synthetic, + inputs: InputsConfig { + sp3: None, + rinex_obs: None, + rinex_nav: None, + antex: None, + }, + output_dir: ".".into(), + forces: ForcesConfig { + two_body: true, + j2: false, + third_body: false, + }, + }; + + assert_eq!( + config.output_dir_relative_to(&request_path), + root.join("job-id") + ); + } } diff --git a/src/service/runner.rs b/src/service/runner.rs index 0ba6000..3738630 100644 --- a/src/service/runner.rs +++ b/src/service/runner.rs @@ -1,116 +1,134 @@ -//! # Configuration-driven pipeline runner -//! -//! ## Scientific scope -//! -//! The runner binds a user-facing run configuration to an executable POD -//! path. In the current workspace that mainly means selecting the -//! deterministic synthetic-arc workflow, validating its inputs, and -//! packaging the resulting outputs and manifest. -//! -//! No new scientific model is introduced in this layer. Its validity regime -//! is whichever estimation path it dispatches to, with current support -//! intentionally limited to the MVP synthetic branch. -//! -//! ## Technical scope -//! -//! The public surface is `RunReport` and `run`. Given a parsed `RunConfig`, -//! the runner resolves output locations, selects the appropriate pipeline -//! branch, and returns a summary of the completed run. -//! -//! It does not parse every underlying file format itself or solve -//! estimation subproblems directly. -//! -//! ## References -//! -//! - Tapley, B. D., Schutz, B. E., & Born, G. H. (2004). Statistical Orbit -//! Determination. Elsevier Academic Press. -//! - Consultative Committee for Space Data Systems. (2010). Orbit Data -//! Messages, CCSDS 502.0-B-2 / 502.0-B-3. -use super::config::RunConfig; -use super::pipeline::{run_synth, RunProvenance}; +//! High-level service orchestration boundary. + +use super::artifacts::{ArtifactLayout, Artifacts}; +use super::error::ServiceError; +use super::job::{RunReport, RunRequest}; +use super::pipeline::run_synth; +use super::provenance::RunProvenance; use super::synth::{generate, SyntheticArcConfig}; +use super::workflow::Workflow; use siderust::astro::dynamics::{OrbitState, Position, Velocity}; use siderust::pod::run::dataset::DatasetRef; -use std::path::PathBuf; -/// Outcome of a run. -#[derive(Debug)] -pub struct RunReport { - /// Final state at the end of the propagated arc. - pub final_state: OrbitState, - /// Run manifest written to disk. - pub manifest_path: PathBuf, - /// Number of integration steps. - pub n_steps: usize, - /// Number of nonlinear estimation iterations. - pub estimator_iterations: usize, - /// Final reduced chi-squared statistic. - pub reduced_chi2: f64, +/// Stateless runner for supported service workflows. +#[derive(Debug, Default, Clone, Copy)] +pub struct Runner; + +impl Runner { + /// Execute a request through validation, dispatch, execution, and packaging. + pub fn run(&self, request: RunRequest) -> Result { + request.config.validate()?; + let configured_inputs = [ + ("sp3", request.config.inputs.sp3.as_ref()), + ("rinex_obs", request.config.inputs.rinex_obs.as_ref()), + ("rinex_nav", request.config.inputs.rinex_nav.as_ref()), + ("antex", request.config.inputs.antex.as_ref()), + ] + .into_iter() + .filter_map(|(name, value)| value.as_ref().map(|_| name)) + .collect::>(); + if !configured_inputs.is_empty() { + return Err(ServiceError::unsupported(format!( + "real-data ingestion is not implemented; unsupported configured inputs: {}. \ + Set all entries under `inputs` to null", + configured_inputs.join(", ") + ))); + } + + let config_ref = DatasetRef::from_file(&request.config_path, "configuration") + .map_err(ServiceError::input)?; + let provenance = RunProvenance::from_config(config_ref, Vec::new()); + match request.config.workflow { + Workflow::Synthetic => self.run_synthetic(request, provenance), + } + } + + fn run_synthetic( + &self, + request: RunRequest, + provenance: RunProvenance, + ) -> Result { + let output_dir = request.config.output_dir_relative_to(&request.config_path); + let layout = ArtifactLayout::new(&output_dir); + let arc = generate(&SyntheticArcConfig::default()); + let t0 = arc.truth_states[0]; + let initial = OrbitState::new( + t0.epoch, + Position::new( + t0.position.x().value() + 0.05, + t0.position.y().value() - 0.05, + t0.position.z().value() + 0.05, + ), + Velocity::new( + t0.velocity.x().value() + 5e-5, + t0.velocity.y().value() - 5e-5, + t0.velocity.z().value() + 5e-5, + ), + ); + let report = run_synth( + &arc, + initial, + 0.0, + &layout, + &request.config.run_id, + request.config.forces.j2, + provenance.clone(), + ) + .map_err(map_pipeline_error)?; + let artifacts = Artifacts::from_layout(&layout).map_err(ServiceError::artifact)?; + Ok(RunReport { + run_id: request.config.run_id, + workflow: Workflow::Synthetic, + final_state: report.estimated_final, + n_steps: arc.epochs.len(), + estimator_iterations: report.estimator.iterations, + reduced_chi2: report.estimator.last.reduced_chi2(), + artifacts, + provenance, + }) + } } -/// Run a configuration end-to-end. -pub fn run(cfg: &RunConfig, config_path: &str) -> std::io::Result { - cfg.validate() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; +/// Execute a configuration through the service boundary. +pub fn run( + config: &super::config::RunConfig, + config_path: impl Into, +) -> Result { + Runner.run(RunRequest { + config: config.clone(), + config_path: config_path.into(), + }) +} - let output_dir = PathBuf::from(&cfg.output_dir); +fn map_pipeline_error(source: super::pipeline::PipelineError) -> ServiceError { + match source { + super::pipeline::PipelineError::Io(source) => ServiceError::Artifact { source }, + super::pipeline::PipelineError::Product(message) => ServiceError::Artifact { + source: std::io::Error::other(message), + }, + source => ServiceError::Scientific { + source: Box::new(source), + }, + } +} - let configured_inputs = [ - ("sp3", cfg.inputs.sp3.as_ref()), - ("rinex_obs", cfg.inputs.rinex_obs.as_ref()), - ("rinex_nav", cfg.inputs.rinex_nav.as_ref()), - ("antex", cfg.inputs.antex.as_ref()), - ] - .into_iter() - .filter_map(|(name, value)| value.as_ref().map(|_| name)) - .collect::>(); +#[cfg(test)] +mod tests { + use super::*; - if !configured_inputs.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - format!( - "real-data ingestion is not implemented in the current synthetic-only service; \ - unsupported configured inputs: {}. Set all entries under `inputs` to null.", - configured_inputs.join(", ") - ), + #[test] + fn propagation_failures_are_scientific() { + let error = map_pipeline_error(super::super::pipeline::PipelineError::Propagation( + siderust::pod::propagation::PodDynamicsError::UnknownModel("test".into()), )); + assert!(matches!(error, ServiceError::Scientific { .. })); } - let config_ref = DatasetRef::from_file(config_path, "configuration")?; - let provenance = RunProvenance::from_config(config_ref, Vec::new()); - - // Synthetic-arc MVP-1 pipeline. - let synth_cfg = SyntheticArcConfig::default(); - let arc = generate(&synth_cfg); - let t0 = arc.truth_states[0]; - let init = OrbitState::new( - t0.epoch, - Position::new( - t0.position.x().value() + 0.05, - t0.position.y().value() - 0.05, - t0.position.z().value() + 0.05, - ), - Velocity::new( - t0.velocity.x().value() + 5e-5, - t0.velocity.y().value() - 5e-5, - t0.velocity.z().value() + 5e-5, - ), - ); - let report = run_synth( - &arc, - init, - 0.0, - &output_dir, - &cfg.run_id, - cfg.forces.j2, - provenance, - ) - .map_err(std::io::Error::other)?; - Ok(RunReport { - final_state: report.estimated_final, - manifest_path: report.manifest_path, - n_steps: arc.epochs.len(), - estimator_iterations: report.estimator.iterations, - reduced_chi2: report.estimator.last.reduced_chi2(), - }) + #[test] + fn product_failures_are_artifact_errors() { + let error = map_pipeline_error(super::super::pipeline::PipelineError::Product( + "serialization failed".into(), + )); + assert!(matches!(error, ServiceError::Artifact { .. })); + } } diff --git a/src/service/synth.rs b/src/service/synth.rs index e06e61a..28e4de6 100644 --- a/src/service/synth.rs +++ b/src/service/synth.rs @@ -15,9 +15,8 @@ //! //! ## Technical scope //! -//! The public items are `SyntheticArcConfig`, `SyntheticArc`, and -//! `generate`. Callers supply simple scenario controls and receive a fully -//! populated synthetic dataset ready for the estimation and QC pipeline. +//! The synthetic types are internal workflow data. External callers select +//! this workflow through [`super::Runner`] rather than constructing arcs. //! //! Real data ingestion, richer force models, and product writing are //! delegated elsewhere. @@ -45,7 +44,7 @@ use siderust::time::JulianDate; /// Configuration for a synthetic arc. #[derive(Debug, Clone)] -pub struct SyntheticArcConfig { +pub(super) struct SyntheticArcConfig { /// Truth LEO state at the arc start (epoch in TT). pub truth_initial: OrbitState, /// Step size, seconds. @@ -87,15 +86,11 @@ impl Default for SyntheticArcConfig { /// Synthetic arc bundle: truth states + per-epoch observations. #[derive(Debug, Clone)] -pub struct SyntheticArc { +pub(super) struct SyntheticArc { /// Truth states at every epoch. - pub truth_states: Vec, + pub(super) truth_states: Vec, /// One bundle per epoch. - pub epochs: Vec, - /// GPS satellites used. - pub gps_sats: Vec, - /// Truth receiver clock bias used. - pub truth_clock_bias_m: f64, + pub(super) epochs: Vec, } #[derive(Clone, Copy)] @@ -172,7 +167,7 @@ impl Lcg { } /// Generate the synthetic arc. -pub fn generate(cfg: &SyntheticArcConfig) -> SyntheticArc { +pub(super) fn generate(cfg: &SyntheticArcConfig) -> SyntheticArc { let force = TwoBody::new(siderust::astro::dynamics::GM_EARTH); let truth = rk4_propagate_series( &force, @@ -249,7 +244,5 @@ pub fn generate(cfg: &SyntheticArcConfig) -> SyntheticArc { SyntheticArc { truth_states: truth, epochs, - gps_sats, - truth_clock_bias_m: cfg.clock_bias_m, } } diff --git a/src/service/workflow.rs b/src/service/workflow.rs new file mode 100644 index 0000000..49573ac --- /dev/null +++ b/src/service/workflow.rs @@ -0,0 +1,21 @@ +//! Supported service workflows. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// A workflow dispatch target owned by the service layer. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Workflow { + /// Deterministic synthetic LEO/GNSS reference workflow. + #[default] + Synthetic, +} + +impl fmt::Display for Workflow { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Synthetic => f.write_str("synthetic"), + } + } +} diff --git a/tests/service_synthetic.rs b/tests/service_synthetic.rs index 593149a..d83a995 100644 --- a/tests/service_synthetic.rs +++ b/tests/service_synthetic.rs @@ -4,7 +4,7 @@ use chrono::DateTime; use siderust::pod::run::dataset::DatasetRef; use siderust::pod::run::manifest::{RunManifest, RUN_MANIFEST_SCHEMA_V1}; use spod::service::config::{ForcesConfig, InputsConfig}; -use spod::service::{run, RunConfig}; +use spod::service::{run, RunConfig, ServiceError, Workflow}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -13,6 +13,7 @@ fn base_config(output_dir: &Path) -> RunConfig { RunConfig { schema_version: "1.0.0".into(), run_id: "integration-test".into(), + workflow: Workflow::Synthetic, inputs: InputsConfig { sp3: None, rinex_obs: None, @@ -52,8 +53,17 @@ fn synthetic_service_produces_valid_manifest_and_products() { let report = run(&config, config_path.to_str().unwrap()).unwrap(); assert!(report.estimator_iterations > 0); assert!(report.reduced_chi2.is_finite()); + assert_eq!(report.workflow, Workflow::Synthetic); + assert_eq!(report.provenance.inputs.len(), 1); + for artifact in report.artifacts.all() { + assert!( + artifact.path.is_file(), + "missing {}", + artifact.path.display() + ); + } - let manifest_text = fs::read_to_string(&report.manifest_path).unwrap(); + let manifest_text = fs::read_to_string(&report.artifacts.manifest.path).unwrap(); let manifest: RunManifest = serde_json::from_str(&manifest_text).unwrap(); assert_eq!(manifest.run_id, "integration-test"); assert_eq!(manifest.config_sha256.len(), 64); @@ -118,7 +128,7 @@ fn synthetic_service_rejects_every_real_data_input() { } let err = run(&cfg, "unused-config.yaml").unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::Unsupported); + assert!(matches!(err, ServiceError::Unsupported { .. })); assert!( err.to_string().contains(input_name), "diagnostic for {input_name} did not identify the unsupported input: {err}" @@ -133,9 +143,41 @@ fn synthetic_service_rejects_unsupported_third_body_force() { cfg.forces.third_body = true; let err = run(&cfg, "unused-config.yaml").unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!(matches!(err, ServiceError::Unsupported { .. })); assert!( err.to_string().contains("third_body"), "diagnostic did not identify the unsupported third-body force: {err}" ); } + +#[test] +fn invalid_configuration_fails_before_execution() { + let output_dir = std::env::temp_dir().join("spod-invalid-config-test"); + let mut cfg = base_config(&output_dir); + cfg.schema_version = "9.9.9".into(); + + let err = run(&cfg, "unused-config.yaml").unwrap_err(); + assert!(matches!(err, ServiceError::Configuration { .. })); +} + +#[test] +fn filesystem_failures_are_artifact_errors() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "spod-artifact-error-{}-{suffix}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + let config_path = root.join("run.yaml"); + let output_path = root.join("output-file"); + fs::write(&config_path, "configuration").unwrap(); + fs::write(&output_path, "not a directory").unwrap(); + + let err = run(&base_config(&output_path), &config_path).unwrap_err(); + assert!(matches!(err, ServiceError::Artifact { .. })); + + fs::remove_dir_all(root).unwrap(); +}