Skip to content
cwagePublic

About

Finds faint Kreutz sungrazing comets in CCOR and LASCO coronagraph images

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

kreutz

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.

How it works

CCOR-1 publishes a 2048x1920 calibrated frame every 15 minutes. For each window of the last N frames:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Running it

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

CCOR-2

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

LASCO C2 and C3 (in progress, issue #53)

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_samples takes the samples within 15 days of the date in any year (more where they're thin). The lanes, kreutz/lane-samples-c2.json and -c3.json, are built from the 2024-2026 reports with kreutz.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/

Pictures

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.

Live

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 it HELD_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:00

Its 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.

A week at a time

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 costs
  • report-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 it
  • orbit-stack.py, faint-orbit-tie.py: is a faint track real, and is it the same comet as a report
  • orbit-family.py: fit orbits to rendered tracks, which is where orbitsearch.py's family comes from
  • render-report.py: render along a report's own points, for a comet too faint for kreutz.render to follow
  • line-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

Results so far

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.

Tests

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.

License

MIT, see LICENSE.

About

Finds faint Kreutz sungrazing comets in CCOR and LASCO coronagraph images

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages