Causal Inference for Multi-Fault Satellite Failures
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Deployement made more easier!

+336 -154
+42
CMakeLists.txt
··· 1 + cmake_minimum_required(VERSION 3.15) 2 + project(Aethelix VERSION 0.2.0 LANGUAGES C CXX) 3 + 4 + # Setup dual-core architecture profiles 5 + option(PROFILE_LEON3 "Target the sparc-unknown-none-elf architecture (LEON3 Industry Standard)" ON) 6 + option(PROFILE_SHAKTI "Target the riscv32imac-unknown-none-elf architecture (Shakti/RISC-V New Norm)" OFF) 7 + 8 + if(PROFILE_LEON3 AND PROFILE_SHAKTI) 9 + message(FATAL_ERROR "Cannot select both PROFILE_LEON3 and PROFILE_SHAKTI simultaneously.") 10 + endif() 11 + 12 + if(PROFILE_SHAKTI) 13 + set(RUST_TARGET "riscv32imac-unknown-none-elf") 14 + message(STATUS "Aethelix-PnP: Configuring for RISC-V Shakti/IRIS architecture (${RUST_TARGET})") 15 + else() 16 + set(RUST_TARGET "sparc-unknown-none-elf") 17 + message(STATUS "Aethelix-PnP: Configuring for LEON3 SPARC architecture (${RUST_TARGET})") 18 + endif() 19 + 20 + # Fetch Corrosion (Rust <-> CMake bridge) 21 + include(FetchContent) 22 + FetchContent_Declare( 23 + Corrosion 24 + GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git 25 + GIT_TAG v0.5.0 26 + ) 27 + FetchContent_MakeAvailable(Corrosion) 28 + 29 + # Import the Rust core (aethelix_core) 30 + corrosion_import_crate(MANIFEST_PATH rust_core/Cargo.toml 31 + TARGET_CLIMBED aethelix_core 32 + FLAGS --target ${RUST_TARGET} --features flight --no-default-features) 33 + 34 + # Define the C/C++ interface library 35 + add_library(aethelix INTERFACE) 36 + target_include_directories(aethelix INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) 37 + target_link_libraries(aethelix INTERFACE aethelix_core) 38 + 39 + # For easy consumption via FetchContent by other projects 40 + add_library(Aethelix::aethelix ALIAS aethelix) 41 + 42 + message(STATUS "Aethelix FDIR Framework successfully registered. Target 'Aethelix::aethelix' is ready for linking.")
+31
Dockerfile
··· 1 + FROM python:3.10-slim 2 + 3 + WORKDIR /app 4 + 5 + # Install system dependencies required for Rust compilation and Python packages 6 + RUN apt-get update && apt-get install -y \ 7 + build-essential \ 8 + curl \ 9 + git \ 10 + graphviz \ 11 + && rm -rf /var/lib/apt/lists/* 12 + 13 + # Install Rust toolchain to compile Aethelix core via Maturin 14 + RUN curl https://sh.rustup.rs -sSf | sh -s -- -y 15 + ENV PATH="/root/.cargo/bin:${PATH}" 16 + 17 + # Install Python dependencies and Maturin 18 + COPY requirements.txt . 19 + RUN pip install --no-cache-dir -r requirements.txt maturin 20 + 21 + # Copy the entire project 22 + COPY . . 23 + 24 + # Build and install the Aethelix python package (including the Rust core via Maturin) 25 + RUN pip install --no-cache-dir -e . 26 + 27 + # Expose Streamlit default port 28 + EXPOSE 8501 29 + 30 + # Run the Mission Control Dashboard 31 + ENTRYPOINT ["streamlit", "run", "dashboard/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
+66 -25
README.md
··· 147 147 148 148 --- 149 149 150 - ### Installation 150 + ### 1. Ground Segment (Data Center & Python) 151 151 152 + **Mission Control in a Box (Docker)** 153 + The easiest way to launch Aethelix is via Docker Compose, which spins up the Streamlit dashboard and pipeline instantly. 152 154 ```bash 153 - # Clone the repository 154 155 git clone https://github.com/rudywasfound/aethelix 155 156 cd aethelix 157 + docker-compose up -d 158 + ``` 159 + The dashboard starts dynamically on port `8501`. You can drop realistic PCoE datasets directly into the mapped `/data` folder. 156 160 157 - # Recommended: setup virtual environment 158 - python -m venv venv 159 - source venv/bin/activate # venv\Scripts\activate on Windows 161 + **Native Python Package** 162 + Aethelix is packaged with `maturin` and PyO3. Install it natively as a Python module: 163 + ```bash 164 + # Inside a virtual environment 165 + pip install -e . 166 + ``` 160 167 161 - # Install all dependencies 162 - pip install -r requirements.txt 168 + ### 2. Space Segment (Flight Software) 169 + 170 + Aethelix targets two dominant architectures as part of the "Strategic Autonomy" Dual-Core strategy: the legacy **LEON3 (SPARC)** fleet, and the next-generation **Shakti (RISC-V)** missions. 171 + 172 + **C/C++ Integration (CMake)** 173 + Drop Aethelix into your embedded flight codebase simply using CMake's `FetchContent` or `add_subdirectory`. Select your compiler target: 174 + ```bash 175 + # LEON3 (SPARC) Industry Standard Profile 176 + cmake -DPROFILE_LEON3=ON .. 177 + 178 + # RISC-V (Shakti) New Norm Profile 179 + cmake -DPROFILE_SHAKTI=ON .. 163 180 ``` 164 181 182 + **Ada Integration (Alire/GNAT)** 183 + Aerospace middlewares relying on Ada can include `aethelix.gpr` directly in their Alire workspace. GNAT will instantly resolve the bindings natively. 184 + 185 + --- 186 + 187 + ## Active Recovery (Sentinel Gap) 188 + 189 + Aethelix is not just a passive diagnostic tool; it possesses an **Active Recovery Callback Interface**. Through the C/Ada FFI, your FDIR middleware can register a recovery function that Aethelix will trigger *the exact moment* a root cause is successfully isolated. 190 + 191 + ```c 192 + // Example: Active Recovery execution on Deep Space Node 193 + void critical_recovery(int fault_id) { 194 + if (fault_id == AETHELIX_FAULT_BATTERY_THERMAL) { 195 + // Trigger emergency bus cooling mechanisms 196 + } 197 + } 198 + 199 + // Bind to Aethelix FDIR Framework 200 + register_recovery_handler(critical_recovery); 201 + ``` 202 + 203 + --- 204 + 165 205 ### Quick Run 166 206 ```bash 167 - python main.py 207 + python dashboard/app.py 168 208 ``` 169 209 This runs the full diagnostic pipeline on a simulated multi-fault scenario (Solar + Battery aging). 170 210 ··· 270 310 271 311 ## Roadmap: Phases 3-4 272 312 273 - ### Phase 3: Expand Subsystems (Weeks 5-6) 274 - - [x] Add thermal subsystem to causal graph 275 - - [x] Update propagation paths (power ↔ thermal ↔ payload) 276 - - [x] Multi-fault scenarios (e.g., thermal drift + solar degradation) 277 - - [x] Improved telemetry plots and textual explanations 313 + ### Completed Phases (1-4) 314 + - [x] Integrate high-performance C/Ada flight FFI boundary. 315 + - [x] Extend causal graph to power-thermal coupling. 316 + - [x] Multi-fault scenarios and cycle-level continuous KS-testing. 317 + - [x] Dual-Core execution framework via CMake (LEON3 + RISC-V). 318 + - [x] Dockerization and seamless Python `pip` packaging. 319 + - [x] Sentinel Gap closure via Active Recovery Callback (`register_recovery_handler`). 278 320 279 - ### Phase 4: Experimental Validation (Weeks 7-8) 280 - - [x] Benchmark: Correlation vs. rule-based vs. Bayesian reasoning 281 - - *Metric:* Accuracy of root cause ranking 282 - - *Condition:* Vary missing data, noise levels, simultaneous faults 283 - - [x] Paper-style report (ICRA/AIAA format) 284 - - [x] Public GitHub repo with reproducible notebooks 321 + ### Phase 5: Orbital Autonomy (Weeks 9-10) 322 + - [ ] Connect with Core Flight System (cFS) components. 323 + - [ ] Communications subsystem monitoring (payload health checks). 324 + - [ ] Fleet-wide causal telemetry syncing mechanism for constellation awareness. 285 325 286 326 --- 287 327 ··· 289 329 290 330 ```text 291 331 aethelix/ 332 + ├── ada/ # Ada 2012 FDIR bindings and GNAT project 292 333 ├── analysis/ # Deviation quantification 293 334 ├── causal_graph/ # DAG definitions & Bayesian inference 335 + ├── dashboard/ # Streamlit frontend & Mission Control GUI 294 336 ├── data/ # Telemetry datasets 295 337 ├── docs/ # Detailed documentation and diagrams 296 338 ├── examples/ # Example workflows (e.g., GSAT-6A) 297 - ├── forensics/ # Post-mission analysis tools 298 - ├── operational/ # Real-time operator integration 299 - ├── rust_core/ # High-performance Rust backend 339 + ├── include/ # C headers for Flight FFI (aethelix.h) 340 + ├── rust_core/ # High-performance bare-metal Rust Core 300 341 ├── scripts/ # Local build and benchmark scripts 301 342 ├── simulator/ # Subsystem simulation 302 - ├── tests/ # Unit and integration tests 303 - ├── visualization/ # Plotters and renderers 304 - ├── main.py # Entry point for local runs 343 + ├── Dockerfile # Mission-Control-in-a-Box container 344 + ├── CMakeLists.txt # Embedded FSW Dual-Core compilation build 345 + ├── pyproject.toml # pip dependency structure & Maturin compiler 305 346 └── README.md 306 347 ``` 307 348
+10
ada/aethelix_binding.ads
··· 117 117 Convention => C, 118 118 External_Name => "aethelix_reset_state"; 119 119 120 + -- Recovery handler callback type 121 + type Recovery_Handler_Ptr is access procedure (Fault_Id : Int) 122 + with Convention => C; 123 + 124 + -- Register an active recovery handler callback for the FDIR framework. 125 + procedure Register_Recovery_Handler (Handler : Recovery_Handler_Ptr) 126 + with Import => True, 127 + Convention => C, 128 + External_Name => "register_recovery_handler"; 129 + 120 130 end Aethelix_Binding;
+20
aethelix.gpr
··· 1 + project Aethelix is 2 + 3 + for Languages use ("Ada"); 4 + for Source_Dirs use ("ada"); 5 + for Object_Dir use "obj"; 6 + for Library_Dir use "lib"; 7 + for Library_Name use "aethelix_ada_binding"; 8 + for Library_Kind use "static"; 9 + 10 + package Compiler is 11 + for Default_Switches ("Ada") use 12 + ("-g", "-O2", "-gnat12", "-gnatwa"); 13 + end Compiler; 14 + 15 + package Linker is 16 + -- Link against the static library compiled by cargo 17 + for Linker_Options use ("-L../rust_core/target/sparc-unknown-none-elf/release", "-laethelix_core"); 18 + end Linker; 19 + 20 + end Aethelix;
+1 -12
causal_graph/graph_compiler.py
··· 36 36 37 37 from causal_graph.graph_definition import CausalGraph, NodeType 38 38 39 - # ── Constants ────────────────────────────────────────────────────────────────── 39 + 40 40 MAGIC = bytes([0xCA, 0x05, 0xAE, 0x01]) 41 41 42 42 NODE_TYPE_MAP = { ··· 47 47 48 48 MAX_BINARY_SIZE = 4096 # Flash budget for the embedded graph 49 49 50 - 51 - # ── Compiler ─────────────────────────────────────────────────────────────────── 52 50 53 51 def compile_graph( 54 52 output_bin: Path = None, ··· 84 82 85 83 print(f" Nodes: {n_nodes} | Edges: {n_edges}") 86 84 87 - # ── Build binary ────────────────────────────────────────────────────────── 88 85 buf = bytearray() 89 86 90 87 # Header ··· 117 114 if skipped: 118 115 print(f" WARNING: {skipped} edges skipped (unknown node reference)") 119 116 120 - # ── Write binary ────────────────────────────────────────────────────────── 121 117 output_bin.write_bytes(bytes(buf)) 122 118 size_bytes = len(buf) 123 119 budget_ok = size_bytes <= MAX_BINARY_SIZE ··· 125 121 print(f" Written: {output_bin} ({size_bytes} bytes)") 126 122 print(f" Budget: ≤{MAX_BINARY_SIZE} bytes → {'✓ OK' if budget_ok else '✗ EXCEEDS BUDGET'}") 127 123 128 - # ── Node ID mapping JSON ────────────────────────────────────────────────── 129 124 root_cause_ids = { 130 125 name: node_id_map[name] 131 126 for name in node_names ··· 151 146 output_ids.write_text(json.dumps(id_info, indent=2)) 152 147 print(f" Written: {output_ids}") 153 148 154 - # ── C header with fault ID constants ───────────────────────────────────── 155 149 output_hdr.parent.mkdir(parents=True, exist_ok=True) 156 150 _write_c_header(output_hdr, root_cause_ids, n_nodes, n_edges) 157 151 print(f" Written: {output_hdr}") ··· 188 182 ] 189 183 path.write_text("\n".join(lines)) 190 184 191 - 192 - # ── Verifier ─────────────────────────────────────────────────────────────────── 193 185 194 186 def verify_binary(bin_path: Path = None) -> bool: 195 187 """Read back and verify structural integrity of the compiled binary.""" ··· 228 220 229 221 print(f" Verification PASSED — {n_nodes} nodes, {n_edges} edges, {len(data)} bytes") 230 222 return True 231 - 232 - 233 - # ── Entry point ──────────────────────────────────────────────────────────────── 234 223 235 224 if __name__ == "__main__": 236 225 print("=" * 60)
+15
docker-compose.yml
··· 1 + version: '3.8' 2 + 3 + services: 4 + mission-control: 5 + build: . 6 + container_name: aethelix-mission-control 7 + ports: 8 + - "8501:8501" 9 + volumes: 10 + # Mount the data directory so operators can drop CSVs into it and 11 + # the Streamlit dashboard sees them instantly. 12 + - ./data:/app/data 13 + environment: 14 + - STREAMLIT_THEME_BASE=dark 15 + restart: unless-stopped
+21 -12
include/aethelix.h
··· 4 4 * Compatible with: LEON3 (SPARC V8), RTEMS, VxWorks, bare-metal C/Ada FDIR. 5 5 * Generated from Rust source (rust_core/src/ffi.rs) via cbindgen. 6 6 * 7 - * ── Quick start (C) ────────────────────────────────────────────────────────── 7 + * Quick start (C) 8 8 * 9 9 * #include "aethelix.h" 10 10 * #include <string.h> ··· 21 21 * handle_fault(&alert); 22 22 * } 23 23 * 24 - * ── Quick start (Ada) ──────────────────────────────────────────────────────── 24 + * Quick start (Ada) 25 25 * See ada/aethelix_binding.ads for a type-safe Ada 2012 thin binding. 26 26 * 27 - * ── ECSS references ────────────────────────────────────────────────────────── 27 + * ECSS references 28 28 * ECSS-E-ST-10-03C CCSDS Space Packet Protocol 29 29 * ECSS-E-ST-40C Software Engineering (req. basis for formal verification) 30 30 * ECSS-Q-ST-80C Software Product Assurance ··· 40 40 #include <stdint.h> 41 41 #include <stddef.h> 42 42 43 - /* ── Version ──────────────────────────────────────────────────────────────── */ 43 + /* Version*/ 44 44 #define AETHELIX_VERSION_MAJOR 0U 45 45 #define AETHELIX_VERSION_MINOR 2U 46 46 #define AETHELIX_VERSION_PATCH 0U 47 47 48 - /* ── Alert level severity ─────────────────────────────────────────────────── */ 48 + /* Alert level severity */ 49 49 #define AETHELIX_LEVEL_NONE 0U /**< Nominal — no fault detected */ 50 50 #define AETHELIX_LEVEL_WARNING 1U /**< Sub-threshold anomaly; monitor */ 51 51 #define AETHELIX_LEVEL_CAUTION 2U /**< Anomaly confirmed; prepare action */ 52 52 #define AETHELIX_LEVEL_CRITICAL 3U /**< Immediate FDIR action required */ 53 53 54 - /* ── Telemetry channel indices (bit positions in evidence_mask) ───────────── */ 54 + /* Telemetry channel indices (bit positions in evidence_mask) */ 55 55 #define AETHELIX_CH_SOLAR_INPUT 0U /**< Solar input power (W) */ 56 56 #define AETHELIX_CH_BATTERY_VOLT 1U /**< Battery voltage (mV) */ 57 57 #define AETHELIX_CH_BATTERY_SOC 2U /**< Battery state-of-charge (%) */ ··· 61 61 #define AETHELIX_CH_PAYLOAD_TEMP 6U /**< Payload temperature (0.01°C) */ 62 62 #define AETHELIX_CH_BUS_CURRENT 7U /**< Bus current (mA) */ 63 63 64 - /* ── CCSDS APID assignments (configure to match your spacecraft) ──────────── */ 64 + /* CCSDS APID assignments (configure to match your spacecraft) */ 65 65 #define AETHELIX_APID_SOLAR_INPUT 0x001U 66 66 #define AETHELIX_APID_BATTERY_VOLT 0x002U 67 67 #define AETHELIX_APID_BATTERY_SOC 0x003U ··· 71 71 #define AETHELIX_APID_PAYLOAD_TEMP 0x007U 72 72 #define AETHELIX_APID_BUS_CURRENT 0x008U 73 73 74 - /* ── Root-cause fault IDs (auto-generated from causal_graph.bin) ─────────── */ 74 + /* ── Root-cause fault IDs (auto-generated from causal_graph.bin) */ 75 75 /* Include aethelix_graph_ids.h for the full enumeration. */ 76 76 #include "aethelix_graph_ids.h" 77 77 78 - /* ── Return codes ─────────────────────────────────────────────────────────── */ 78 + /* Return codes */ 79 79 #define AETHELIX_OK 0 /**< Success */ 80 80 #define AETHELIX_ERR_TOO_SHORT 1 /**< Buffer < 6 bytes (no CCSDS header) */ 81 81 #define AETHELIX_ERR_BAD_LEN 2 /**< Payload length mismatch in header */ 82 82 #define AETHELIX_ERR_NULL_PTR (-1) /**< Null pointer argument */ 83 83 84 - /* ── FDIR Alert (12 bytes, packed, C ABI compatible) ─────────────────────── */ 84 + /* FDIR Alert (12 bytes, packed, C ABI compatible) */ 85 85 typedef struct __attribute__((packed)) { 86 86 uint8_t level; /**< AETHELIX_LEVEL_* severity */ 87 87 uint8_t root_cause_id; /**< AETHELIX_FAULT_* or 0xFF = no fault */ ··· 95 95 /* Compile-time size assertion (14 bytes with __packed__) */ 96 96 typedef char _aethelix_alert_size_check[sizeof(AethelixAlert) == 12 ? 1 : -1]; 97 97 98 - /* ── Opaque persistent state ──────────────────────────────────────────────── */ 98 + /* Opaque persistent state */ 99 99 /* Caller must allocate >= aethelix_state_size() bytes and zero the buffer */ 100 100 /* before the first call to aethelix_process_frame(). */ 101 101 typedef struct AethelixState AethelixState; 102 102 103 - /* ── API ──────────────────────────────────────────────────────────────────── */ 103 + /* API */ 104 104 105 105 /** 106 106 * Process one CCSDS Space Packet through the Aethelix diagnostic engine. ··· 135 135 * @return Exact byte size of AethelixState in this firmware build. 136 136 */ 137 137 uint32_t aethelix_state_size(void); 138 + 139 + /** 140 + * Register an active recovery handler callback for the FDIR framework. 141 + * This instructs Aethelix to actively call the function pointer supplied 142 + * the moment a root cause fault is isolated. 143 + * 144 + * @param handler Function pointer taking an integer fault_id to execute recovery 145 + */ 146 + void register_recovery_handler(void (*handler)(int fault_id)); 138 147 139 148 #ifdef __cplusplus 140 149 }
+31
pyproject.toml
··· 1 + [build-system] 2 + requires = ["maturin>=1.0,<2.0"] 3 + build-backend = "maturin" 4 + 5 + [project] 6 + name = "aethelix" 7 + version = "1.0.1" 8 + description = "Aethelix: Physics-Based Causal Inference for Real-Time Satellite Fault Detection" 9 + readme = "README.md" 10 + authors = [ 11 + { name = "Atiksh Sharma" }, 12 + ] 13 + requires-python = ">=3.8" 14 + dependencies = [ 15 + "numpy>=1.20.0", 16 + "matplotlib>=3.3.0", 17 + "pandas>=1.3.0", 18 + "scipy>=1.7.0", 19 + "streamlit>=1.10.0", 20 + "graphviz>=0.17", 21 + "plotly>=5.0.0", 22 + "PyYAML>=6.0", 23 + ] 24 + 25 + [tool.maturin] 26 + features = ["pyo3/extension-module"] 27 + manifest-path = "rust_core/Cargo.toml" 28 + module-name = "aethelix.rust_core" 29 + 30 + [tool.pytest.ini_options] 31 + testpaths = ["tests"]
-2
rust_core/.cargo/config.toml
··· 39 39 # Set SPARC sysroot if using BCC2 40 40 # SYSROOT = "/opt/bcc2/sparc-gaisler-elf" 41 41 42 - # ── Build settings ─────────────────────────────────────────────────────────────── 43 - 44 42 [build] 45 43 # Default target for `cargo build` remains the host (ground station) 46 44 # Use --target explicitly for cross-compilation
+10 -39
rust_core/Cargo.toml
··· 2 2 name = "aethelix_core" 3 3 version = "0.2.0" 4 4 edition = "2021" 5 - description = "Satellite Telemetry Causal Diagnostic Framework — Flight & Ground" 5 + description = "Satellite Telemetry Causal Diagnostic Framework" 6 6 license = "Apache-2.0" 7 7 repository = "https://github.com/rudywasfound/aethelix" 8 8 9 - # Ground binary (requires std features) 10 9 [[bin]] 11 10 name = "aethelix_core" 12 11 path = "src/bin/main.rs" 13 12 required-features = ["ground"] 14 13 15 - # LEON3 flight benchmark binary (no_std) 16 14 [[bin]] 17 15 name = "leon3_bench" 18 16 path = "src/bin/leon3_bench.rs" ··· 21 19 [lib] 22 20 name = "aethelix_core" 23 21 path = "src/lib.rs" 24 - # cdylib: Python extension (.so) — ground feature only 25 - # rlib: Rust library — used by both features 26 - # staticlib: C/Ada FFI for LEON3 bare-metal — flight feature 27 22 crate-type = ["cdylib", "rlib", "staticlib"] 28 23 29 - # ── Dependencies ──────────────────────────────────────────────────────────────── 30 24 31 - [dependencies] 32 25 33 - # Flight-only: fixed-point math for SPARC/RISC-V without FPU 26 + [dependencies] 34 27 micromath = { version = "2", optional = true, default-features = false } 35 - 36 - # Ground-only: numerical computation 37 28 nalgebra = { version = "0.32", optional = true } 38 29 ndarray = { version = "0.15", optional = true } 39 - 40 - # Ground-only: async runtime 41 30 tokio = { version = "1.35", features = ["full"], optional = true } 42 - 43 - # Ground-only: serialization 44 31 serde = { version = "1.0", features = ["derive"], optional = true } 45 32 serde_json = { version = "1.0", optional = true } 46 - 47 - # Ground-only: Python FFI 48 33 pyo3 = { version = "0.21", features = ["extension-module"], optional = true } 49 - 50 - # Ground-only: logging (requires std) 51 34 log = { version = "0.4", optional = true } 52 35 env_logger = { version = "0.10", optional = true } 53 - 54 - # Ground-only: time handling 55 36 chrono = { version = "0.4", features = ["serde"], optional = true } 56 - 57 - # Ground-only: error handling (flight uses bare Result<_, u8>) 58 37 thiserror = { version = "1.0", optional = true } 59 38 anyhow = { version = "1.0", optional = true } 60 39 61 40 [dev-dependencies] 62 - criterion = "0.5" # Benchmarking (ground only) 41 + criterion = "0.5" 63 42 64 - # ── Feature flags ─────────────────────────────────────────────────────────────── 43 + 65 44 [features] 66 45 67 - # Flight: bare-metal no_std for LEON3 OBC 68 - # Build: cargo +nightly build --target sparc-unknown-none-elf --features flight --no-default-features 69 46 flight = ["micromath"] 70 - 71 - # Ground: full Python/async stack for ground-station use 72 - # Build: cargo build --features ground (default) 73 47 ground = ["python", "async", "std"] 74 - 75 - # Sub-features (ground only) 76 48 python = ["pyo3"] 77 49 async = ["tokio"] 78 50 std = [ ··· 80 52 "nalgebra", "ndarray", "thiserror", "anyhow", 81 53 ] 82 54 83 - # Default build targets the ground station 55 + 84 56 default = ["ground"] 85 57 86 - # ── Release profile: size-optimised for LEON3 flash budget ───────────────────── 58 + 87 59 [profile.release] 88 - opt-level = "s" # Optimize for size (critical for 64 KB flash budget) 60 + opt-level = "s" 89 61 lto = true 90 62 codegen-units = 1 91 - panic = "abort" # No unwinding — required for no_std 92 - strip = true # Strip debug symbols from release binary 63 + panic = "abort" 64 + strip = true 93 65 94 - # ── Dev profile: keep fast for ground-station iteration ──────────────────────── 95 66 [profile.dev] 96 67 opt-level = 0 97 68 debug = true 98 - panic = "unwind" # Enable backtraces in ground dev 69 + panic = "unwind"
+5 -6
rust_core/src/bin/leon3_bench.rs
··· 80 80 } 81 81 } 82 82 83 - // ── LEON3 hardware cycle counter (ASR16) ───────────────────────────────────── 83 + // LEON3 hardware cycle counter (ASR16) 84 84 // LEON3 exposes an internal performance counter in ASR16 (via rdsr instruction). 85 85 // This is SPARC V8 ASR access — LEON3 specific. 86 86 ··· 101 101 0u32 102 102 } 103 103 104 - // ── Synthetic CCSDS test frame ───────────────────────────────────────────── 104 + // Synthetic CCSDS test frame 105 105 /// Build a minimal 8-byte CCSDS Space Packet for a given APID and i16 value. 106 106 fn make_test_frame(apid: u16, value: i16) -> [u8; 8] { 107 107 [ ··· 114 114 ] 115 115 } 116 116 117 - // ── Entry point ─────────────────────────────────────────────────────────────── 118 117 #[no_mangle] 119 118 pub extern "C" fn _start() -> ! { 120 119 const ITERS: u32 = 1000; ··· 135 134 make_test_frame(0x008, -2_000), // bus_current 136 135 ]; 137 136 138 - // ── Warm-up the caches and branch predictor ────────────────────────── 137 + // Warm-up the caches and branch predictor 139 138 for _ in 0..50 { 140 139 for f in &frames { 141 140 unsafe { ··· 145 144 } 146 145 unsafe { aethelix_reset_state(&mut state); } 147 146 148 - // ── Timed benchmark ────────────────────────────────────────────────── 147 + // Timed benchmark 149 148 let t_start = unsafe { read_cycle_counter() }; 150 149 151 150 for _ in 0..ITERS { ··· 162 161 // latency_ns = (per_frm * 1000) / LEON3_FREQ_MHZ (avoids division by freq first) 163 162 let lat_ns = (per_frm * 1000) / LEON3_FREQ_MHZ; 164 163 165 - // ── UART output ────────────────────────────────────────────────────── 164 + // UART output 166 165 unsafe { 167 166 uart_puts("\r\n===============================================\r\n"); 168 167 uart_puts(" AETHELIX LEON3 CYCLE-COUNT BENCHMARK\r\n");
+27 -10
rust_core/src/ffi.rs
··· 18 18 use crate::flight::causal_ranker::{rank_root_causes, MAX_NODES}; 19 19 use crate::flight::fixed_point::normalize_q15; 20 20 21 - // ── APID → channel mapping ──────────────────────────────────────────────────── 21 + // APID → channel mapping 22 22 // Maps well-known CCSDS APIDs to Aethelix channel indices 0–7. 23 23 // Configurable for each spacecraft by modifying this table (no recompile needed 24 24 // if APID table is stored in EEPROM — add a `set_apid_map()` call to configure). ··· 43 43 (0x008, 7), 44 44 ]; 45 45 46 - // ── Return codes (match aethelix.h) ────────────────────────────────────────── 46 + // Return codes (match aethelix.h) 47 47 const RET_OK: i32 = 0; 48 48 const RET_TOO_SHORT: i32 = 1; 49 49 const RET_BAD_LEN: i32 = 2; 50 50 const RET_NULL_PTR: i32 = -1; 51 51 52 - // ── Main API ────────────────────────────────────────────────────────────────── 52 + // Global Recovery Handler 53 + // Bare-metal safe global callback (assumes single-threaded cooperative OS or 54 + // interrupt-masked access if needed). 55 + static mut RECOVERY_HANDLER: Option<extern "C" fn(i32)> = None; 56 + 57 + // Main API 53 58 54 59 /// Process one CCSDS Space Packet through the Aethelix diagnostic engine. 55 60 /// ··· 81 86 // Clear output alert at entry 82 87 *alert_ref = AethelixAlert::NO_FAULT; 83 88 84 - // ── Parse CCSDS packet ──────────────────────────────────────────────── 89 + // Parse CCSDS Packets 85 90 let pkt = match parse_flight_packet(buf) { 86 91 Ok(p) => p, 87 92 Err(CcsdsError::BufferTooShort) => return RET_TOO_SHORT, 88 93 Err(CcsdsError::LengthMismatch) => return RET_BAD_LEN, 89 94 }; 90 95 91 - // ── Map APID to telemetry channel ───────────────────────────────────── 96 + // Map APID to telemetry channel 92 97 let channel = match APID_CHANNEL_MAP.iter().find(|(apid, _)| *apid == pkt.header.apid) { 93 98 Some((_, ch)) => *ch, 94 99 None => return RET_OK, // Unknown APID — silently skip 95 100 }; 96 101 97 - // ── Extract measurement (first i16 in payload, big-endian CCSDS) ───── 102 + // Extract measurement (first i16 in payload, big-endian CCSDS) 98 103 if (pkt.payload_len as usize) < 2 { 99 104 return RET_OK; // No data to process 100 105 } 101 106 let raw_val = ((pkt.payload[0] as i16) << 8) | (pkt.payload[1] as i16); 102 107 103 - // ── Normalize to Q15 using pre-calibrated mean/range ───────────────── 108 + // Normalize to Q15 using pre-calibrated mean/range 104 109 let nom_mean = state_ref.nom_means[channel] as i32; 105 110 let nom_range = state_ref.nom_ranges[channel]; 106 111 let q15_val = normalize_q15(raw_val as i32, nom_mean, nom_range); 107 112 108 - // ── Run KS anomaly detection ────────────────────────────────────────── 113 + // Run KS anomaly detection 109 114 let severity = process_channel(state_ref, channel, q15_val); 110 115 state_ref.frame_count = state_ref.frame_count.wrapping_add(1); 111 116 112 - // ── No anomaly — return clean ───────────────────────────────────────── 117 + // No anomaly — return clean 113 118 if severity.0 == 0 { 114 119 return RET_OK; 115 120 } 116 121 117 - // ── Anomaly detected — populate alert ──────────────────────────────── 122 + // Anomaly detected — populate alert 118 123 alert_ref.evidence_mask |= 1u32 << channel; 119 124 120 125 // Build severity array for causal ranker (only this channel is anomalous) ··· 130 135 alert_ref.root_cause_id = hits[0].node_id; 131 136 alert_ref.confidence_q15 = hits[0].score_q15; 132 137 alert_ref.root_cause_2_id = hits[1].node_id; 138 + 139 + // Trigger active recovery callback if registered 140 + if let Some(handler) = RECOVERY_HANDLER { 141 + handler(alert_ref.root_cause_id as i32); 142 + } 133 143 } 134 144 135 145 // Set alert level from Q15 severity thresholds ··· 162 172 pub extern "C" fn aethelix_state_size() -> u32 { 163 173 core::mem::size_of::<AethelixState>() as u32 164 174 } 175 + 176 + /// Register a global recovery handler to be invoked dynamically when 177 + /// a root cause is isolated. 178 + #[no_mangle] 179 + pub unsafe extern "C" fn register_recovery_handler(handler: extern "C" fn(i32)) { 180 + RECOVERY_HANDLER = Some(handler); 181 + }
+5 -5
rust_core/src/flight/causal_ranker.rs
··· 23 23 static GRAPH_BIN: &[u8] = 24 24 include_bytes!("../../../causal_graph/causal_graph.bin"); 25 25 26 - // ── Format constants ────────────────────────────────────────────────────────── 26 + // Format constants 27 27 pub const MAGIC: [u8; 4] = [0xCA, 0x05, 0xAE, 0x01]; 28 28 29 29 pub const NODE_ROOT: u8 = 0; ··· 37 37 /// Maximum BFS traversal depth (causal graph diameter is typically 4–5). 38 38 pub const MAX_DEPTH: usize = 7; 39 39 40 - // ── Internal graph representation ───────────────────────────────────────────── 40 + // Internal graph representation 41 41 42 42 #[derive(Copy, Clone, Default)] 43 43 struct NodeMeta { ··· 99 99 Some(graph) 100 100 } 101 101 102 - // ── Public types ────────────────────────────────────────────────────────────── 102 + // Public types 103 103 104 104 /// Top-ranked root cause hypothesis from the causal engine. 105 105 #[repr(C)] ··· 226 226 result 227 227 } 228 228 229 - // ── Unit tests ──────────────────────────────────────────────────────────────── 229 + // Unit tests 230 230 #[cfg(test)] 231 231 mod tests { 232 232 use super::*; ··· 250 250 } 251 251 } 252 252 253 - // ── Kani proofs ─────────────────────────────────────────────────────────────── 253 + // Kani proofs 254 254 #[cfg(kani)] 255 255 mod kani_proofs { 256 256 use super::*;
+2 -2
rust_core/src/flight/ccsds_flight.rs
··· 97 97 }) 98 98 } 99 99 100 - // ── Unit tests ──────────────────────────────────────────────────────────────── 100 + // Unit tests 101 101 #[cfg(test)] 102 102 mod tests { 103 103 use super::*; ··· 153 153 } 154 154 } 155 155 156 - // ── Kani proofs ─────────────────────────────────────────────────────────────── 156 + // Kani proofs 157 157 #[cfg(kani)] 158 158 mod kani_proofs { 159 159 use super::*;
+4 -4
rust_core/src/flight/fixed_point.rs
··· 67 67 } 68 68 } 69 69 70 - // ── Arithmetic traits (all saturating) ─────────────────────────────────────── 70 + // Arithmetic traits (all saturating) 71 71 72 72 impl Add for Q15 { 73 73 type Output = Self; ··· 97 97 fn mul(self, rhs: Self) -> Self { self.mul_sat(rhs) } 98 98 } 99 99 100 - // ── Conversion helpers ──────────────────────────────────────────────────────── 100 + // Conversion helpers 101 101 102 102 /// Normalize a raw telemetry integer value into Q15 range. 103 103 /// ··· 118 118 Q15(scaled.clamp(i16::MIN as i32, i16::MAX as i32) as i16) 119 119 } 120 120 121 - // ── Unit tests ──────────────────────────────────────────────────────────────── 121 + // Unit tests 122 122 #[cfg(test)] 123 123 mod tests { 124 124 use super::*; ··· 169 169 } 170 170 } 171 171 172 - // ── Kani proofs — mathematically prove no panic for ANY i16 input ───────────── 172 + // Kani proofs — mathematically prove no panic for ANY i16 input 173 173 #[cfg(kani)] 174 174 mod kani_proofs { 175 175 use super::*;
+32 -23
rust_core/src/flight/ks_detector.rs
··· 22 22 use crate::flight::fixed_point::Q15; 23 23 use crate::flight::fdir_output::AethelixState; 24 24 25 - // ── Constants ───────────────────────────────────────────────────────────────── 25 + // Constants 26 26 27 27 pub const NUM_CHANNELS: usize = 8; // EPS/TCS primary channels 28 28 pub const REF_SIZE: usize = 128; // Reference window depth (samples) ··· 37 37 /// p_threshold ≈ 0.005 at window sizes 64/128 corresponds to D ≈ 0.22–0.30. 38 38 const KS_THRESHOLD_Q15: i16 = 0x1C00; // ≈ 0.22 39 39 40 - // ── Insertion sort (stack-safe, bounded) ────────────────────────────────────── 40 + // Insertion sort (stack-safe, bounded) 41 41 42 42 /// Sort a small `i16` slice in-place using insertion sort. 43 43 /// O(n²) — acceptable for n ≤ 128 on LEON3 (≈ 2 400 cycles worst case). ··· 55 55 } 56 56 } 57 57 58 - // ── KS D-statistic (fixed-point merge walk) ─────────────────────────────────── 58 + // KS D-statistic (fixed-point merge walk) 59 59 60 60 /// Compute the KS D-statistic between two **sorted** `i16` slices. 61 61 /// ··· 74 74 75 75 // Merge-walk: advance through both sorted arrays simultaneously. 76 76 // At each step, compute |F1(x) - F2(x)| in integer arithmetic. 77 - while i < a.len() || j < b.len() { 78 - // Advance the pointer of the smaller current value 79 - let advance_a = match (i < a.len(), j < b.len()) { 80 - (true, false) => true, 81 - (false, true) => false, 82 - (true, true) => a[i] <= b[j], 83 - _ => break, 84 - }; 77 + while i < a.len() && j < b.len() { 78 + if a[i] < b[j] { 79 + let val = a[i]; 80 + while i < a.len() && a[i] == val { i += 1; } 81 + } else if b[j] < a[i] { 82 + let val = b[j]; 83 + while j < b.len() && b[j] == val { j += 1; } 84 + } else { 85 + let val = a[i]; 86 + while i < a.len() && a[i] == val { i += 1; } 87 + while j < b.len() && b[j] == val { j += 1; } 88 + } 89 + let diff = (i as i32 * nb - j as i32 * na).abs(); 90 + if diff > d_max { d_max = diff; } 91 + } 85 92 86 - if advance_a { i += 1; } else { j += 1; } 93 + while i < a.len() { 94 + let val = a[i]; 95 + while i < a.len() && a[i] == val { i += 1; } 96 + let diff = (i as i32 * nb - j as i32 * na).abs(); 97 + if diff > d_max { d_max = diff; } 98 + } 87 99 88 - // |CDF_a - CDF_b| = |i/na - j/nb| = |i*nb - j*na| / (na*nb) 89 - // We track the numerator only (scale at the end). 100 + while j < b.len() { 101 + let val = b[j]; 102 + while j < b.len() && b[j] == val { j += 1; } 90 103 let diff = (i as i32 * nb - j as i32 * na).abs(); 91 104 if diff > d_max { d_max = diff; } 92 105 } ··· 100 113 Q15(d_q15.clamp(0, i16::MAX as i32) as i16) 101 114 } 102 115 103 - // ── Public API ──────────────────────────────────────────────────────────────── 116 + 104 117 105 118 /// Process one telemetry frame for a single channel and update alarm state. 106 119 /// ··· 116 129 return Q15::ZERO; 117 130 } 118 131 119 - // ── Push into current window ────────────────────────────────────────── 132 + 120 133 let cur_head = state.cur_heads[channel] as usize; 121 134 state.cur_windows[channel][cur_head] = sample.0; 122 135 state.cur_heads[channel] = (if cur_head + 1 >= CUR_SIZE { 0 } else { cur_head + 1 }) as u8; ··· 127 140 let cur_len = state.cur_lens[channel] as usize; 128 141 let ref_len = state.ref_lens[channel] as usize; 129 142 130 - // ── Warm-up: not enough data yet ───────────────────────────────────── 131 143 if cur_len < CUR_SIZE || ref_len < 20 { 132 144 // Feed into reference window until it's primed 133 145 let rh = state.ref_heads[channel] as usize; ··· 139 151 return Q15::ZERO; 140 152 } 141 153 142 - // ── Copy windows to stack scratch and sort ──────────────────────────── 143 154 let mut cur_scratch = [0i16; CUR_SIZE]; 144 155 let mut ref_scratch = [0i16; REF_SIZE]; 145 156 ··· 154 165 155 166 let d = ks_statistic(&ref_scratch[..ref_n], &cur_scratch[..cur_n]); 156 167 157 - // ── Persistence logic ───────────────────────────────────────────────── 158 168 if d.0 >= KS_THRESHOLD_Q15 { 159 169 // Increment streak (capped at PERSIST to avoid u8 overflow) 160 170 if state.alarm_streak[channel] < PERSIST { ··· 162 172 } 163 173 164 174 if state.alarm_streak[channel] >= PERSIST { 165 - // Anomaly confirmed — return D as severity 166 175 return d; 167 176 } 168 177 } else { ··· 184 193 Q15::ZERO 185 194 } 186 195 187 - // ── Unit tests ──────────────────────────────────────────────────────────────── 196 + 188 197 #[cfg(test)] 189 198 mod tests { 190 199 use super::*; ··· 193 202 #[test] 194 203 fn test_no_alarm_on_nominal_data() { 195 204 let mut state = AethelixState::zeroed(); 196 - // Feed 300 identical samples — no distribution shift → no alarm 205 + // Feed 300 identical samples - no distribution shift → no alarm 197 206 for _ in 0..300 { 198 207 let result = process_channel(&mut state, 0, Q15(1000)); 199 208 assert_eq!(result, Q15::ZERO, "Nominal data should not trigger alarm"); ··· 207 216 for _ in 0..200 { 208 217 process_channel(&mut state, 0, Q15(50)); 209 218 } 210 - // Then inject a major shift — distribution jumps to +16000 219 + // Then inject a major shift - distribution jumps to +16000 211 220 let mut triggered = false; 212 221 for _ in 0..100 { 213 222 let r = process_channel(&mut state, 0, Q15(16_000));
+2 -2
rust_core/src/flight/ring_buffer.rs
··· 109 109 } 110 110 } 111 111 112 - // ── Unit tests ──────────────────────────────────────────────────────────────── 112 + // Unit tests 113 113 #[cfg(test)] 114 114 mod tests { 115 115 use super::*; ··· 162 162 } 163 163 } 164 164 165 - // ── Kani proofs ─────────────────────────────────────────────────────────────── 165 + // Kani proofs 166 166 #[cfg(kani)] 167 167 mod kani_proofs { 168 168 use super::*;
+4 -4
rust_core/src/lib.rs
··· 34 34 unsafe { core::arch::asm!("nop", options(nomem, nostack)); } 35 35 } 36 36 } 37 - // ── Flight modules (always compiled when `flight` feature is active) ────────── 37 + // Flight modules (always compiled when `flight` feature is active) 38 38 #[cfg(feature = "flight")] 39 39 pub mod flight; 40 40 ··· 43 43 #[cfg(feature = "flight")] 44 44 pub mod ffi; 45 45 46 - // ── Ground modules (require std) ───────────────────────────────────────────── 46 + // Ground modules (require std) 47 47 #[cfg(feature = "std")] 48 48 pub mod error; 49 49 ··· 106 106 /// Framework version 107 107 pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 108 108 109 - // ── Ground: process_measurement helper ─────────────────────────────────────── 109 + // Ground: process_measurement helper 110 110 #[cfg(feature = "std")] 111 111 pub fn process_measurement( 112 112 measurement: &measurement::Measurement, ··· 118 118 Ok(kalman.get_estimate()) 119 119 } 120 120 121 - // ── Tests ───────────────────────────────────────────────────────────────────── 121 + // Tests 122 122 #[cfg(test)] 123 123 mod tests { 124 124 use super::*;
+8 -8
scripts/pcoe_benchmark.py
··· 40 40 41 41 import numpy as np 42 42 43 - # ── Path setup ──────────────────────────────────────────────────────────────── 43 + # Path setup 44 44 REPO_ROOT = Path(__file__).resolve().parent.parent 45 45 sys.path.insert(0, str(REPO_ROOT)) 46 46 47 47 from operational.anomaly_detector import CycleLevelDetector 48 48 49 - # ── Configuration ───────────────────────────────────────────────────────────── 49 + # Configuration 50 50 DATA_DIR = REPO_ROOT / "data" / "pcoe_battery" 51 51 OUT_DIR = REPO_ROOT / "output" 52 52 ··· 60 60 # Zenodo open-access CSV mirror (CSV conversions of the original MATLAB .mat files) 61 61 ZENODO_BASE = "https://zenodo.org/record/3402516/files" 62 62 63 - # ── Dataset loader ───────────────────────────────────────────────────────────── 63 + # Dataset loader 64 64 65 65 def try_download(battery_id: str) -> bool: 66 66 """Attempt to download battery CSV from Zenodo. Returns True if successful.""" ··· 144 144 "volts": volts, "temps": temps} 145 145 146 146 147 - # ── Detection methods ───────────────────────────────────────────────────────── 147 + # Detection methods 148 148 149 149 def threshold_detection_cycle(caps: np.ndarray) -> int: 150 150 """NASA threshold method: fires first cycle where capacity < WARN_AH (80%).""" ··· 201 201 return len(volts) - 1 # Degradation not detected before end of dataset 202 202 203 203 204 - # ── Per-battery benchmark ───────────────────────────────────────────────────── 204 + # Per-battery benchmark 205 205 206 206 def benchmark_battery(battery_id: str, seed: int, allow_download: bool) -> dict: 207 207 print(f"\n ── {battery_id} ──") ··· 250 250 return result 251 251 252 252 253 - # ── Entry point ─────────────────────────────────────────────────────────────── 253 + # Entry point 254 254 255 255 def run_pcoe_benchmark(batteries=None, allow_download=True): 256 256 batteries = batteries or ALL_BATTERIES ··· 267 267 for i, bat in enumerate(batteries): 268 268 results.append(benchmark_battery(bat, seed=42 + i, allow_download=allow_download)) 269 269 270 - # ── Summary table ───────────────────────────────────────────────────────── 270 + # Summary table 271 271 print() 272 272 print("=" * 70) 273 273 print(" Summary: Detection Lead Time vs NASA Threshold Baseline") ··· 300 300 print(" 4. LEAN — <8 KB RAM, runs live on LEON3 OBC flash at 50 MHz") 301 301 print("=" * 70) 302 302 303 - # ── Persist results ─────────────────────────────────────────────────────── 303 + # Persist results 304 304 OUT_DIR.mkdir(exist_ok=True) 305 305 out_path = OUT_DIR / "pcoe_benchmark_results.json" 306 306 with open(out_path, "w") as f: