Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 22 additions & 16 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
@@ -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
```
Expand All @@ -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.
1 change: 1 addition & 0 deletions examples/configs/leo_gnss_mvp1.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/bin/spod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } => {
Expand All @@ -86,7 +86,7 @@ fn main() -> anyhow::Result<()> {
.epoch
.to::<siderust::tempoch::JD>()
.value(),
report.manifest_path.display()
report.artifacts.manifest.path.display()
);
}
Cmd::InspectManifest { manifest } => {
Expand Down
68 changes: 68 additions & 0 deletions src/service/artifacts.rs
Original file line number Diff line number Diff line change
@@ -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<Self, std::io::Error> {
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,
]
}
}
105 changes: 55 additions & 50 deletions src/service/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
Expand All @@ -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<Self, std::io::Error> {
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<Path>) -> Result<Self, ServiceError> {
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<Path>) -> 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)
}
}
}
62 changes: 62 additions & 0 deletions src/service/error.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error + Send + Sync>,
},
}

impl ServiceError {
pub(crate) fn configuration(message: impl Into<String>) -> Self {
Self::Configuration {
message: message.into(),
}
}

pub(crate) fn unsupported(message: impl Into<String>) -> 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 }
}
}
Loading
Loading