Finds faint moving objects, mostly Kreutz-group sungrazing comets, in images from CCOR-1, the coronagraph on NOAA's GOES-19 weather satellite. It searches the NOAA archive, watches the real-time feed, and checks what it finds against the reports on NASA/NRL's Sungrazer Project.
It is a candidate finder, not a reporter. Everything it flags is meant to be looked at by a person before anyone tells Sungrazer about it.
CCOR-1 publishes a 2048x1920 calibrated frame every 15 minutes. For each window of the last N frames:
- Background. Subtract the median of the previous 16 frames (past only, as a real-time system would have), skipping the most recent so a slow object doesn't subtract itself out.
- Stars. Stars drift about 2 px/frame, non-uniformly (plate scale and the satellite's orbit). Each pair of frames is aligned by matching a few hundred stars, a star model is built from 8 aligned past frames and subtracted, and confirmed stars are masked.
- Point filter. A difference-of-Gaussians filter keeps point sources and cancels diffuse coronal structure; the result is divided by a local noise estimate to give a significance map per frame.
- Shift-and-stack. For each of ~1,900 trial velocities, shift the N maps back along that motion and sum. An object moving at that velocity adds up (signal x N, noise x sqrt N). Each frame is clipped first so one hot pixel can't make a track. Position and velocity priors come from reported Kreutz tracks.
- Linking and vetting. A detection has to recur in consecutive windows where its velocity predicts, show up in most frames of each window, and move. It is then followed through the raw frames to make sure it isn't a star, and in the archive checked for persistence: does it keep going in the frames after, compared with parallel control paths in the same frames?
kreutz/lane.py is a faster, deeper variant: Kreutz comets all enter the
field along one lane, so a flow field built from observed tracks gives the
expected velocity at each pixel, and the stack can follow the comet's
acceleration over 16 frames.
kreutz/orbitsearch.py goes a step further on the CCOR instruments. Every
Kreutz comet is on nearly the same orbit, so the trials are orbits: a small
family of node values, and every perihelion time that puts the comet in
the frame, each projected through the frame's WCS. That is a few hundred
thousand trials against the full search's 760 million, and the predicted
path curves and speeds up with the comet, so long windows don't smear.
kreutz.run --orbit runs it over the archive; the watcher adds it to
every window with --orbit-threshold.
Coordinates throughout are Sungrazer report coordinates (x right, y down from the top of the displayed image, pixels per 15-minute frame) so results compare directly with reports. The Sun's center comes from each frame's WCS, at (1006, 957) in these coordinates; the WCS reference pixel is 40 px from it and is not the Sun.
Everything runs in Docker. data/ is bind-mounted into the container as
/data and holds frames, reports, run output and vetting images; it is not
checked in. Frames are about 880 MB per day. The optional settings (an
ntfy topic for pages, the CPU cap, the web port) go in .env; copy
.env.example and fill in what you use.
docker compose build
docker compose run --rm kreutz kreutz.fetch 2025-07-16 2025-07-24 # frames from NOAA NCEI
docker compose run --rm kreutz kreutz.reports 2025-07-01 2025-08-31 # Sungrazer reports
docker compose run --rm kreutz kreutz.run 2025-07-17 --n 8 # search one day
docker compose run --rm kreutz kreutz.evaluate /data/runs/n8/20250717*.jsonl
KREUTZ_INSTRUMENT=ccor2 points every command at CCOR-2, the coronagraph
on SWFO-L1 at the Sun-Earth L1 point, instead of CCOR-1 (the default; see
kreutz/instrument.py). Its frames are the same size, cadence and units,
so the detector runs on them, and they go to data/ccor2/l1a/
while its runs, cache and live state get a -ccor2 suffix, so both
instruments share one checkout and one data/. What doesn't carry over
is everything measured on CCOR-1. The search sector (instrument.py) is
CCOR-2's own: the lower right of the Sun rather than the right. The lane
swings right round the Sun over a year (CCOR-1's reports: -11 degrees in
September, -50 in November, -124 in December, -168 in March), so on both
CCORs, as on LASCO, the search sector and the lane field come from the
season's samples (season_days in instrument.py, lane.season_sector):
the samples file's archive-search tracks (kreutz.lanesamples over archive
runs, saved as kreutz/lane-samples-ccor2.json; kreutz.live and --lane
refuse to run without one), topped up with the points of every Kreutz
report newer than the file's last sample (lanesamples.from_reports, from
data/reports, which the audit loop refreshes from the site once a day).
A fixed sector went stale: CCOR-2's, from the Jul-Oct 2026 reports at
-37 degrees, never searched where Banach's comet of 2026-11-09 came in at
-65. The sector is the samples' spread plus 15 degrees either side, so
below the lane the stray-light striping near the bottom edge, which stacks
into tracks, is partly inside it; the line gate holds those. The SWPC feed carries CCOR-2 at level 1A only and NOAA's
archive has none yet, so the feed's rolling ~30 days are the only frames;
kreutz.sync --loop keeps them from aging out. Level 1A isn't cleaned of
particle hits, and at L1 there are many (five times CCOR-1's count of
blobs above 5 sigma in a frame), so frames.cosmic_rays takes out pixels
far above the local noise and sharper than the PSF before the point
filter; that brought one window's candidates from 284 to 18. Two more
1A quirks each made a class of slow false tracks on the Oct 16, 2026
test day: bright stars leave a ring of residue at 4-8 px after the star
model (the star mask is 10 px there, not 4), and the detector's dark
defects wander a few pixels from frame to frame on the 1A grid, so they
don't cancel against the background; frames.fill_dark replaces them
with the local median in every raw frame, and frames.dark_blotches
masks the wide ones (15 px and more) that a median fill can't reach.
KREUTZ_INSTRUMENT=ccor2 docker compose run --rm -d kreutz kreutz.sync --loop
KREUTZ_INSTRUMENT=c2 (or c3) works on SOHO's LASCO coronagraphs.
Their level 0.5 frames come from NASA (kreutz/lasco.py): the last ~10
days a few minutes behind, the archive from 2000 on about three weeks
behind, so kreutz.fetch and kreutz.sync both work. Only the ~12-minute
synoptic sequence is taken, stored as data/c2/l05/.../C2_05_<time>_<seq>.fts,
and every frame is loaded bias-subtracted, per second of exposure, and
rotated to solar north up: SOHO's roll flips by 180 degrees every few
months. Sungrazer's C2 positions are on that north-up frame (checked on
comets reported at roll -0.5 and -173 degrees).
What differs from CCOR, all from instrument.py:
- Cadence 12 minutes, and frames are stacked at their true time offsets (LASCO runs at 10.6 minutes for hours at a time); windows may miss two frames, since SOHO skips two in a row several times a day.
- The search stays 14 px from the frame edge, not 30 (LASCO's edges are as quiet as the middle, and C2's comets enter across the right edge at up to 20 px/frame), and C2 searches up to 24 px/frame.
- Where Kreutz comets come in swings through the year (C3: lower right at
-40 degrees in Aug-Oct, lower left at -141 in Feb-Apr), so the lane and
the search sector are the season's:
lane.season_samplestakes the samples within 15 days of the date in any year (more where they're thin). The lanes,kreutz/lane-samples-c2.jsonand-c3.json, are built from the 2024-2026 reports withkreutz.lanesamples --from-reports. - Stars are used and masked down to 200 px (C2) and 100 px (C3) from the
Sun, with an 8 px mask. C2's small field rarely has 20 stars bright
enough to align on among its particle hits, so its star drift is the
Sun's motion along the ecliptic (
lasco.star_drift; on C3, where the stars can be measured, the two agree to 2%).
On the test days (C2 2026-09-16, C3 2026-09-15) the 4-frame lane search found every reported Kreutz comet bright enough to see, with no unexplained tracks at 5.5 sigma; C3's caught a Potential Comet 2.3 hours before it was posted.
KREUTZ_INSTRUMENT=c3 docker compose run --rm kreutz kreutz.fetch 2026-09-14 2026-09-15
KREUTZ_INSTRUMENT=c3 docker compose run --rm kreutz kreutz.run 2026-09-14T18:00 2026-09-15T18:00 --n 4 --lane
Modules, each with usage in its docstring:
| module | what it does |
|---|---|
kreutz.fetch |
download archived frames from NCEI |
kreutz.reports |
scrape Sungrazer reports into data/reports/ |
kreutz.run |
search every window in a span; --lane for the lane search, --orbit for the orbit search |
kreutz.evaluate |
score a run against reports: what would have been found, and how early |
kreutz.shortlist |
archive run to a persistence-ranked list of unexplained tracks |
kreutz.look |
strip of crops along a hypothesized track, for vetting by eye |
kreutz.sheet |
contact sheets of those strips for a candidate list |
kreutz.weekly |
collate a span's runs into shortlist, sheets and summary |
kreutz.render |
follow one detection through the frames; write the aligned cutouts as FITS cubes, the track, and a quick-look preview |
kreutz.style |
pictures from a render's FITS cubes in a named preset (reference styles, copy and change) |
kreutz.live |
watch the SWPC real-time feed; alert on unreported candidates and keep going |
kreutz.score |
table of the live alerts against the reports that appeared: our lead or lag |
kreutz.audit |
the other way round: reports the watcher missed, vetoed, or alerted on and then dropped, when we had each against its reporter, and alerts that came to nothing; --loop --digest HH:MM runs it for every watcher and sends a daily digest |
kreutz.report |
measure an object frame by frame and print the block Sungrazer's report form takes |
kreutz.notify |
post an alert or confirmation to an ntfy topic (the watcher's --notify) |
kreutz.page |
write the one status page over every instrument's watcher, data/index.html, and data/mark.html, a frame player for circling things in a render (mark.html?dir=render-ccor2/<name>/style-soft/field) |
kreutz.sync |
pull everything on the instrument's real-time feed to disk, beyond the hours the watcher needs (--loop to keep up) |
kreutz.lanesamples |
lane samples for lane.py from archive runs: the windows of tracks matching the instrument's Kreutz reports |
The rest of the package is what those import:
| module | what it does |
|---|---|
kreutz.instrument |
everything that depends on which coronagraph: directories, feeds, sector, lane file, masks |
kreutz.frames |
frames as background-subtracted, star-subtracted significance maps; the coordinate conventions |
kreutz.search |
the full shift-and-stack search over every velocity |
kreutz.lane |
the Kreutz flow field and the lane-constrained search |
kreutz.orbit, kreutz.orbitsearch |
where a Kreutz orbit puts a comet in a frame, fitting one to a track, and the orbit-constrained search |
kreutz.vet |
second look at a fired track in the raw frames: star, stationary, ghost, line |
kreutz.persist |
archive only: does a track keep going in the frames after it fired? |
kreutz.lasco |
LASCO frame listing, download and loading |
kreutz/status.html, kreutz/mark.html |
the status page and the frame player that kreutz.page installs into data/ |
docker compose run --rm kreutz kreutz.render 2026-09-16T01:45 1953 1126 -9 -1.5 --name sep16
docker compose run --rm kreutz kreutz.style /data/render/sep16 --preset tintype
kreutz.render follows the object forward and back through the frames until
it is lost, across data gaps, and writes data/render/<name>/: track.json,
cutouts.fits (raw and background-subtracted crops aligned on the object, the
field region it crosses, a shift-and-stack along its motion, and the track as
a table) and a plain preview.gif. The FITS data is unfiltered and
unstretched; it is the product.
kreutz.style turns those cubes into a crop animation (object held still,
stars sliding past), a field animation, PNG frames and a still, in one of a
few reference presets. Styling is a matter of taste, so the presets are
starting points to copy, not the project's opinion.
docker compose run --rm -d kreutz kreutz.live --n 8 --threshold 6.0 --orbit-threshold 6.0 --notify "python -m kreutz.notify" --render
polls NOAA's real-time feed, searches each new window, and when a track
fires that isn't a star and isn't already reported, writes
data/live/alerts/<time>_<x>_<y>.json and .gif, appends to
data/live/log.jsonl, and keeps watching. It then measures the object in
each new frame; once it has held for five frames, a second "confirmed" file
is written with the report block ready for Sungrazer's form.
Pages. With --notify "python -m kreutz.notify" and NTFY_TOPIC set in
.env, the watcher pages an ntfy topic, and the title says what to do:
REPORT(urgent): an unreported candidate the tool is sure of. The page carries the evidence (the stack, the gates, frames held, whether anyone has reported anything nearby) and the positions in Sungrazer's form layout, so it can be filed from a phone. It comes when the candidate's stack reads real (below), when the follow-up has measured it in five frames on a straight line (confirmed), or when the search or the follow-up has held itHELD_WINDOWS(6) windows after the alert with a positive stack. A candidate the orbit or line gate held at the alert still pages REPORT once the search has held it that long with a stack that reads real: those gates are for three-window coincidences, which don't persist (the comet of 2026-11-09 01:45 fit no family orbit and stacked to z 31 over the morning). One REPORT per object; a confirmation after it is a quiet page with the measured block.watching(low): a candidate that passed the page gates but whose stack is still weak. No action; it pages REPORT if it gets there.known comet(low, at confirmation): matched to somebody's report.- Nothing for a candidate held by a gate (below) until it's confirmed, and then a quiet page saying why it was held, unless the orbit search found it.
The stack (kreutz/stackcheck.py) is what settled every live candidate by
hand before this: the significance maps added along the object's path and
compared with the same sum along 200 offset copies of the path, as a
z-score. It starts at the alert over the alert's own frames and grows
with each frame the follow-up looks at, at the measured position, else
the search chain's, else the predicted one. scripts/stack-calibrate.py
ran it over every alert of 2026-10-23..11-08 (data/calib/): along the
alert's own line, no false CCOR alert that passed the gates stacked past
6.3 while real ones ran 3 to 50, so REAL_Z is 8 on CCOR; C2's and C3's
false alerts stacked to 13.8 (stars and movers their gates let through),
so 15 there. The faint real comets stacked 3 to 6, no better than the false
alerts, which is why persistence is the other way to REPORT: every false
alert that passed the gates in that span was never seen again after its
alert frame, while the faint real ones were held by the search for hours.
The status page shows each followed object's stack. KREUTZ_URL in .env
(where data/ is served) makes a page open the watcher's status page when
tapped.
Gates. A candidate that doesn't move like a sungrazer, pointed more than
20 degrees off straight at the Sun or slower than the instrument's floor (4
px/frame on CCOR-1, 2.5 on CCOR-2), is not paged until it's confirmed:
it's still written, followed, pictured and shown on the status page as
"not paged". Every real alert so far was within 14 degrees of sunward at
4 px/frame or more; the stray-light ghosts, lines and corner noise that
page scatter out to 90 degrees, and the satellite glint of 2026-10-21 was
24 off. (A gate on distance to the archived Kreutz tracks came first, and
the stream outgrew it: Ruan's comet of 2026-10-21 ran 273 px from the
nearest track and its first page was held back.) Two more holds are per
instrument: on CCOR-1 a page is held when the track fits no Kreutz family
orbit, and on CCOR-2 when a straight line runs through the track in its
own stack, which is how its recurring stray-light streak looks (it lies
along the lane and slides along itself, so it moves like a sungrazer).
A held track is followed like any other, and its confirmation pages in
full.
With --orbit-threshold T the orbit-constrained search runs on every
window too, at threshold T; its candidates join the full search's and
are linked, vetted and followed with them. A track made only of orbit
candidates is marked "found_by": "orbit" in its files and, unless
--orbit-pages is given, isn't paged until it's confirmed.
A frame where only the
search's stacked chain holds an object, and nothing is measured, keeps
the follow-up alive but counts toward neither confirmation nor the report
block; an object is confirmed on a frame it was measured in. With
--render, a confirmed object's cutouts and pictures are made in the
background under data/render/, and remade every few frames while it stays
in view, so the pictures grow with the passage.
A comet somebody has already reported gets the same files, follow-up and
pictures, logged as reported rather than alert; the notification for it
is a single low-priority one at the confirmed stage.
Before anything is raised the track is vetted in the raw frames: dropped as
a star if it moves with the star drift, as stationary if it doesn't move,
as off-lane if its motion runs more than 45 degrees from the Kreutz
flow there, as a ghost if the same pixels rose and fell the same way at
this time yesterday (the eclipse-season stray light comes back daily),
and as a line if its brightest frame shows a streak rather than a point.
Over the 53 alerts of 2026-10-05..18 those two caught 14 false ones and
none of the real comets. A follow-up whose measured frames don't lie on a line, don't
move, or run against the lane is rejected instead of confirmed, and nothing
is sent or rendered for it.
The watcher saves its working state (data/live/state.json) after every
window and resumes from it on restart, so it doesn't re-search the span
and an object being followed keeps being measured and rendered.
After every window the watcher rewrites data/live/status.json: open chains,
what's being followed and at which stage (candidate at three windows,
confirmed at five frames), whether and by whom it's already reported, and
the last decisions (CCOR-2's watcher writes data/live-ccor2/). One static
page, data/index.html, shows every instrument's watcher together: it
reads data/instruments.json for the instruments and their live
directories, then each one's status.json. Every object carries an
instrument badge, and chips at the top narrow the page to one. The
watchers write the page and the list when they start, and kreutz.page
does it by hand. The old per-watcher status.html pages now redirect to it.
docker compose up -d web # then http://localhost:8000/
The audit runs on its own alongside the watchers, every half hour for every instrument with one, and once a day sends the day's verdicts and page count to the ntfy topic at low priority:
docker compose run --rm -d --name kreutz-audit kreutz kreutz.audit --loop --digest 13:00Its lines go to data/audit.log. scripts/watch.sh is for a terminal:
it prints a line for each alert, confirmation, rejection, report match,
error or stopped container, flags a watcher whose status.json goes 45
minutes without an update, and runs the audit too (harmless alongside
the loop: a verdict is said once wherever it's first seen).
Chains under three windows are mostly noise and appear only as a count.
With --keep-days N, frames and significance maps more than N days old are
deleted (about 1.6 GB a day otherwise accumulates); alerts, logs and renders
are kept, and a pruned day comes back with kreutz.fetch. Frames share a
directory with the archive fetcher, so use it only where nothing else keeps
frames there.
A CCOR-1 report claims a track that passes through its points, or along
their path extrapolated for up to 12 h either way, so a morning report
still claims the comet's track in the evening; a followed object is
re-matched against the latest reports every window, since the report of
a comet we're following usually arrives hours after our alert. On the
CCOR instruments a report from either of them is also matched by orbit:
one Kreutz orbit that fits the report's points and the track together
(kreutz.orbitsearch.joint_rms), which is how a comet first reported
from the other coronagraph is recognised. A track that fits no Kreutz
orbit is tried against the reports filed under the Marsden, Kracht and
Meyer groups the same way. LASCO's reports can't be matched by position
or orbit; a lane track while LASCO has a Kreutz comet in view counts as
that comet, where "in view" allows a day before our first sighting for
C3, which looks much farther out, and six hours for C2. The search stacks frames and
so holds a comet too faint for the follow-up's single-frame measurement;
a frame where the search still chains a followed object counts as seen,
and a chain that sits where a follow-up predicts its object is not
alerted again. kreutz.score later compares each alert with the reports
that appeared and prints the lead or lag in minutes. Submitting a report is
always a person's job.
scripts/weekly.sh # the seven complete UTC days through yesterday
scripts/weekly.sh 2026-10-04 # the week ending before that date
fetches the frames and reports, runs the full search day by day and the lane
search over the span, and collates (kreutz.weekly) into
data/weekly/<start>_<end>/: a shortlist of every fired track, contact sheets
of the unexplained candidates and the brightest reported comets, and a
summary.txt. Add --gpu to use the GPU for the full search. Days already
searched are skipped, so it can be rerun.
scripts/ also has the wrappers for longer jobs: day.sh (one day in three
parallel chunks), archive.sh and archive-gpu.sh (a span of days),
lane.sh, benchmark.sh (the July 2025 week below), lane-honest.sh
(the lane search with a lane built only from the reports before each
test day) and stop.sh.
The Python scripts in scripts/ are diagnostics that import the package.
Each has its usage in its docstring; they run in a one-off container with
the scripts directory mounted:
docker compose run --rm -T -w /srv -e PYTHONPATH=/srv -v $PWD/scripts:/srv/scripts:ro --entrypoint python kreutz scripts/report-stack.py report_xx_20261024182048
threshold-replay.py: replay archive runs through the watcher's alert rules at several thresholds, for how many pages a threshold costsreport-stack.py,orbit-miss.py,faint-probe.py: is a reported comet in our significance maps at all, and if not, which processing step loses itorbit-stack.py,faint-orbit-tie.py: is a faint track real, and is it the same comet as a reportorbit-family.py: fit orbits to rendered tracks, which is whereorbitsearch.py's family comes fromrender-report.py: render along a report's own points, for a comet too faint forkreutz.renderto followline-probe.py: score alerts for CCOR-2's stray-light streak
Containers are CPU-capped (KREUTZ_CPUS, default 1.5) with single-threaded
numeric libraries, so several can run side by side at a predictable load. The
GPU path is opt-in and much faster for the full search:
docker compose -f docker-compose.yml -f docker-compose.gpu.yml run --rm kreutz kreutz.run 2025-07-17 --n 8
On a benchmark week (July 17-24, 2025, eight confirmed CCOR-1 comets) the detector found all eight. Six it had before the human report was posted, by 17 minutes to 4.5 hours; two it found late, 85 and 116 minutes after the report. Over a two-month archive search it found every bright Kreutz comet that hunters reported, plus a planet, and no unreported comet. On the live feed it has flagged a real comet 30 minutes after it entered the field; a hunter had already reported it from another coronagraph.
Sungrazer's rules are at https://sungrazer.nrl.navy.mil/. Reports there must be made by the person who found and measured the object.
scripts/test.sh runs lint and the unit tests in the container; CI runs
the same on every push, plus an import of every module. Those cover the bookkeeping (coordinates, window
and linking rules, report parsing and conversion) on synthetic inputs. The
detector itself is tested by the July 2025 benchmark, scripts/benchmark.sh,
run by hand whenever it changes, and that's where the numbers above come
from.
MIT, see LICENSE.