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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest maturin onnxruntime
pip install pytest pytest-xdist maturin onnxruntime
cd ext/astro_native
maturin build --release
pip install --force-reinstall --no-deps target/wheels/astro_native-*.whl

- name: Native kernel + originvision parity tests
run: pytest -q tests/test_native.py tests/test_originvision.py
run: pytest -q -n auto tests/test_native.py tests/test_originvision.py

test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -97,7 +97,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pip-audit bandit
pip install pytest pytest-xdist pip-audit bandit

- name: Audit dependencies
run: pip-audit -r requirements.txt
Expand All @@ -106,7 +106,7 @@ jobs:
run: bandit -r src/ -ll -q

- name: Run tests
run: pytest -q
run: pytest -q -n auto

- name: Smoke run (synthetic)
run: |
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ orion-live/
synthetic_data/
synthetic_data_mixed/

# transient-triage training data (tools/gen_transient_triage_data.py,
# tools/mine_real_transient_data.py) -- generated, not committed
transient_triage_data.npz
transient_triage_real_data*.npz
transient_triage_real_work*/

# Claude Code directories
.claude/
**/claude/
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@ match the `VERSION` file and `v*` git tags.

## [Unreleased]

## [2.3.0] - 2026-09-23

### Added

- **`--transient-triage`**: scores each `--transient-detect` candidate with a small CNN (new/ref/diff
stamp triplet, native `tract` inference) for a `real_probability` -- the same real/bogus triage role
ZTF's BTSbot / Rubin's DIA play downstream of classical image differencing, and the first amateur
stacking tool to do it. Advisory only, never drops a candidate. The bundled model is trained entirely
on synthetic data (`tools/gen_transient_triage_data.py` + `tools/train_transient_triage.py`); a
companion `tools/mine_real_transient_data.py` mines real cross-session negatives and real-epoch
injection positives from a user's own multi-night sessions for future retraining. Native-only for now
(no numpy/onnxruntime fallback yet).
- **Cancel button** in the desktop app. Cooperative (a shared `threading.Event`, not a thread kill):
noticed between Phase 1 frames -- usually the longest phase -- and between targets in a multi-session
run. Phases 2-4 of a single target aren't interruptible yet.

### Changed

- **`--originvision` is on by default now** (`--no-originvision` to disable -- a single action, same
shape as `--auto`/`--no-auto`). A bare `--originvision` on an old command line now errors instead of
being a no-op, deliberately: the desktop app's auto-generated form keys purely on argparse dest with
no dest-collision handling, so a redundant positive flag alongside the new negative one would have
silently duplicated or dropped that field.
- **`--originvision-workers` default raised 2 -> 8**, measured (not guessed): near-linear scaling on a
real full-resolution frame, 2751 ms/frame at 1 worker down to 473 ms at 8 -- the previous default left
most of the free GIL-released parallelism the code already claimed on the table.

### Fixed

- **`--transient-detect` no longer hard-refuses two epochs with different pixel dimensions.** Two
independently-stacked sessions of the same target routinely differ in shape (different dither pattern,
different Phase 3 crop) even though they cover the same field; `_align_reference` now reconciles them
onto a common grid first (`src.utils.embed_to_shape`, the same trick `--merge` already used for its own
differently-shaped previous stacks), carrying a real valid-data mask through the warp so the padded
border reads as uncovered, not real reference data.

## [2.2.6] - 2026-09-22

### Changed
Expand Down
16 changes: 12 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.2.6
2.3.0
2 changes: 1 addition & 1 deletion ext/astro_native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ext/astro_native/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "astro_native"
version = "0.35.0"
version = "0.36.0"
edition = "2021"
description = "Native (Rust) hot-path kernels for OriginStack: stacking combine, etc."

Expand Down
2 changes: 1 addition & 1 deletion ext/astro_native/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "astro_native"
version = "0.35.0"
version = "0.36.0"
description = "Native Rust hot-path kernels for OriginStack"
requires-python = ">=3.10"
classifiers = ["Programming Language :: Rust"]
Expand Down
146 changes: 146 additions & 0 deletions ext/astro_native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,7 @@
}

#[pyfunction]
fn dwt2_native<'py>(

Check warning on line 1385 in ext/astro_native/src/lib.rs

View workflow job for this annotation

GitHub Actions / lint

OS004 native pyfunction 'dwt2_native' is not referenced under tests/ -- add a parity or smoke test
py: Python<'py>,
img: PyReadonlyArray2<'py, f64>,
dec_lo: PyReadonlyArray1<'py, f64>,
Expand Down Expand Up @@ -1462,7 +1462,7 @@
}

#[pyfunction]
fn idwt2_native<'py>(

Check warning on line 1465 in ext/astro_native/src/lib.rs

View workflow job for this annotation

GitHub Actions / lint

OS004 native pyfunction 'idwt2_native' is not referenced under tests/ -- add a parity or smoke test
py: Python<'py>,
ca: PyReadonlyArray2<'py, f64>,
ch: PyReadonlyArray2<'py, f64>,
Expand Down Expand Up @@ -3190,7 +3190,7 @@
/// reference wherever the values are f32-representable.
#[pyfunction]
#[pyo3(signature = (channel, emission_mask, patch_size, masked_frac_thresh, sky_ref, sky_std))]
fn dbe_sample_patches<'py>(

Check warning on line 3193 in ext/astro_native/src/lib.rs

View workflow job for this annotation

GitHub Actions / lint

OS004 native pyfunction 'dbe_sample_patches' is not referenced under tests/ -- add a parity or smoke test
py: Python<'py>,
channel: PyReadonlyArray2<'py, f32>,
emission_mask: PyReadonlyArray2<'py, f32>,
Expand Down Expand Up @@ -6469,6 +6469,151 @@
}
}

// ---------------------------------------------------------------------------
// ZOGY transient triage: real/bogus classification of --transient-detect
// candidates (src/transient_triage.py, --transient-triage)
// ---------------------------------------------------------------------------
//
// A much smaller sibling of `mod originvision` above: no percentile stretch,
// no resize -- stamps arrive already extracted at a fixed size and
// per-channel sigma-normalized in Python (there is no perf-relevant work to
// move into Rust for a few hundred 31x31x3 stamps), and no multi-head
// metadata decode, just one scalar sigmoid probability per candidate.
// Batched: all of a frame's candidates score in a single tract forward pass
// over (N, 3, size, size), not one call per candidate. Advisory only --
// never filters `detect_transients`' output, just attaches a
// `real_probability`.
//
// The bundled model (src/data/transient_triage.onnx) is trained entirely on
// synthetic data (tools/gen_transient_triage_data.py +
// tools/train_transient_triage.py) -- no labelled real transients exist yet
// -- so treat it as a first cut, not a production classifier. Deliberately
// has no numpy/onnxruntime fallback yet, unlike every other native kernel in
// this file: a source checkout without astro_native built simply can't use
// --transient-triage until one is added (self-disables with a warning, see
// src/transient_triage.py).
mod transient_triage {
use pyo3::prelude::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use tract_onnx::prelude::*;

type Runnable = TypedRunnableModel<TypedModel>;

struct Session {
model: Runnable,
}

fn cache() -> &'static Mutex<HashMap<String, Arc<Session>>> {
static C: OnceLock<Mutex<HashMap<String, Arc<Session>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}

fn load(path: &str, _size: usize) -> TractResult<Session> {
// Unlike `originvision::load`, the batch axis here must stay
// symbolic: this kernel scores a whole frame's candidates in one
// batched call (N varies per frame), while originvision always
// calls with N=1. The channel/H/W axes are already concrete in the
// exported graph (torch.onnx.export's dynamic_axes only marks axis
// 0 as dynamic), so no `with_input_fact` override is needed -- one
// was tried and made every N != 1 call fail with a tract symbol
// resolution clash against the fixed batch=1 it forced.
let proto = tract_onnx::onnx().proto_model_for_path(path)?;
let model = tract_onnx::onnx()
.model_for_proto_model(&proto)?
.into_optimized()?
.into_runnable()?;
Ok(Session { model })
}

fn get_session(path: &str, size: usize) -> TractResult<Arc<Session>> {
// Key on (path, size): the input size is baked into the compiled
// graph by `load`'s `with_input_fact` + `into_optimized`, same
// reasoning as `originvision`'s cache above.
let key = format!("{path}\u{0}{size}");
{
let c = cache().lock().unwrap();
if let Some(s) = c.get(&key) {
return Ok(Arc::clone(s));
}
}
let s = Arc::new(load(path, size)?);
cache().lock().unwrap().insert(key, Arc::clone(&s));
Ok(s)
}

fn sigmoid(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}

/// Pure compute: batched forward pass over N pre-normalized stamps, no
/// `Python` token -- runs inside `py.detach`.
fn compute(stamps: &[f32], n: usize, size: usize, model_path: &str) -> Result<Vec<f64>, String> {
if n == 0 {
return Ok(Vec::new());
}
let sess = get_session(model_path, size).map_err(|e| e.to_string())?;
// `stamps` is already NCHW-ordered per candidate: (n, 3, size, size).
let input = tract_ndarray::Array4::from_shape_vec((n, 3, size, size), stamps.to_vec())
.map_err(|e| e.to_string())?
.into_tensor();
let outputs = sess.model.run(tvec!(input.into())).map_err(|e| e.to_string())?;
let raw = outputs
.first()
.ok_or_else(|| "model produced no output".to_string())?
.to_array_view::<f32>()
.map_err(|e| e.to_string())?;
if raw.len() != n {
return Err(format!(
"model produced {} outputs for {n} candidates -- refusing to guess",
raw.len()
));
}
Ok(raw.iter().map(|&v| sigmoid(v as f64)).collect())
}

#[pyfunction]
#[pyo3(signature = (stamps, model_path, size=31))]
pub fn transient_triage_score(
py: Python<'_>,
stamps: numpy::PyReadonlyArray4<f32>,
model_path: &str,
size: usize,
) -> PyResult<Vec<f64>> {
let a = stamps.as_array();
let sh = a.shape();
if sh.len() != 4 || sh[1] != 3 || sh[2] != size || sh[3] != size {
return Err(pyo3::exceptions::PyValueError::new_err(format!(
"expected stamps shaped (N, 3, {size}, {size}), got {:?}",
sh
)));
}
let n = sh[0];
let flat: Vec<f32> = a.iter().cloned().collect();
let model_path = model_path.to_string();

// Heavy work off the GIL, panic-guarded -- same reasoning as
// `originvision_score`: `tract` parses an external .onnx file and a
// malformed one can panic inside the parser, which would otherwise
// unwind past callers' `except Exception` as a bare PanicException.
let outcome = py.detach(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
compute(&flat, n, size, &model_path)
}))
});

match outcome {
Ok(Ok(probs)) => Ok(probs),
Ok(Err(msg)) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"transient_triage native inference failed: {msg}"
))),
Err(_) => Err(pyo3::exceptions::PyRuntimeError::new_err(
"transient_triage native inference panicked (malformed model?)".to_string(),
)),
}
}
}

// ---------------------------------------------------------------------------
// CFA drizzle: splat one frame's measured Bayer samples (src/cfa_drizzle.py)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -7913,6 +8058,7 @@
#[pymodule]
fn astro_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(originvision::originvision_score, m)?)?;
m.add_function(wrap_pyfunction!(transient_triage::transient_triage_score, m)?)?;
m.add_function(wrap_pyfunction!(sigma_clip_combine, m)?)?;
m.add_function(wrap_pyfunction!(online_sigma_clip_combine, m)?)?;
m.add_function(wrap_pyfunction!(online_sigma_clip_seed_burnin, m)?)?;
Expand Down
10 changes: 6 additions & 4 deletions packaging/verify_build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,14 @@ if (-not (Test-Path $synthDir)) { throw "synthetic_data was not created -- canno
$outPath = "$env:TEMP\originstack_verify_out.fits"
$headlessLog = "$env:TEMP\originstack_verify_stdout.txt"
if (Test-Path $headlessLog) { Remove-Item $headlessLog -Force }
# --originvision exercises the bundled native scorer (astro_native.originvision_score
# + src/data/originvision.onnx). It self-disables with a warning if either is
# missing from the frozen build -- asserted absent below.
# originvision runs by default now (--no-originvision to disable; no positive
# flag exists, see cli.py) and exercises the bundled native scorer
# (astro_native.originvision_score + src/data/originvision.onnx). It
# self-disables with a warning if either is missing from the frozen build --
# asserted absent below.
$headlessArgs = @('--verify-headless', '-d', (Resolve-Path $synthDir).Path, '-o', $outPath,
'--parallel', '4', '--debayer-method', 'malvar',
'--white-balance', 'grayworld', '--stack-method', 'median', '--originvision')
'--white-balance', 'grayworld', '--stack-method', 'median')
$headlessProc = Start-Process -FilePath $ExePath -ArgumentList $headlessArgs -PassThru `
-RedirectStandardOutput $headlessLog

Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
# pip install -r requirements-dev.txt
ruff==0.16.8
pytest
pytest-xdist
pip-audit
bandit
Loading
Loading