diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ee69cf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: lint · tests · selftest (py${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + - run: python -m pip install -r requirements-dev.txt + - name: ruff + run: python -m ruff check --select E,F,W --line-length 130 astraeus isaac tests scripts + - name: compile all entry points (incl. Isaac scripts that need the Isaac runtime to execute) + run: python -m compileall -q astraeus isaac tests legacy scripts + - name: pytest + run: python -m pytest tests -q + - name: selftest (19 physical / statistical invariants) + run: python -m astraeus selftest + + server: + name: server smoke test + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install -r requirements.txt + - name: boot uvicorn and hit the API + run: | + python -m uvicorn astraeus.app:app --port 8000 --log-level warning & + for i in $(seq 1 30); do curl -sf localhost:8000/api/health && break; sleep 1; done + curl -sf localhost:8000/api/health + curl -sf localhost:8000/api/priors | head -c 300; echo + curl -sf -X POST localhost:8000/api/simulate -H 'content-type: application/json' \ + -d '{"x":{"k_soil":2,"theta_r":0.724,"r_terrain":1,"rho_rock":2,"slope_deg":4,"sun_e":1.5,"sun_psi":200,"tau_dust":0.05},"seed":0}' \ + | head -c 300; echo + curl -sf -o /tmp/frame.png "localhost:8000/api/whatif/frame.png?k_soil=2&theta_r=0.724&r_terrain=1&rho_rock=2&slope_deg=4&sun_e=1.5&sun_psi=200&tau_dust=0.05&w=320&h=180&q=2" + python -c "from PIL import Image; im=Image.open('/tmp/frame.png'); assert im.size==(320,180), im.size; print('frame', im.size)" diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..4e56d65 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,27 @@ +cff-version: 1.2.0 +message: "If you use Astraeus in academic work, please cite it as below." +title: "Astraeus: adversarial stress testing of off-world rover autonomy under unknown disturbance priors" +type: software +version: 2.0.0 +date-released: 2026-09-12 +license: MIT +repository-code: "https://github.com/vineigenphase/Non-Trivial---Astraeus" +authors: + - family-names: Vinujan + given-names: S. + email: svinujan10@gmail.com +keywords: + - adaptive stress testing + - importance sampling + - distribution shift + - lunar rover + - terramechanics + - autonomy validation + - cross-entropy method +abstract: >- + Astraeus simulates thousands of autonomous rover traverses under an eight-dimensional + disturbance vector (soil, terrain, rocks, slope, illumination, dust), re-weights one + defensive-mixture Monte-Carlo campaign under six competing environmental priors with + explicit effective-sample-size and support-coverage gates, searches adversarially for + the most plausible failures with the cross-entropy method, and renders every episode + in a deterministic software renderer and a live WebGL world model. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d7c0292 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +Astraeus is a research framework; correctness and honesty about what the model does and +does not capture matter more than features. Please keep that spirit. + +## Setup + +```bash +git clone https://github.com/vineigenphase/Non-Trivial---Astraeus.git +cd Non-Trivial---Astraeus +python -m pip install -r requirements-dev.txt +make check # ruff + pytest + 19 selftest invariants +``` + +## Ground rules + +- **Determinism is a contract.** Every episode is a pure function of `(x, seed)`. If a change + alters trajectories, say so in the PR and regenerate `docs/VALIDATION.md` numbers with + `make campaign`. +- **Do not hide support mismatch.** Priors whose mass lies outside the simulated support must + read `n/a`, never a clean `0.000`. +- **Keep provenance.** Proposal (`mc`), adversarial (`cem`) and external simulator rows are + never mixed into the same importance-weighted estimate. +- **Model changes need a citation or a measurement.** New terramechanics, photometry or + perception terms should reference a source in `docs/RESEARCH.md` or be labelled as a + heuristic in `docs/PARAMETERS.md`. +- **Isaac Sim / ROS 2 code you cannot run here must be marked unverified** (see + `isaac/configs/ros2_topics.yaml`). + +## Before opening a PR + +```bash +make check # must be green +python scripts/make_renders.py runs/val.npz # only if the renderer or physics changed +``` + +CI runs ruff, `compileall`, pytest, the selftest, and boots the FastAPI server to hit the API on +Python 3.10 and 3.12. diff --git a/README.md b/README.md index b0ecebc..b6a2442 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,229 @@ +
+ # Astraeus -**An open adversarial stress-testing (AST) framework for off-world autonomy under unknown disturbance priors.** +**Adversarial stress testing of off-world rover autonomy under unknown disturbance priors.** + +[![CI](https://github.com/vineigenphase/Non-Trivial---Astraeus/actions/workflows/ci.yml/badge.svg)](https://github.com/vineigenphase/Non-Trivial---Astraeus/actions/workflows/ci.yml) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white)](pyproject.toml) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Deterministic](https://img.shields.io/badge/episodes-deterministic%20in%20(x%2C%20seed)-8A2BE2)](docs/ARCHITECTURE.md#determinism-contract) +[![Cite](https://img.shields.io/badge/cite-CITATION.cff-blue)](CITATION.cff) +[![Research](https://img.shields.io/badge/research-docs%2FRESEARCH.md-orange)](docs/RESEARCH.md) -Astraeus asks one question of an autonomous rover: *how likely is it to fail, and how does that answer -change when you are not sure which environment it will face?* It simulates thousands of traverses -under an eight-dimensional disturbance vector, re-weights the same episodes under six competing -priors about the destination, searches adversarially for the most plausible failures, and shows every -episode in a live, interactive world model. +Astraeus - a VIPER-class rover entrapped on a crater rim under 2.6 degree polar sun. Deterministic software render of campaign episode #111. -![Stuck episode, chase camera](docs/img/stuck_chase.png) +Episode #111 of the validation campaign: soft sloped regolith, 2.65° sun, wheel sinkage exceeding traction. +Every pixel is a deterministic function of the eight numbers in the caption. Rendered by `astraeus/render.py`, no GPU. -Everything in this repository runs on a laptop with NumPy: the simulator, the campaign engine, the -renderer and the web UI. NVIDIA Isaac Sim, OmniLRS/ROS 2 and Reactor world models are optional -back-ends that plug into the same campaign format. +
+ +--- + +A rover on the Moon will not meet the environment its engineers simulated. Soil will be softer or +firmer, the sun lower, rocks denser, the sky dustier after landing. Astraeus asks the question that +matters for that rover: **how likely is it to fail, and how much does the answer change when you are +honest about not knowing which environment it will face?** + +It simulates thousands of traverses under an **eight-dimensional disturbance vector**, re-weights one +campaign under **six competing priors** about the destination with explicit reliability gates, searches +**adversarially** for the most plausible failures, and shows every episode in a **live, interactive world +model** and a **deterministic software renderer**. Everything runs on a laptop with NumPy. NVIDIA Isaac Sim, +OmniLRS/ROS 2 and Reactor world models are optional back-ends that share the same campaign format. + +
+ +| | | +|:--:|:--:| +|
**tip-over** - static roll limit exceeded on crater-rim relief |
**collision** - rock not resolved in low-sun glare and dust, rover camera | +|
**nav_miss** - visual-odometry drift (amber) leaves the goal ring, overview |
**success** - nominal traverse under a benign draw | + +Chase-camera replay of episode #111 from departure to entrapment + +Chase-camera replay of the hero episode, 40 frames from departure to entrapment. +Cyan: true path. Amber: the rover's own visual-odometry estimate. Green ring and beacon: the goal. + +
--- ## Contents +- [Why this exists](#why-this-exists) - [What Astraeus does](#what-astraeus-does) - [Quick start](#quick-start) - [The eight disturbance dimensions](#the-eight-disturbance-dimensions) -- [Candidate priors and the proposal](#candidate-priors-and-the-proposal) -- [Command line](#command-line) +- [Priors, proposal and the reliability gate](#priors-proposal-and-the-reliability-gate) +- [Results from the validation campaign](#results-from-the-validation-campaign) - [Live interactive world model](#live-interactive-world-model) -- [High-quality renders](#high-quality-renders) +- [Renderer](#renderer) +- [Command line](#command-line) - [NVIDIA Isaac Sim, OmniLRS and ROS 2](#nvidia-isaac-sim-omnilrs-and-ros-2) - [Reactor world model (optional)](#reactor-world-model-optional) -- [Validation evidence](#validation-evidence) +- [Architecture](#architecture) - [Assumptions, known flaws and limitations](#assumptions-known-flaws-and-limitations) -- [Documentation](#documentation) - [Research](#research) -- [Repository layout](#repository-layout) - [Development](#development) -- [License](#license) +- [Citing](#citing) --- +## Why this exists + +Validation of autonomous systems usually samples disturbances from *one* assumed distribution and +reports *one* failure rate. For a destination nobody has driven, that single number hides the +decision that actually matters: **which prior you believed**. Astraeus makes the prior an explicit, +swappable input and treats the mismatch between what you simulated and what you believe as a +first-class result: + +- one Monte-Carlo campaign is drawn from a **defensive mixture proposal `q`**; +- the *same* episodes are **re-weighted** under each candidate prior with `w = p_k(x) / q(x)`; +- every estimate carries an **effective sample size**, a **support-coverage** check and a + **weighted-bootstrap confidence interval**; +- a prior whose mass the proposal never covered is reported as **not estimable**, not as a clean zero; +- a **cross-entropy search** finds the most severe failures that are still plausible under the prior you + chose, and keeps them as replayable elites. + +The result is a comparison across beliefs with the mechanisms behind each failure visible and +replayable, rather than a single number that looks precise and is not. + ## What Astraeus does -1. **Simulates** a 430 kg VIPER-class four-wheel rover driving a 30 m goal-seeking traverse over procedurally - generated lunar terrain (relief, craters, rocks, regional slope) with wheel–soil mechanics - (Bekker sinkage, slip-dependent traction, rut compaction), attitude and tip-over, rock collision, - low-sun perception, dust-attenuated visibility and visual-odometry drift. Each episode is - **deterministic in `(x, seed)`** and replays bit-for-bit. -2. **Samples** disturbances from a single defensive proposal `q` and **re-weights** the resulting - episodes under six candidate priors `P1…P6` with importance weights `w = p_k(x)/q(x)`. Every prior - gets a failure probability, a weighted-bootstrap confidence interval, an effective sample size and a - support-coverage check. Estimates that fail the reliability gate are labelled, not hidden. -3. **Analyses distribution shift**: which failure mode dominates under which prior, whether the - ranking of failure modes changes between priors, and how each dimension drives failure. -4. **Searches adversarially** with a cross-entropy method (CEM) for the highest-severity failures that - are still plausible under a chosen prior, and keeps them as replayable elites. -5. **Renders** every episode: a NumPy software renderer produces HUD-annotated frames from four cameras - and contact sheets, and a WebGL viewer shows the exact terrain the simulator used with the true and - estimated trajectories. -6. **Bridges** to NVIDIA Isaac Sim (standalone) and OmniLRS/ROS 2 so the same disturbance vectors can be - run in a physics engine, logged to CSV and folded back into the campaign with explicit provenance. +| Stage | What happens | Where | +|---|---|---| +| **Simulate** | 430 kg VIPER-class four-wheel rover, 30 m goal-seeking traverse over procedural lunar terrain (relief, craters, rocks, regional slope). Bekker sinkage, slip-dependent traction, rut compaction, attitude and tip-over, rock collision, low-sun perception, dust-attenuated visibility, visual-odometry drift. Deterministic in `(x, seed)`. | `astraeus/rover.py`, `terrain.py`, `terramechanics.py`, `perception.py` | +| **Sample and re-weight** | Draw from `q`, compute importance weights under `P1..P6`, ESS, support coverage, bootstrap CIs, reliability gate. | `astraeus/priors.py`, `campaign.py` | +| **Analyse shift** | Dominant failure mode per prior, rank changes between priors, per-dimension sensitivity. | `astraeus/campaign.py` | +| **Search adversarially** | Cross-entropy method over `x` with a severity objective and a plausibility floor under the chosen prior; elites kept with `source = cem` so they never contaminate weighted estimates. | `astraeus/campaign.py` | +| **Render** | Ray-marched height field, true sun vector, shadows, dust extinction, five cameras, HUD or clean caption, contact sheets. | `astraeus/render.py` | +| **Show live** | WebGL world model of the exact terrain the physics used; eight sliders re-simulate in 0.1-0.6 s; campaign and CEM stream progress over WebSocket. | `astraeus/app.py`, `astraeus/static/` | +| **Bridge** | Isaac Sim standalone environment and OmniLRS/ROS 2 harness run the same `x`, log the same CSV, fold back in with `source = external`. | `isaac/` | ## Quick start -Requirements: Python 3.10+, NumPy, Pillow. FastAPI + Uvicorn for the web UI. PyYAML for the Isaac configs. +Python 3.10+. Core needs only NumPy and Pillow; the web UI adds FastAPI and Uvicorn. ```bash git clone https://github.com/vineigenphase/Non-Trivial---Astraeus.git cd Non-Trivial---Astraeus python -m pip install -r requirements.txt -python -m astraeus selftest # 19 invariant checks, ~20 s -python -m astraeus run --n 2000 --out runs/c1.npz # campaign under q, report for P1..P6 -python -m astraeus cem runs/c1.npz --prior P2 # adversarial search, appended to the run +python -m astraeus selftest # 19 physical/statistical invariants, ~20 s +python -m astraeus run --n 2000 --out runs/c1.npz # campaign under q, report for P1..P6 +python -m astraeus cem runs/c1.npz --prior P2 # adversarial search, appended with provenance python -m astraeus render runs/c1.npz --prior P5 --k 4 --sheet --quality 2 -python -m astraeus serve --port 8000 # live world model at http://localhost:8000 +python -m astraeus serve --port 8000 # live world model at http://localhost:8000 ``` -Or with `make`: `make test`, `make campaign`, `make renders`, `make serve`. +`make test`, `make campaign`, `make renders`, `make serve` wrap the same commands. ## The eight disturbance dimensions -| # | Dimension | Meaning | Support | -|---|-----------|---------|---------| -| 1 | `k_soil` | soil sinkage modulus, multiple of nominal | 0.5 – 5.0 (log-uniform under P1) | -| 2 | `theta_r` | internal friction angle, rad (35° – 50°) | 0.61 – 0.87 | -| 3 | `r_terrain` | terrain relief amplitude, multiple of nominal | 0.5 – 2.0 | -| 4 | `rho_rock` | rock density, rocks per 100 m² | 0 – 12 | -| 5 | `slope_deg` | regional slope, deg | 0 – 15 | -| 6 | `sun_e` | solar elevation, deg | 0.5 – 6 (polar) | -| 7 | `sun_psi` | solar azimuth, deg | 0 – 360 | -| 8 | `tau_dust` | dust optical depth (suspended + deposited) | 0 – 0.6 | +| # | Dimension | Physical channel | Support | +|--:|---|---|---| +| 1 | `k_soil` | soil sinkage modulus, multiple of nominal → wheel sinkage, motion resistance | 0.5 - 5.0 (log-uniform under P1) | +| 2 | `theta_r` | internal friction angle, rad (35°-50°) → shear strength, traction limit | 0.61 - 0.87 | +| 3 | `r_terrain` | relief amplitude, multiple of nominal → local slopes, attitude | 0.5 - 2.0 | +| 4 | `rho_rock` | rocks per 100 m² → obstacle density, collision exposure | 0 - 12 | +| 5 | `slope_deg` | regional slope → gravity component along traverse | 0 - 15 | +| 6 | `sun_e` | solar elevation → shadow length, camera contrast, detection | 0.5 - 6 (polar) | +| 7 | `sun_psi` | solar azimuth → glare direction relative to heading | 0 - 360 | +| 8 | `tau_dust` | dust optical depth, suspended + deposited → visibility, VO drift | 0 - 0.6 | -Each dimension reaches the rover through a specific physical channel (sinkage, traction, geometry, -obstacles, gravity component, shadow length and camera contrast, glare direction, visibility). -Full definitions, channels and derived quantities: [docs/PARAMETERS.md](docs/PARAMETERS.md). +Each dimension reaches the rover through a named physical channel, so a sensitivity result is a +statement about a mechanism, not a regression coefficient. Definitions, derived quantities and the +rover constants: [docs/PARAMETERS.md](docs/PARAMETERS.md). -## Candidate priors and the proposal +## Priors, proposal and the reliability gate | Prior | Name | What it believes | -|-------|------|------------------| -| `P1` | maximum ignorance | uniform (log-uniform for `k_soil`) over the whole support | -| `P2` | VIPER-spec south pole | firmer soil, moderate relief and rocks, low-sun-skewed lighting (mode 1.5°), light dust | -| `P3` | simulator default | tight kernel at the stock simulator settings, **sun at 45°** | -| `P4` | equatorial mare | flat mare, sparse rocks, sun 4–6° at its low-elevation edge (the benign case) | -| `P5` | PSR rim, deep winter | steep flanks (mode 9°), loose regolith, dense ejecta rocks, grazing sun 0.5–2.5° | -| `P6` | post-landing plume | P2 terrain and lighting with heavy plume dust, `tau_dust` 0.25–0.6 | +|---|---|---| +| `P1` | maximum ignorance | uniform (log-uniform `k_soil`) over the whole support | +| `P2` | VIPER-spec south pole | firmer soil, moderate relief and rocks, sun skewed low (mode 1.5°), light dust | +| `P3` | simulator default | tight kernel at the stock simulator settings, **sun at 45°** | +| `P4` | equatorial mare | flat mare, sparse rocks, sun at its 4-6° low edge (the benign case) | +| `P5` | PSR rim, deep winter | steep flanks (mode 9°), loose regolith, dense ejecta, grazing sun 0.5-2.5° | +| `P6` | post-landing plume | P2 terrain and lighting with heavy plume dust, `tau_dust` 0.25-0.6 | + +Every episode is drawn from + +``` +q = 0.40·P1 + 0.15·(P2 + P4 + P5 + P6) +``` + +`P3` is deliberately **not** in the mixture and its 45° sun lies outside the simulated support. Astraeus +reports it as *not estimable* (ESS ≈ 1, 100 % of its mass outside support) instead of clipping the +parameter. That mismatch is a finding about Earth-convention simulator defaults, not a bug to hide. + +**Reliability gate:** an estimate is flagged when `ESS < 30` or support coverage `< 0.5`. ESS is +`(Σw)² / Σw²`; CIs are weighted bootstrap. + +## Results from the validation campaign -Every episode is drawn from `q = 0.40·P1 + 0.15·(P2 + P4 + P5 + P6)`. `P3` is intentionally **not** in -the mixture and its sun elevation lies outside the simulated support: the framework reports it as -*not estimable* (ESS ≈ 1, 100 % of its mass outside support) instead of clipping it. That mismatch is -a finding about Earth-convention simulator defaults, not a bug to hide. +4000 proposal episodes + 2400 CEM rows, `gt_pose = False`, 13.8 s of simulation time. These are the +numbers behind the images above; the images were chosen for legibility, the numbers were not. -Reliability gate: an estimate is flagged unreliable when `ESS < 30` or support coverage `< 0.5`. +| Prior | P(fail) | 95 % CI | ESS / 4000 | Dominant mode | +|---:|---:|---:|---:|---| +| P1 | 0.550 | 0.530 - 0.573 | 1794 | stuck | +| P2 | 0.331 | 0.294 - 0.364 | 687 | stuck | +| P3 | n/a | - | 1 | *not estimable: 100 % outside support* | +| P4 | 0.084 | 0.064 - 0.108 | 583 | stuck | +| P5 | 0.665 | 0.629 - 0.697 | 675 | stuck | +| P6 | 0.379 | 0.347 - 0.417 | 667 | **nav_miss** | + +Three things the single-prior approach would have missed: + +1. **The answer spans 0.08 to 0.67** for the same rover, same software, depending only on which + destination you believe in. +2. **The dominant failure mode changes.** Under P6 (plume dust) the rover stops failing by getting + stuck and starts failing by *navigating to the wrong place*: dust degrades visual odometry faster + than it degrades traction. +3. **P3 is not a number.** The stock simulator prior cannot be evaluated from a polar campaign, and + the framework says so instead of printing `0.000`. + +Test suite: 30 pytest cases + 19 selftest invariants, `ruff` clean, CI on Python 3.10 and 3.12. +Procedure, full tables and what was *not* validated: [docs/VALIDATION.md](docs/VALIDATION.md). + +## Live interactive world model + +```bash +python -m astraeus serve --port 8000 +``` + +- **`/`** - the world model. A WebGL view of the *exact* height field the simulator used, rocks, craters, + goal ring, true path and VO estimate. Eight live sliders for `x`; moving one re-simulates the episode + in 0.1-0.6 s. Prior selector, elite-failure table, renderer frames from five cameras, contact sheets, + campaign runner and CEM launcher with progress over WebSocket, named save/load. +- **`/internals`** - the engine room: importance weights, ESS per prior, support coverage, weighted + marginals, per-dimension sensitivity, shift matrix and CEM traces. +- **REST + WebSocket API** - `/api/priors`, `/api/campaign`, `/api/campaign/run`, `/api/episodes`, + `/api/episode/{i}`, `/api/episode/{i}/frame.png`, `/api/episode/{i}/sheet.png`, `/api/simulate`, + `/api/whatif/frame.png`, `/api/sensitivity`, `/api/internals`, `/api/cem`, `/api/save`, `/api/load`, + `/ws`. Payloads in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +Nothing is re-generated on the client: the browser shows the terrain the physics ran on. + +## Renderer + +`astraeus/render.py` is a NumPy software renderer with no GPU dependency. It ray-marches the height +field, shades with the true sun vector, casts terrain shadows, applies dust scattering, adds +multi-octave regolith texture and horizon-based ambient occlusion, darkens the wheel ruts along the +recorded path, draws the rover from its recorded attitude with MLI, solar-cell and tread detail, +overlays true/estimated paths and the goal ring, and tone-maps with a filmic curve. Cameras: +`chase`, `rover_cam`, `overview`, `orbit`, `cinematic`. Two overlays: the technical HUD (full +telemetry) and the clean lower-third caption used in this README. + +```bash +python -m astraeus render runs/c1.npz --episode 111 --camera cinematic --quality 3 # HUD still +python scripts/make_showcase.py runs/c1.npz --hero-episode 111 # README assets +python scripts/make_renders.py runs/c1.npz --prior P1 --quality 3 # HUD gallery + contact sheet +``` + +A contact sheet of one stuck episode with the technical HUD (`--sheet`): four cameras plus the full +disturbance vector and telemetry. + +Contact sheet: four cameras and HUD telemetry for one stuck episode ## Command line @@ -125,57 +239,23 @@ python -m astraeus serve --port 8000 python -m astraeus selftest ``` -`ingest --source csv` (default) trusts each row's `source` column (`mc`/`cem`/`external` or `0/1/2`); -rows without one are treated as external. Only proposal rows (`source=0`) enter importance-weighted -estimates; CEM and external rows are kept for replay, ranking and cross-simulator comparison. - -## Live interactive world model - -`python -m astraeus serve` starts a FastAPI server with: - -- **`/`** — the world model. A WebGL view of the exact height field the simulator used, rocks, craters, - goal ring, true path and VO estimate; eight live sliders for `x` (moving one re-simulates the episode - in 0.1–0.6 s); prior selector; elite-failure table; renderer frames from four cameras; campaign runner - and CEM launcher with progress over WebSocket. -- **`/internals`** — the engine room: importance weights, ESS per prior, support coverage, weighted - marginals, dimension sensitivity, shift matrix and CEM traces. -- **REST + WebSocket API** — `/api/priors`, `/api/campaign`, `/api/campaign/run`, `/api/episodes`, - `/api/episode/{i}`, `/api/episode/{i}/frame.png`, `/api/episode/{i}/sheet.png`, `/api/simulate`, - `/api/sensitivity`, `/api/internals`, `/api/cem`, `/api/save`, `/api/load`, `/ws`. - Route list and payloads: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - -The browser shows the *same* terrain the physics ran on; nothing is re-generated on the client. - -## High-quality renders - -The software renderer in `astraeus/render.py` needs no GPU. It ray-marches the height field, shades -with the true sun vector, applies dust scattering and low-sun shadowing, draws the rover from its -recorded attitude, overlays the true/estimated paths and goal ring, and prints the full disturbance -vector and telemetry as a HUD. Cameras: `chase`, `rover_cam`, `overview`, `orbit`. `--quality 3` -supersamples for publication figures. - -| | | -|---|---| -| ![tip over](docs/img/tip_over_chase.png) | ![collision](docs/img/collision_rover_cam.png) | -| ![nav miss](docs/img/nav_miss_overview.png) | ![success](docs/img/success_orbit.png) | - -A contact sheet of one stuck episode (`--sheet`): - -![contact sheet](docs/img/stuck_sheet.png) +`ingest --source csv` (default) trusts each row's `source` column (`mc` / `cem` / `external` or `0/1/2`); +rows without one are treated as external. Only proposal rows enter importance-weighted estimates; CEM +and external rows are kept for replay, ranking and cross-simulator comparison. ## NVIDIA Isaac Sim, OmniLRS and ROS 2 `isaac/` contains the physics back-end scripts. They compile and their helper logic is unit-tested -without Isaac Sim installed; the Isaac/ROS runtime calls themselves are **unverified in this -repository** (see the status box in [docs/ISAAC_SIM.md](docs/ISAAC_SIM.md)). +without Isaac Sim installed. The Isaac/ROS runtime calls themselves are **unverified in this +repository** - see the status box in [docs/ISAAC_SIM.md](docs/ISAAC_SIM.md) before relying on them. ```bash -# Standalone Isaac Sim (Isaac's python.sh): builds terrain, rocks, craters, sun, dust and a rover -# from each x drawn from q, drives it, monitors the outcome, logs Astraeus-compatible CSV. +# Standalone Isaac Sim (run with Isaac's python.sh): builds terrain, rocks, craters, sun, dust and a +# rover from each x drawn from q, drives it, monitors the outcome, logs Astraeus-compatible CSV. ./python.sh isaac/standalone_env.py --episodes 200 --mode proposal --out isaac/out/log.csv # OmniLRS via ROS 2: pushes sun / terrain / teleport commands, listens to pose, logs the same CSV. -python isaac/discover_topics.py # verify topic names + types against a live sim +python isaac/discover_topics.py # verify topic names + types against a live sim first python isaac/omnilrs_bridge.py --episodes 50 --mode proposal # Load-time parameters (k_soil, theta_r) need one launch per stratum: @@ -192,76 +272,13 @@ against its local twin. ## Reactor world model (optional) The default frame provider is the local renderer. `ASTRAEUS_PROVIDER=reactor` plus `REACTOR_API_KEY` -enables streaming frames from a Reactor (reactor.inc) world model through `reactor-sdk`, prompted from -the eight parameters and rover state and optionally conditioned on the local render. It **never -replaces the local frame silently**: when a live frame is unavailable the UI shows the local render -tagged `live: false` with the reason. No credentials are needed for anything else in the project. -`python -m astraeus.providers --probe` prints the model's request schema. - -## Validation evidence - -Numbers from the 4000-episode proposal campaign (+2400 CEM rows) that produced the images above, -`gt_pose=False`, 13.8 s of simulation time: - -| Prior | P(fail) | 95 % CI | ESS / 4000 | Top mode | -|------:|--------:|--------:|-----------:|----------| -| P1 | 0.550 | 0.530 – 0.573 | 1794 | stuck | -| P2 | 0.331 | 0.294 – 0.364 | 687 | stuck | -| P3 | — | — | 1 | *not estimable: 100 % outside support* | -| P4 | 0.084 | 0.064 – 0.108 | 583 | stuck | -| P5 | 0.665 | 0.629 – 0.697 | 675 | stuck | -| P6 | 0.379 | 0.347 – 0.417 | 667 | **nav_miss** | - -The failure-mode ranking shifts across priors (P6's dust moves the dominant mode from mobility to -navigation). Test suite: 30 pytest cases (19 simulator/campaign/render invariants + 11 Isaac helper -tests), `ruff` clean. Details, procedure and what was *not* validated: [docs/VALIDATION.md](docs/VALIDATION.md). - -## Assumptions, known flaws and limitations - -Read this before quoting a number. - -- **It is a reduced-order simulator.** Terramechanics is Bekker/Wong-Reece style with empirical slip - coefficients, not DEM soil; the rover is a rigid body with kinematic four-wheel contact; the controller - is a proportional heading controller with reactive rock repulsion. Absolute failure rates are properties of - *this* rover-in-*this*-simulator; the framework's value is the comparison across priors and the - replayable failure mechanisms. -- **Priors P2/P4/P5/P6 are engineering judgements**, documented in `astraeus/priors.py`, not - calibrated mission data. Change them and re-run; the campaign does not need re-simulation. -- **P3 is not estimable by design.** Importance weights cannot recover mass the proposal never - samples. Widening `q` to cover 45° sun would make P3 estimable but would also spend samples on - lighting no polar mission will see. -- **ESS is a necessary, not sufficient, diagnostic.** A prior with ESS 600/4000 has real variance - in its estimate; the bootstrap CI is the honest width, and it assumes the proposal covers the prior. -- **Perception is a model, not a pipeline.** Visibility, detection probability and VO drift are - parametric functions of sun elevation, dust and contrast; no images are processed. -- **Isaac Sim rigid PhysX does not reproduce sinkage.** Without a terramechanics plugin, Isaac - episodes show collision, tip-over and navigation failures but not Bekker entrapment; the bridge - exposes a hook and documents the gap. -- **ROS 2 topic names are unverified** (`verified: false` in `isaac/configs/ros2_topics.yaml`) until - `discover_topics.py` has been run against a live OmniLRS. -- **The Reactor provider was written against the reactor-sdk 1.5 surface** and probed for schema at - runtime; no frames were generated in this repository's validation run because no key was used. -- **Rendering is illustrative.** The software renderer is physically motivated (true sun vector, - shadowing, dust extinction) but not radiometrically calibrated; treat frames as diagnostic views. - -## Documentation - -| Document | Contents | -|----------|----------| -| [docs/PARAMETERS.md](docs/PARAMETERS.md) | the eight dimensions, bounds, priors, proposal, weights, ESS, gates, outcomes, rover constants | -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | package layout, data flow, provenance, determinism, HTTP/WebSocket API | -| [docs/ISAAC_SIM.md](docs/ISAAC_SIM.md) | Isaac Sim / OmniLRS / ROS 2 integration, batch semantics, CSV schema, gaps | -| [docs/VALIDATION.md](docs/VALIDATION.md) | what was run, the numbers, what was not validated | -| [docs/RESEARCH.md](docs/RESEARCH.md) | AST and related literature; **next phase — the author's research programme** | - -## Research - -Astraeus builds on adaptive stress testing (Lee et al.), importance-sampling reliability estimation, -cross-entropy rare-event search, and terramechanics (Bekker, Wong & Reece). The bibliography, how each -result is used, and the clearly labelled next-phase research programme of the repository author are in -[docs/RESEARCH.md](docs/RESEARCH.md). +enables streaming frames from a Reactor ([reactor.inc](https://reactor.inc)) world model through +`reactor-sdk`, prompted from the eight parameters and rover state and optionally conditioned on the +local render. It **never replaces the local frame silently**: when a live frame is unavailable the UI +shows the local render tagged `live: false` with the reason. No credentials are needed for anything +else in the project. `python -m astraeus.providers --probe` prints the model's request schema. -## Repository layout +## Architecture ``` astraeus/ simulator + campaign engine + renderer + web app (pure Python) @@ -271,33 +288,87 @@ astraeus/ simulator + campaign engine + renderer + web app (pure Pyth perception.py visibility, detection, VO drift as functions of sun and dust rover.py vectorised episode loop, controller, outcomes, traces campaign.py MC chunks, weights, bootstrap CI, ESS, shift analysis, CEM, save/load, ingest - render.py software renderer, 4 cameras, HUD, contact sheets + render.py software renderer, 5 cameras, HUD + caption overlays, contact sheets providers.py LocalProvider / ReactorProvider (opt-in) app.py FastAPI + WebSocket server static/ WebGL world model UI + internals page cli.py, selftest.py isaac/ Isaac Sim standalone env, OmniLRS ROS 2 bridge, topic verifier, terrain export - astraeus_isaac/ Isaac-free helper package (scene mapping, monitor, CSV log, export) — unit-tested + astraeus_isaac/ Isaac-free helper package (scene mapping, monitor, CSV log, export) - unit-tested configs/ lunar_scene.yaml, ros2_topics.yaml (verified: false), campaign.yaml -scripts/ make_renders.py (regenerates docs/img from a saved run) +scripts/ make_showcase.py (README assets), make_renders.py (HUD renders + contact sheet) tests/ pytest suite legacy/ v1 OmniLRS harness (3-prior, 5-dim) kept for provenance -docs/ documentation and validation renders +docs/ PARAMETERS, ARCHITECTURE, ISAAC_SIM, VALIDATION, RESEARCH + renders ``` +Data flow, provenance rules, determinism contract and the HTTP/WebSocket schema: +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Assumptions, known flaws and limitations + +Read this before quoting a number. + +- **It is a reduced-order simulator.** Terramechanics is Bekker / Wong-Reece style with empirical slip + coefficients, not DEM soil; the rover is a rigid body with kinematic four-wheel contact; the controller + is a proportional heading controller with reactive rock repulsion. Absolute failure rates are + properties of *this* rover in *this* simulator. The framework's value is the comparison across priors + and the replayable failure mechanisms. +- **Priors P2 / P4 / P5 / P6 are engineering judgements**, documented in `astraeus/priors.py`, not + calibrated mission data. Change them and re-run the report; the campaign does not need re-simulation. +- **P3 is not estimable by design.** Importance weights cannot recover mass the proposal never + samples. Widening `q` to cover 45° sun would make P3 estimable but would spend samples on lighting + no polar mission will see. +- **ESS is necessary, not sufficient.** A prior with ESS 600 / 4000 has real variance in its estimate; + the bootstrap CI is the honest width, and it assumes the proposal covers the prior. +- **Perception is a model, not a pipeline.** Visibility, detection probability and VO drift are + parametric functions of sun elevation, dust and contrast; no images are processed. +- **Isaac Sim rigid PhysX does not reproduce sinkage.** Without a terramechanics plugin, Isaac + episodes show collision, tip-over and navigation failures but not Bekker entrapment; the bridge + exposes a hook and documents the gap. +- **ROS 2 topic names are unverified** (`verified: false` in `isaac/configs/ros2_topics.yaml`) until + `discover_topics.py` has been run against a live OmniLRS. +- **The Reactor provider was written against the reactor-sdk 1.5 surface** and probed for schema at + runtime; no frames were generated in this repository's validation because no key was used. +- **Rendering is illustrative.** The renderer is physically motivated (true sun vector, shadowing, dust + extinction) but not radiometrically calibrated; frames are diagnostic views, not sensor simulation. + +## Research + +Astraeus builds on adaptive stress testing (Lee et al.), black-box safety validation, importance-sampling +reliability estimation with defensive mixtures and the balance heuristic, cross-entropy rare-event +search, Bekker / Wong-Reece terramechanics, the Spirit "Troy" entrapment record, lunar regolith and +south-pole illumination studies, and the OmniLRS lunar simulator. + +[docs/RESEARCH.md](docs/RESEARCH.md) has the bibliography and how each result is used, followed by +**Part B - the author's next-phase research programme**, clearly labelled as planned work with no +claimed results. + ## Development ```bash +python -m pip install -r requirements-dev.txt +make check # ruff + pytest + selftest (what CI runs) +make lint # ruff check make test # pytest make selftest # python -m astraeus selftest -make lint # ruff make serve # uvicorn astraeus.app:app --port 8000 ``` -Contributions that add calibrated priors, a terramechanics plugin for Isaac, verified OmniLRS topic -maps or a real perception pipeline are the most valuable. Keep estimates honest: never widen a prior -or clip a parameter to make a number look better. +CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs ruff, `compileall`, pytest, the +selftest and a server smoke test on Python 3.10 and 3.12. Contribution rules, including the +determinism and provenance contracts, are in [CONTRIBUTING.md](CONTRIBUTING.md). + +## Citing -## License +```bibtex +@software{astraeus2026, + title = {Astraeus: adversarial stress testing of off-world rover autonomy under unknown disturbance priors}, + author = {Vinujan, S.}, + year = {2026}, + url = {https://github.com/vineigenphase/Non-Trivial---Astraeus}, + version = {2.0.0} +} +``` -MIT — see [LICENSE](LICENSE). +Machine-readable metadata: [CITATION.cff](CITATION.cff). License: [MIT](LICENSE). diff --git a/astraeus/app.py b/astraeus/app.py index dd5ca66..0e5501d 100644 --- a/astraeus/app.py +++ b/astraeus/app.py @@ -210,6 +210,12 @@ def _prior(name: str) -> PR.Prior: return PR.PRIORS[name] +def _camera(name: str) -> str: + if name not in R.CAMERAS: + raise HTTPException(400, f"unknown camera {name!r}; choose from {list(R.CAMERAS)}") + return name + + def _not_busy() -> None: if STATE.busy: raise HTTPException(409, f"engine busy: {STATE.busy}") @@ -323,6 +329,7 @@ def _png(im) -> Response: @app.get("/api/episode/{i}/frame.png") async def api_frame(i: int, camera: str = "chase", frame: int = -1, w: int = 960, h: int = 540, q: int = 1, hud: int = 1) -> Response: + camera = _camera(camera) res = await asyncio.to_thread(STATE.replay, i) w, h, q = min(max(w, 160), 1920), min(max(h, 90), 1080), 1 if q < 2 else 2 im = await asyncio.to_thread(R.render_episode, res, camera, frame, (w, h), q, bool(hud)) @@ -333,6 +340,7 @@ async def api_frame(i: int, camera: str = "chase", frame: int = -1, w: int = 960 async def api_frame_json(i: int, camera: str = "chase", frame: int = -1, w: int = 960, h: int = 540, q: int = 1) -> JSONResponse: """Provider-routed frame (local or Reactor) with provenance metadata.""" + camera = _camera(camera) res = await asyncio.to_thread(STATE.replay, i) fr = await asyncio.to_thread(STATE.provider.render, res, frame, camera, (w, h), 1 if q < 2 else 2) return JSONResponse(fr.to_dict()) @@ -340,6 +348,7 @@ async def api_frame_json(i: int, camera: str = "chase", frame: int = -1, w: int @app.get("/api/episode/{i}/sheet.png") async def api_sheet(i: int, n: int = 6, camera: str = "chase") -> Response: + camera = _camera(camera) res = await asyncio.to_thread(STATE.replay, i) im = await asyncio.to_thread(R.render_contact_sheet, res, n, (640, 360), camera) return _png(im) @@ -383,6 +392,7 @@ async def api_whatif_frame(k_soil: float, theta_r: float, r_terrain: float, rho_ sun_e: float, sun_psi: float, tau_dust: float, seed: int = 0, camera: str = "chase", frame: int = -1, w: int = 960, h: int = 540, q: int = 1, hud: int = 1) -> Response: """GET form of the what-if render so can address it directly.""" + camera = _camera(camera) x = _whatif_x(k_soil, theta_r, r_terrain, rho_rock, slope_deg, sun_e, sun_psi, tau_dust) res = await asyncio.to_thread(RV.simulate_one, x, seed, False) w, h = min(max(w, 160), 1920), min(max(h, 90), 1080) @@ -394,6 +404,7 @@ async def api_whatif_frame(k_soil: float, theta_r: float, r_terrain: float, rho_ async def api_whatif_sheet(k_soil: float, theta_r: float, r_terrain: float, rho_rock: float, slope_deg: float, sun_e: float, sun_psi: float, tau_dust: float, seed: int = 0, camera: str = "chase", n: int = 6) -> Response: + camera = _camera(camera) x = _whatif_x(k_soil, theta_r, r_terrain, rho_rock, slope_deg, sun_e, sun_psi, tau_dust) res = await asyncio.to_thread(RV.simulate_one, x, seed, False) im = await asyncio.to_thread(R.render_contact_sheet, res, min(max(n, 2), 12), (640, 360), camera) diff --git a/astraeus/render.py b/astraeus/render.py index d05aa76..6880077 100644 --- a/astraeus/render.py +++ b/astraeus/render.py @@ -33,7 +33,7 @@ from . import perception as P from . import rover as RV from .priors import DIMS, UNITS -from .terrain import GOAL_X +from .terrain import GOAL_X, value_noise # --------------------------------------------------------------------------- setup DEM_RES = 0.20 # m per DEM cell for ray marching @@ -157,6 +157,12 @@ def camera_rays(scene: Scene, mode: str, w: int, h: int, fov_deg: float = 60.0): eye = np.array([px, py, pz + WHEEL_R + 2.12]) + 0.95 * fwd look = eye + 8.0 * fwd - np.array([0, 0, 1.4]) fov_deg = 2 * P.SENSOR_HALF_FOV_DEG * 0.85 + elif mode == "cinematic": + # low three-quarter front view; the sun rakes across the frame from the side + side = np.array([-fwd[1], fwd[0], 0.0]) + eye = np.array([px, py, pz]) + 5.6 * fwd + 4.2 * side + np.array([0, 0, 1.9]) + look = np.array([px, py, pz + 0.55]) - 2.0 * fwd - 0.6 * side + fov_deg = 44.0 elif mode == "overview": eye = np.array([GOAL_X * 0.5 - 4.0, -24.0, 14.0]) look = np.array([GOAL_X * 0.5, 0.0, 0.0]) @@ -368,12 +374,65 @@ def _headlights(scene: Scene, p: np.ndarray, n: np.ndarray, d: np.ndarray) -> np def _stars(d: np.ndarray, seed: int) -> np.ndarray: - """Sparse deterministic star field on the sky directions.""" - q = np.floor(d * 420.0).astype(np.int64) + """Sparse deterministic star field on the sky directions (two magnitude classes).""" s = np.int64((int(seed) * 2654435761) & 0x7FFFFFFFFFFFFFFF) - hsh = (q[..., 0] * 73856093) ^ (q[..., 1] * 19349663) ^ (q[..., 2] * 83492791) ^ s - u = ((hsh & 0xFFFFFF) / float(0xFFFFFF)) - return np.where(u > 0.9994, 0.3 + 1.2 * (u - 0.9994) / 0.0006, 0.0) + out = np.zeros(d.shape[0]) + for scale, thresh, gain in ((420.0, 0.9994, 1.2), (900.0, 0.9990, 0.35)): + q = np.floor(d * scale).astype(np.int64) + hsh = (q[..., 0] * 73856093) ^ (q[..., 1] * 19349663) ^ (q[..., 2] * 83492791) ^ s + u = ((hsh & 0xFFFFFF) / float(0xFFFFFF)) + out += np.where(u > thresh, 0.15 + gain * (u - thresh) / (1 - thresh), 0.0) + return out + + +def _regolith_detail(xy: np.ndarray, seed: int, fade: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Multi-octave regolith texture: (albedo factor, height-detail gradient (N, 2)). + + Octaves are faded with distance so the fine grain does not alias far away.""" + # (spatial frequency 1/m, height amplitude m): metre-scale undulation down to cm-scale grain + octaves = ((0.35, 0.060), (1.6, 0.020), (6.5, 0.0055), (23.0, 0.0012)) + + def field(x: np.ndarray, y: np.ndarray) -> np.ndarray: + acc = np.zeros_like(x) + for k, (f, amp) in enumerate(octaves): + sd = np.int64((seed * 31 + 7 * k) & 0x7FFFFFFF) + acc += amp * value_noise(x * f, y * f, sd) * fade ** k + return acc + + h0 = field(xy[:, 0], xy[:, 1]) + e = 0.02 + gx = (field(xy[:, 0] + e, xy[:, 1]) - h0) / e + gy = (field(xy[:, 0], xy[:, 1] + e) - h0) / e + albedo = 1.0 + 2.2 * h0 / octaves[0][1] * 0.08 + return albedo, np.stack([gx, gy], 1) + + +def _ambient_occlusion(scene: Scene, p: np.ndarray) -> np.ndarray: + """Horizon-based occlusion of the sky hemisphere in [0, 1] (1 = fully open).""" + occ = np.zeros(len(p)) + n_dir = 6 + for r in (0.7, 1.8, 4.5): + for k in range(n_dir): + a = 2 * math.pi * k / n_dir + 0.3 * r + hgt = scene.height(p[:, 0] + r * math.cos(a), p[:, 1] + r * math.sin(a)) + hgt = np.where(np.isfinite(hgt), hgt, p[:, 2]) + occ += np.clip((hgt - p[:, 2]) / r, 0.0, 1.0) + return 1.0 - 0.75 * occ / (3 * n_dir) + + +def _offset_path(path: np.ndarray, offset: float) -> np.ndarray: + """Polyline shifted sideways by `offset` (left positive) - one wheel track.""" + if len(path) < 2: + return path + d = np.gradient(path, axis=0) + d /= np.maximum(np.linalg.norm(d, axis=1, keepdims=True), 1e-9) + perp = np.stack([-d[:, 1], d[:, 0]], 1) + return path + offset * perp + + +def _filmic(x: np.ndarray) -> np.ndarray: + """ACES-style filmic tone curve (Narkowicz fit), keeps highlights from greying out.""" + return np.clip((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0) def _path_mask(hit_xy: np.ndarray, path: np.ndarray, width: float, dashed: bool = False) -> np.ndarray: @@ -412,10 +471,15 @@ def _path_mask(hit_xy: np.ndarray, path: np.ndarray, width: float, dashed: bool def render_frame(scene: Scene, camera: str = "chase", size: Tuple[int, int] = (1280, 720), - hud: bool = True, quality: int = 1) -> Image.Image: - """Render one frame. quality=1 full, 2 renders at half resolution and upsamples.""" + hud: bool = True, quality: int = 1, supersample: int = 1) -> Image.Image: + """Render one frame. + + quality=1 full, 2 renders at half resolution and upsamples (interactive use). + supersample=k renders at k x k the pixel count and downsamples with Lanczos + (anti-aliased stills for documentation; cost grows as k^2).""" W, H = size - w, h = W // quality, H // quality + ss = max(1, int(supersample)) + w, h = W * ss // quality, H * ss // quality o, D = camera_rays(scene, camera, w, h) d = D.reshape(-1, 3) N = d.shape[0] @@ -430,10 +494,23 @@ def render_frame(scene: Scene, camera: str = "chase", size: Tuple[int, int] = (1 hit = hit_rov | hit_ter p = o[None, :] + np.where(np.isfinite(t), t, 0.0)[:, None] * d - # normals + # normals (+ regolith micro-relief bump on the terrain, faded with distance) n = np.zeros_like(d) n[hit_ter] = scene.normal(p[hit_ter, 0], p[hit_ter, 1]) n[hit_rov] = n_rov[hit_rov] + grain = np.ones(N) + if hit_ter.any(): + fade = np.exp(-t[hit_ter] / 45.0) + alb, grad = _regolith_detail(p[hit_ter, :2], scene.seed, fade) + grain[hit_ter] = alb + bump = 0.9 * fade[:, None] * grad + nt = n[hit_ter].copy() + nt[:, 0] -= bump[:, 0] + nt[:, 1] -= bump[:, 1] + n[hit_ter] = nt / np.linalg.norm(nt, axis=1, keepdims=True) + ao = _ambient_occlusion(scene, p[hit_ter]) + else: + ao = np.ones(0) # shadows for every hit point (rover included: it sits in terrain shadow too) sh = np.ones(N) if hit.any(): @@ -444,10 +521,8 @@ def render_frame(scene: Scene, camera: str = "chase", size: Tuple[int, int] = (1 phase = np.arccos(np.clip((-d) @ sun, -1.0, 1.0)) col = np.zeros((N, 3)) - # regolith — slight per-pixel albedo variation from the height field detail reg = _brdf_regolith(mu0, mu, phase) * sh tint = np.array([1.0, 0.97, 0.92]) - grain = 0.9 + 0.2 * np.mod(np.abs(p[:, 0] * 7.31 + p[:, 1] * 3.17), 1.0) col[hit_ter] = (reg[hit_ter] * grain[hit_ter])[:, None] * tint[None, :] # driving lights on the terrain (rover surfaces are excluded: no self-illumination) @@ -457,23 +532,44 @@ def render_frame(scene: Scene, camera: str = "chase", size: Tuple[int, int] = (1 # rover materials: 1 body (white/gold MLI), 2 structure (dark), 3 solar (blue-black), 4 wheels mats = {1: np.array([0.85, 0.80, 0.62]), 2: np.array([0.22, 0.22, 0.24]), 3: np.array([0.10, 0.12, 0.28]), 4: np.array([0.30, 0.30, 0.30])} - for m_id, albedo in mats.items(): - sel = hit_rov & (mat == m_id) - if sel.any(): - diff = albedo[None, :] * (mu0[sel] * sh[sel])[:, None] - hvec = sun[None, :] - d[sel] - hvec /= np.linalg.norm(hvec, axis=1, keepdims=True) - spec = np.clip((n[sel] * hvec).sum(1), 0, 1) ** (60 if m_id == 3 else 18) * sh[sel] - col[sel] = diff + (0.6 if m_id in (1, 3) else 0.15) * spec[:, None] - # ambient: earthshine / multiple scattering is tiny on the Moon; dust adds some - amb = 0.012 + 0.10 * tau - col[hit] += amb * np.where(hit_rov[hit, None], 0.6, ALBEDO * 2.5) * tint[None, :] - - # painted ground overlays: true path (cyan), VO estimate (amber, dashed), goal disc + if hit_rov.any(): + Rr = _rot(*scene.rover_att) + pl = (p[hit_rov] - (scene.rover_pos + np.array([0, 0, WHEEL_R]))[None, :]) @ Rr + tex = np.ones(N) + m_rov = mat[hit_rov] + # MLI blanket seams on the body, cell grid on the solar array, tread on the wheels + seam = (np.mod(pl[:, 2] + 0.02, 0.11) < 0.010) | (np.mod(pl[:, 1] + 0.05, 0.31) < 0.012) + cell = (np.mod(pl[:, 0] + 0.03, 0.16) < 0.012) | (np.mod(pl[:, 1] + 0.03, 0.16) < 0.012) + ang_w = np.arctan2(pl[:, 2], pl[:, 0] - np.round(pl[:, 0] / HALF_LEN) * HALF_LEN) + tread = np.mod(ang_w, 2 * math.pi / 24) < (2 * math.pi / 24) * 0.35 + tex_r = np.where(m_rov == 1, np.where(seam, 0.72, 1.0), 1.0) + tex_r = np.where(m_rov == 3, np.where(cell, 2.6, 1.0), tex_r) + tex_r = np.where(m_rov == 4, np.where(tread, 0.7, 1.0), tex_r) + tex[hit_rov] = tex_r + for m_id, albedo in mats.items(): + sel = hit_rov & (mat == m_id) + if sel.any(): + diff = albedo[None, :] * (mu0[sel] * sh[sel] * tex[sel])[:, None] + hvec = sun[None, :] - d[sel] + hvec /= np.linalg.norm(hvec, axis=1, keepdims=True) + spec = np.clip((n[sel] * hvec).sum(1), 0, 1) ** (60 if m_id == 3 else 18) * sh[sel] + col[sel] = diff + (0.6 if m_id in (1, 3) else 0.15) * spec[:, None] + # ambient: earthshine (faintly blue) plus dust multiple scattering; occluded in hollows + amb = 0.022 + 0.10 * tau + amb_col = np.array([0.78, 0.86, 1.0]) * (1 - min(tau, 1.0) * 0.5) + tint * min(tau, 1.0) * 0.5 + amb_scale = np.full(N, 0.6) + amb_scale[hit_ter] = ALBEDO * 2.5 * ao * grain[hit_ter] + col[hit] += amb * amb_scale[hit, None] * amb_col[None, :] + + # wheel ruts (compacted, shadowed regolith) and painted overlays: true path (cyan), + # VO estimate (amber, dashed), goal ring if hit_ter.any(): hxy = p[hit_ter, :2] - m_true = _path_mask(hxy, scene.path_true, 0.24) - m_est = _path_mask(hxy, scene.path_est, 0.16, dashed=True) + rut = np.maximum(_path_mask(hxy, _offset_path(scene.path_true, HALF_WID), WHEEL_W * 0.55), + _path_mask(hxy, _offset_path(scene.path_true, -HALF_WID), WHEEL_W * 0.55)) + col[hit_ter] *= (1.0 - 0.45 * rut)[:, None] + m_true = _path_mask(hxy, scene.path_true, 0.12) + m_est = _path_mask(hxy, scene.path_est, 0.09, dashed=True) gdist = np.linalg.norm(hxy - np.array([GOAL_X, 0.0]), axis=1) ring = np.clip(1.0 - np.abs(gdist - RV.GOAL_TOL) / 0.12, 0.0, 1.0) base = col[hit_ter] @@ -523,15 +619,19 @@ def render_frame(scene: Scene, camera: str = "chase", size: Tuple[int, int] = (1 g = (0.12 * np.exp(-ang / 0.30) + 0.02 * np.exp(-ang / 0.6)) * np.clip(1 - ang / math.radians(40), 0, 1) col += (g * (0.5 + tau))[:, None] * np.array([1.0, 0.9, 0.75]) - # tone map + gamma: exposure set for a sunlit surface at this elevation - exposure = 1.6 / max(0.35, math.sqrt(max(sun[2], 0.02)) * 2.2) - col = col * exposure - col = col / (1.0 + col) # Reinhard + # tone map + gamma: exposure set for a sunlit surface at this elevation, filmic curve, + # gentle optical vignette + exposure = 1.15 / max(0.35, math.sqrt(max(sun[2], 0.02)) * 2.2) + col = _filmic(col * exposure) + vx = (np.arange(w) + 0.5) / w * 2 - 1 + vy = (np.arange(h) + 0.5) / h * 2 - 1 + r2 = (vx[None, :] ** 2 + vy[:, None] ** 2).reshape(-1) + col *= (1.0 - 0.18 * r2 * r2)[:, None] col = np.clip(col, 0, 1) ** (1 / 2.2) - img = (col.reshape(h, w, 3) * 255).astype(np.uint8) + img = (col.reshape(h, w, 3) * 255 + 0.5).astype(np.uint8) im = Image.fromarray(img, "RGB") - if quality != 1: - im = im.resize((W, H), Image.BICUBIC) + if (w, h) != (W, H): + im = im.resize((W, H), Image.LANCZOS if ss > 1 else Image.BICUBIC) if hud: _draw_hud(im, scene, camera) return im @@ -588,9 +688,14 @@ def _draw_hud(im: Image.Image, s: Scene, camera: str) -> None: oc = {"success": (90, 230, 120), "driving": (200, 205, 215), "stuck": (255, 150, 60), "tip_over": (255, 80, 80), "collision": (255, 90, 140), "nav_miss": (255, 210, 70), "timeout": (170, 170, 190)}[s.outcome] - draw.text((pad, H - bar_h + bar_h // 2 - f_big.size // 2), f"ASTRAEUS · {s.outcome.upper()}", - font=f_big, fill=oc + (255,)) - foot = f"seed {s.seed} frame {s.frame + 1}/{s.n_frames} cam {camera} software render (synthetic)" + title = f"ASTRAEUS · {s.outcome.upper()}" + draw.text((pad, H - bar_h + bar_h // 2 - f_big.size // 2), title, font=f_big, fill=oc + (255,)) + # footer fields drop from the right until the line fits beside the title (narrow frames) + fields = [f"seed {s.seed}", f"frame {s.frame + 1}/{s.n_frames}", f"cam {camera}", "software render (synthetic)"] + avail = W - 3 * pad - draw.textlength(title, font=f_big) + while len(fields) > 1 and draw.textlength(" ".join(fields), font=f_small) > avail: + fields.pop() + foot = " ".join(fields) tw2 = draw.textlength(foot, font=f_small) draw.text((W - pad - tw2, H - bar_h + bar_h // 2 - f_small.size // 2), foot, font=f_small, fill=(170, 178, 192, 255)) @@ -604,8 +709,43 @@ def _draw_hud(im: Image.Image, s: Scene, camera: str) -> None: ly += lh +def draw_caption(im: Image.Image, s: Scene, title: str = "ASTRAEUS", subtitle: str = "") -> None: + """Minimal lower-third for documentation stills: wordmark, outcome, and the + disturbance vector on one line over a soft gradient. Use instead of the HUD.""" + W, H = im.size + band = int(H * 0.26) + grad = Image.new("L", (1, band)) + for y in range(band): + grad.putpixel((0, y), int(225 * (y / band) ** 1.4)) + grad = grad.resize((W, band)) + dark = Image.new("RGBA", (W, band), (3, 4, 7, 255)) + dark.putalpha(grad) + im.paste(dark, (0, H - band), dark) + draw = ImageDraw.Draw(im, "RGBA") + f_title = _font(False, max(18, H // 22)) + f_sub = _font(False, max(12, H // 46)) + f_mono = _font(True, max(11, H // 58)) + pad = W // 40 + y0 = H - band + int(band * 0.34) + oc = {"success": (90, 230, 120), "driving": (200, 205, 215), "stuck": (255, 150, 60), + "tip_over": (255, 80, 80), "collision": (255, 90, 140), "nav_miss": (255, 210, 70), + "timeout": (170, 170, 190)}[s.outcome] + draw.text((pad, y0), title, font=f_title, fill=(240, 242, 246, 255)) + tw = draw.textlength(title, font=f_title) + draw.text((pad + tw + f_title.size * 0.6, y0 + f_title.size * 0.18), s.outcome.replace("_", " ").upper(), + font=_font(False, max(14, H // 30)), fill=oc + (255,)) + if subtitle: + draw.text((pad, y0 + f_title.size * 1.25), subtitle, font=f_sub, fill=(190, 198, 212, 255)) + xs = " ".join(f"{_LABELS[d]} {s.params[d]:.3g}" for d in DIMS) + xw = draw.textlength(xs, font=f_mono) + draw.text((W - pad - xw, H - pad - f_mono.size * 1.1), xs, font=f_mono, fill=(160, 170, 188, 255)) + foot = f"seed {s.seed} · t = {s.t:.1f} s · deterministic software render" + draw.text((W - pad - draw.textlength(foot, font=f_mono), H - pad - f_mono.size * 2.5), foot, + font=f_mono, fill=(120, 130, 150, 255)) + + # --------------------------------------------------------------------------- helpers -CAMERAS = ("chase", "rover_cam", "overview", "orbit") +CAMERAS = ("chase", "rover_cam", "overview", "orbit", "cinematic") def to_png_bytes(im: Image.Image) -> bytes: @@ -615,8 +755,10 @@ def to_png_bytes(im: Image.Image) -> bytes: def render_episode(res: RV.EpisodeResult, camera: str = "chase", frame: int = -1, - size: Tuple[int, int] = (1280, 720), quality: int = 1, hud: bool = True) -> Image.Image: - return render_frame(Scene.from_result(res, frame), camera, size, hud=hud, quality=quality) + size: Tuple[int, int] = (1280, 720), quality: int = 1, hud: bool = True, + supersample: int = 1) -> Image.Image: + return render_frame(Scene.from_result(res, frame), camera, size, hud=hud, quality=quality, + supersample=supersample) def render_contact_sheet(res: RV.EpisodeResult, n: int = 6, size: Tuple[int, int] = (640, 360), diff --git a/astraeus/static/index.html b/astraeus/static/index.html index 790f7a5..aedb9ac 100644 --- a/astraeus/static/index.html +++ b/astraeus/static/index.html @@ -98,7 +98,7 @@

Rendered frame

rendered frame
- + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b8a7a5d..1779121 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ core only through a CSV schema. | `perception.py` | Composite visibility `V ∈ [0,1]` from self-shadowing (`sun_e`, `r_terrain`), glare (`sun_psi` vs. heading, `sun_e`) and dust (`tau_dust`); rock detection rate and visual-odometry drift as functions of `V`. | numpy | | `rover.py` | Episode loop (0.25 s steps, 180 s budget, 30 m waypoint) for `n` episodes in lockstep: perception → proportional heading control with reactive rock avoidance → wheel-soil mobility → attitude → outcome classification. Optional trajectory recording for replay. | terrain, terramechanics, perception | | `campaign.py` | Accumulates episodes with a `source` tag (0 = proposal MC, 1 = CEM, 2 = external). Importance re-weights onto each `P_k`, reports failure rate with CI, ESS, max-weight share, support coverage, failure-mode distribution shift; cross-entropy method adversarial search; `.npz` save/load; CSV ingestion. | priors, rover | -| `render.py` | Deterministic software renderer: ray-marched DEM, rocks, rover body/wheels, sun-direction shading, cast shadows, dust haze, HUD; four cameras; contact sheets. | rover, perception, Pillow | +| `render.py` | Deterministic software renderer: ray-marched DEM, rocks, rover body/wheels, sun-direction shading, cast shadows, dust haze, regolith texture, ambient occlusion, wheel ruts, filmic tone map; HUD or clean caption; five cameras (`chase`, `rover_cam`, `overview`, `orbit`, `cinematic`); optional supersampling; contact sheets. | rover, perception, Pillow | | `providers.py` | `LocalProvider` (default, credential-free) and opt-in `ReactorProvider` (reactor-sdk). Every frame carries provenance (`provider`, `live`, `reason`). | render | | `app.py` | FastAPI REST + WebSocket. Owns one in-memory `Campaign`, streams progress, serves frames and episode JSON to the browser. | campaign, providers, fastapi | | `static/` | Browser UI: 8 sliders, prior sampling, what-if, campaign/CEM controls, failure-mode shift tables, episode timeline, WebGL world model (`gl.js`), software-render panel, `/internals` diagnostics page. | — | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 9ba6776..e4312db 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -107,6 +107,19 @@ Generated from the same run by `python scripts/make_renders.py runs/val.npz --pr Episode indices ≥ 4000 are CEM rows (`source=1`); they are legitimate replayable failures but are not part of the estimates in §2.1. +The README showcase assets come from the same run via +`python scripts/make_showcase.py runs/val.npz --hero-episode 111` (2× supersampled, clean caption +instead of the HUD). Selection is photogenic (low but not grazing sun, light dust, visible rocks), not +statistical: + +| File | Episode | Seed | Camera | +|------|---------|------|--------| +| `hero.png`, `gallery_stuck.png`, `demo.webp` | #111 stuck (proposal row) | 20260912000111 | cinematic / cinematic / chase, 40 frames | +| `gallery_tip_over.png` | #675 tip_over (proposal row) | 20260912000675 | chase | +| `gallery_collision.png` | #4363 collision (CEM row) | 20260912004363 | rover_cam | +| `gallery_nav_miss.png` | #4686 nav_miss (CEM row) | 20260912004686 | overview | +| `gallery_success.png` | #18 success (proposal row) | 20260912000018 | cinematic | + ## 3. Web application Manually exercised on the same commit with `uvicorn astraeus.app:app --port 8000`: diff --git a/docs/img/demo.webp b/docs/img/demo.webp new file mode 100644 index 0000000..bd30bef Binary files /dev/null and b/docs/img/demo.webp differ diff --git a/docs/img/gallery_collision.png b/docs/img/gallery_collision.png new file mode 100644 index 0000000..df7c665 Binary files /dev/null and b/docs/img/gallery_collision.png differ diff --git a/docs/img/gallery_nav_miss.png b/docs/img/gallery_nav_miss.png new file mode 100644 index 0000000..c748e95 Binary files /dev/null and b/docs/img/gallery_nav_miss.png differ diff --git a/docs/img/gallery_stuck.png b/docs/img/gallery_stuck.png new file mode 100644 index 0000000..6dbb1cc Binary files /dev/null and b/docs/img/gallery_stuck.png differ diff --git a/docs/img/gallery_success.png b/docs/img/gallery_success.png new file mode 100644 index 0000000..52b2115 Binary files /dev/null and b/docs/img/gallery_success.png differ diff --git a/docs/img/gallery_tip_over.png b/docs/img/gallery_tip_over.png new file mode 100644 index 0000000..103bb4e Binary files /dev/null and b/docs/img/gallery_tip_over.png differ diff --git a/docs/img/hero.png b/docs/img/hero.png new file mode 100644 index 0000000..c16be70 Binary files /dev/null and b/docs/img/hero.png differ diff --git a/scripts/make_showcase.py b/scripts/make_showcase.py new file mode 100644 index 0000000..b512dc4 --- /dev/null +++ b/scripts/make_showcase.py @@ -0,0 +1,132 @@ +"""Generate the README showcase assets (hero still, outcome gallery, animated demo) +from a saved campaign with the local software renderer. + + python scripts/make_showcase.py runs/val.npz # everything (~10 min on a laptop CPU) + python scripts/make_showcase.py runs/val.npz --only hero gallery + python scripts/make_showcase.py runs/val.npz --hero-episode 111 + +Every asset is a deterministic function of (campaign file, episode index): the +script prints the index and seed behind each file so the README can cite them. + +Selection is purely photogenic, not statistical: the hero is the highest-weight +elite under --prior whose sun is low but not grazing and whose sky is reasonably +clear, so the raking light shows terrain relief. Failure probabilities in the +README come from `astraeus report`, never from these picks. +""" +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path +from typing import List, Sequence + +import numpy as np +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from astraeus import campaign as C # noqa: E402 +from astraeus import priors as PR # noqa: E402 +from astraeus import render as RD # noqa: E402 +from astraeus import rover as RV # noqa: E402 + +SUBTITLE = "adversarial stress testing of off-world rover autonomy under unknown priors" +GALLERY = ( # (outcome, camera, caption) + ("stuck", "cinematic", "wheel sinkage exceeds traction on soft, sloped regolith"), + ("tip_over", "chase", "static tip-over on crater rim relief"), + ("collision", "rover_cam", "rock not resolved in low-sun glare and dust"), + ("nav_miss", "overview", "visual-odometry drift misses the goal ring"), + ("success", "cinematic", "nominal traverse under a benign draw"), +) + + +def photogenic(c: C.Campaign, idx: Sequence[int]) -> List[int]: + """Keep episodes whose draw lights the terrain well: low raking sun, clear sky.""" + X = c.X + keep = [] + for i in idx: + x = dict(zip(PR.DIMS, X[i])) + if 2.2 <= x["sun_e"] <= 5.8 and x["tau_dust"] <= 0.15 and x["rho_rock"] >= 3.0: + keep.append(int(i)) + return keep + + +def pick(c: C.Campaign, outcome: str, prior: PR.Prior) -> int: + code = RV.OUTCOMES.index(outcome) + if outcome == "success": + cand = np.flatnonzero((c.outcome == code) & (c.source == 0)) + good = photogenic(c, cand) + if not good: + raise SystemExit("no successful proposal episode in the run") + return good[0] + ranked = [i for i in c.elites(k=c.n, prior=prior) if c.outcome[i] == code] + good = photogenic(c, ranked) + if good: + return good[0] + if ranked: + return int(ranked[0]) + raise SystemExit(f"no {outcome} episode in the run") + + +def still(res: RV.EpisodeResult, camera: str, size, ss: int, frame: int = -1, subtitle: str = SUBTITLE) -> Image.Image: + scene = RD.Scene.from_result(res, frame) + im = RD.render_frame(scene, camera, size, hud=False, supersample=ss) + RD.draw_caption(im, scene, subtitle=subtitle) + return im + + +def animate(res: RV.EpisodeResult, camera: str, size, n_frames: int, out: Path, fps: int = 12) -> None: + assert res.trace is not None + T = res.trace["x"].shape[1] + frames = [] + for k, fr in enumerate(np.linspace(0, T - 1, n_frames).astype(int)): + scene = RD.Scene.from_result(res, int(fr)) + im = RD.render_frame(scene, camera, size, hud=False) + RD.draw_caption(im, scene, subtitle=f"frame {k + 1}/{n_frames} · t = {scene.t:.1f} s") + frames.append(im) + hold = [frames[-1]] * fps # linger on the terminal frame + frames[0].save(out, save_all=True, append_images=frames[1:] + hold, duration=int(1000 / fps), + loop=0, quality=82, method=4) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("run", type=Path) + ap.add_argument("--prior", default="P2", choices=list(PR.PRIORS)) + ap.add_argument("--hero-episode", type=int, default=None) + ap.add_argument("--only", nargs="*", default=("hero", "gallery", "demo"), choices=("hero", "gallery", "demo")) + ap.add_argument("--supersample", type=int, default=2) + ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "docs" / "img") + a = ap.parse_args() + + c = C.Campaign.load(a.run) + prior = PR.PRIORS[a.prior] + a.out.mkdir(parents=True, exist_ok=True) + + hero_i = a.hero_episode if a.hero_episode is not None else pick(c, "stuck", prior) + hero = c.replay(hero_i) + + if "hero" in a.only: + t0 = time.perf_counter() + still(hero, "cinematic", (1920, 1080), a.supersample).save(a.out / "hero.png", optimize=True) + print(f"hero.png episode #{hero_i} seed {int(c.seeds[hero_i])} {time.perf_counter() - t0:.0f}s", flush=True) + + if "gallery" in a.only: + for outcome, camera, caption in GALLERY: + i = hero_i if outcome == "stuck" else pick(c, outcome, prior) + t0 = time.perf_counter() + res = hero if i == hero_i else c.replay(i) + still(res, camera, (1280, 720), a.supersample, subtitle=caption).save( + a.out / f"gallery_{outcome}.png", optimize=True) + print(f"gallery_{outcome + '.png':<14}episode #{i} seed {int(c.seeds[i])} " + f"{time.perf_counter() - t0:.0f}s", flush=True) + + if "demo" in a.only: + t0 = time.perf_counter() + animate(hero, "chase", (800, 450), 40, a.out / "demo.webp") + print(f"demo.webp episode #{hero_i} chase, 40 frames {time.perf_counter() - t0:.0f}s", flush=True) + + +if __name__ == "__main__": + main()