From 07b1e25255ea55ae321bcc2a12655392c13b98ee Mon Sep 17 00:00:00 2001 From: VPRamon Date: Sun, 4 Oct 2026 22:44:08 +0200 Subject: [PATCH 1/2] refactor: consolidate service orchestration Introduce a typed service runner boundary with explicit workflow dispatch, categorized errors, structured artifacts, and provenance. Route CLI and REST execution through the runner while preserving the synthetic reference workflow and documenting the service architecture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/architecture/boundaries.md | 2 +- docs/architecture/overview.md | 14 +- examples/configs/leo_gnss_mvp1.yaml | 1 + src/bin/spod.rs | 4 +- src/service/artifacts.rs | 45 +++++++ src/service/config.rs | 105 ++++++++------- src/service/error.rs | 62 +++++++++ src/service/job.rs | 44 +++++++ src/service/mod.rs | 33 +++-- src/service/pipeline.rs | 24 +--- src/service/provenance.rs | 27 ++++ src/service/rest.rs | 51 ++++---- src/service/runner.rs | 195 +++++++++++++--------------- src/service/workflow.rs | 21 +++ tests/service_synthetic.rs | 18 ++- 15 files changed, 419 insertions(+), 227 deletions(-) create mode 100644 src/service/artifacts.rs create mode 100644 src/service/error.rs create mode 100644 src/service/job.rs create mode 100644 src/service/provenance.rs create mode 100644 src/service/workflow.rs 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..857fbb8 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,10 +1,17 @@ # 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 @@ -44,6 +51,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..394bf58 --- /dev/null +++ b/src/service/artifacts.rs @@ -0,0 +1,45 @@ +//! Explicit outputs produced by a service run. + +use serde::Serialize; +use siderust::pod::run::dataset::DatasetRef; +use std::path::Path; + +/// 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_output_dir(output_dir: &Path) -> Result { + let dataset = + |path: std::path::PathBuf, kind: &'static str| DatasetRef::from_file(path, kind); + Ok(Self { + orbit_sp3: dataset(output_dir.join("products/orbit.sp3"), "orbit-sp3")?, + orbit_oem: dataset(output_dir.join("products/orbit.oem"), "orbit-oem")?, + residuals_csv: dataset(output_dir.join("residuals/residuals.csv"), "residuals")?, + qc_json: dataset(output_dir.join("qc/qc.json"), "qc")?, + manifest: dataset(output_dir.join("run.manifest.json"), "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..7872d1e --- /dev/null +++ b/src/service/error.rs @@ -0,0 +1,62 @@ +//! Errors exposed at the service boundary. + +use super::pipeline::PipelineError; +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: PipelineError, + }, +} + +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..da64fb0 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,23 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod artifacts; pub mod config; +pub mod error; +pub mod job; pub mod pipeline; +pub mod provenance; pub mod rest; pub mod runner; pub 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 artifacts::Artifacts; +pub use config::{ForcesConfig, InputsConfig, RunConfig}; +pub use error::ServiceError; +pub use job::{JobId, RunReport, RunRequest, RunResult}; +pub use pipeline::{run_synth, ArcEpoch, GpsSatellite, PipelineError, PipelineReport}; +pub use provenance::RunProvenance; +pub use runner::{run, Runner}; pub use synth::{generate, SyntheticArc, SyntheticArcConfig}; +pub use workflow::Workflow; diff --git a/src/service/pipeline.rs b/src/service/pipeline.rs index 9dbcf2f..3369c80 100644 --- a/src/service/pipeline.rs +++ b/src/service/pipeline.rs @@ -31,6 +31,7 @@ //! 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::provenance::RunProvenance; use super::synth::SyntheticArc; use super::synth::SyntheticProviders; use chrono::{SecondsFormat, Utc}; @@ -108,29 +109,6 @@ pub struct PipelineReport { 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, - } - } -} - /// Run MVP-1 against a synthetic arc and write all artifacts to `output_dir`. pub fn run_synth( arc: &SyntheticArc, 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..f007f49 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: out.display().to_string(), + 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(), }, ); } diff --git a/src/service/runner.rs b/src/service/runner.rs index 0ba6000..17dd159 100644 --- a/src/service/runner.rs +++ b/src/service/runner.rs @@ -1,116 +1,103 @@ -//! # 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::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, -} -/// 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))?; +/// Stateless runner for supported service workflows. +#[derive(Debug, Default, Clone, Copy)] +pub struct Runner; - let output_dir = PathBuf::from(&cfg.output_dir); +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 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::>(); + 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), + } + } - 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(", ") + fn run_synthetic( + &self, + request: RunRequest, + provenance: RunProvenance, + ) -> Result { + let output_dir = request.config.output_dir_relative_to(&request.config_path); + 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, + &output_dir, + &request.config.run_id, + request.config.forces.j2, + provenance.clone(), + ) + .map_err(|source| match source { + super::pipeline::PipelineError::Io(source) => ServiceError::Artifact { source }, + source => ServiceError::Scientific { source }, + })?; + let artifacts = Artifacts::from_output_dir(&output_dir).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, + }) } +} - 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(), +/// 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(), }) } 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..4fc8698 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,7 +143,7 @@ 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}" From 4695852fb5abae0ba88fd597a185acab4394f531 Mon Sep 17 00:00:00 2001 From: VPRamon Date: Sun, 4 Oct 2026 22:51:44 +0200 Subject: [PATCH 2/2] fix: close service boundary review gaps Hide synthetic pipeline internals, preserve scientific versus artifact error categories, centralize deterministic artifact paths, fix relative REST job output resolution, and align architecture documentation with direct dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/architecture/overview.md | 24 ++++----- src/service/artifacts.rs | 39 +++++++++++--- src/service/error.rs | 4 +- src/service/mod.rs | 6 +-- src/service/pipeline.rs | 97 ++++++++++++++++------------------- src/service/rest.rs | 31 ++++++++++- src/service/runner.rs | 45 +++++++++++++--- src/service/synth.rs | 21 +++----- tests/service_synthetic.rs | 32 ++++++++++++ 9 files changed, 197 insertions(+), 102 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 857fbb8..5becbb0 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -14,22 +14,20 @@ 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 ``` diff --git a/src/service/artifacts.rs b/src/service/artifacts.rs index 394bf58..f512af3 100644 --- a/src/service/artifacts.rs +++ b/src/service/artifacts.rs @@ -4,6 +4,30 @@ 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 { @@ -20,15 +44,14 @@ pub struct Artifacts { } impl Artifacts { - pub(crate) fn from_output_dir(output_dir: &Path) -> Result { - let dataset = - |path: std::path::PathBuf, kind: &'static str| DatasetRef::from_file(path, kind); + 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(output_dir.join("products/orbit.sp3"), "orbit-sp3")?, - orbit_oem: dataset(output_dir.join("products/orbit.oem"), "orbit-oem")?, - residuals_csv: dataset(output_dir.join("residuals/residuals.csv"), "residuals")?, - qc_json: dataset(output_dir.join("qc/qc.json"), "qc")?, - manifest: dataset(output_dir.join("run.manifest.json"), "manifest")?, + 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")?, }) } diff --git a/src/service/error.rs b/src/service/error.rs index 7872d1e..f0d4cdc 100644 --- a/src/service/error.rs +++ b/src/service/error.rs @@ -1,6 +1,6 @@ //! Errors exposed at the service boundary. -use super::pipeline::PipelineError; +use std::error::Error; use std::io; use thiserror::Error; @@ -35,7 +35,7 @@ pub enum ServiceError { #[error("scientific execution failed: {source}")] Scientific { /// Underlying Siderust-backed pipeline error. - source: PipelineError, + source: Box, }, } diff --git a/src/service/mod.rs b/src/service/mod.rs index da64fb0..b2cb74f 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -39,19 +39,17 @@ pub mod artifacts; pub mod config; pub mod error; pub mod job; -pub mod pipeline; +mod pipeline; pub mod provenance; pub mod rest; pub mod runner; -pub mod synth; +mod synth; pub mod workflow; pub use artifacts::Artifacts; pub use config::{ForcesConfig, InputsConfig, RunConfig}; pub use error::ServiceError; pub use job::{JobId, RunReport, RunRequest, RunResult}; -pub use pipeline::{run_synth, ArcEpoch, GpsSatellite, PipelineError, PipelineReport}; pub use provenance::RunProvenance; pub use runner::{run, Runner}; -pub use synth::{generate, SyntheticArc, SyntheticArcConfig}; pub use workflow::Workflow; diff --git a/src/service/pipeline.rs b/src/service/pipeline.rs index 3369c80..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,7 @@ //! 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; @@ -52,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, )>, @@ -83,38 +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, + 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, @@ -163,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); @@ -179,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. @@ -218,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. @@ -229,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/rest.rs b/src/service/rest.rs index f007f49..739a227 100644 --- a/src/service/rest.rs +++ b/src/service/rest.rs @@ -167,7 +167,7 @@ fn run_job(state: AppState, id: String, req: JobRequest) { rinex_nav: None, antex: None, }, - output_dir: out.display().to_string(), + output_dir: ".".into(), forces: ForcesConfig { two_body: true, j2: req.enable_j2, @@ -242,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 17dd159..3738630 100644 --- a/src/service/runner.rs +++ b/src/service/runner.rs @@ -1,6 +1,6 @@ //! High-level service orchestration boundary. -use super::artifacts::Artifacts; +use super::artifacts::{ArtifactLayout, Artifacts}; use super::error::ServiceError; use super::job::{RunReport, RunRequest}; use super::pipeline::run_synth; @@ -49,6 +49,7 @@ impl Runner { 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( @@ -68,16 +69,13 @@ impl Runner { &arc, initial, 0.0, - &output_dir, + &layout, &request.config.run_id, request.config.forces.j2, provenance.clone(), ) - .map_err(|source| match source { - super::pipeline::PipelineError::Io(source) => ServiceError::Artifact { source }, - source => ServiceError::Scientific { source }, - })?; - let artifacts = Artifacts::from_output_dir(&output_dir).map_err(ServiceError::artifact)?; + .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, @@ -101,3 +99,36 @@ pub fn run( config_path: config_path.into(), }) } + +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), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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 { .. })); + } + + #[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/tests/service_synthetic.rs b/tests/service_synthetic.rs index 4fc8698..d83a995 100644 --- a/tests/service_synthetic.rs +++ b/tests/service_synthetic.rs @@ -149,3 +149,35 @@ fn synthetic_service_rejects_unsupported_third_body_force() { "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(); +}