Metadata-Version: 2.4
Name: meteor-shower-forecast
Version: 0.2.0
Summary: Realistic per-location meteor-shower rate estimates from Bortle class and radiant altitude
Keywords: meteor,meteor-shower,astronomy,stargazing,zhr,bortle
Author: trice
Author-email: trice <rtphokie@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Astronomy
Requires-Dist: bortlefinder>=0.2.0
Requires-Dist: timezonefinder>=6.5
Requires-Dist: bortlefinder[build] ; extra == 'build'
Requires-Python: >=3.11
Project-URL: Homepage, https://github.com/rtphokie/meteor-shower-forecast
Project-URL: Repository, https://github.com/rtphokie/meteor-shower-forecast
Project-URL: Issues, https://github.com/rtphokie/meteor-shower-forecast/issues
Provides-Extra: build
Description-Content-Type: text/markdown

# meteor-shower-forecast

Location meteor-shower rate estimates, not the inflated, best case,
[Zenithal Hourly Rate](https://www.amsmeteors.org/glossary/#zenithal-hourly-rate)
(ZHR), but what a naked-eye observer at a specific place and time should
actually expect to see.

A shower's published ZHR assumes the
[radiant](https://www.amsmeteors.org/glossary/#radiant) is at the zenith
and the sky is dark enough to see magnitude 6.5 stars. Neither is usually
true, so this combines three corrections:

1. **Day-decayed ZHR** -- an asymmetric log-linear falloff around the
   shower's peak date (showers typically ramp up faster than they decline).
2. **Radiant altitude** -- computed from the observer's coordinates and
   time; a radiant near the horizon is heavily foreshortened
   (`rate ∝ sin(radiant altitude)`). A radiant up to ~10 degrees below the
   local horizon can still produce visible meteors (they ablate ~100km up,
   well above the radiant's own point on the horizon); that ~10 degrees is
   a derived geometric estimate, not a value from meteor-science
   literature -- see `forecast.HORIZON_DIP_DEG`.
3. **Sky brightness** -- the site's
   [Bortle class](https://en.wikipedia.org/wiki/Bortle_scale), combined
   with the actual moon illumination on the given date, degrades the
   naked-eye [limiting magnitude](https://en.wikipedia.org/wiki/Limiting_magnitude),
   which disproportionately hides a shower's fainter meteors according to
   its [population index](https://en.wikipedia.org/wiki/Population_index).

Both the Bortle class and the moon illumination are estimated
automatically (from coordinates and date/time, respectively) unless you
override them.

The rate-estimation formulas (the day-decay/radiant-altitude/sky-
brightness combination) are adapted from the
[DarkHours](https://github.com/mbeher2200/DarkHours) project (MIT
licensed). DarkHours has no PyPI package, so this reimplements the
relevant math directly (see the module docstrings in
`src/meteor_shower_forecast/` for exactly what was ported from where)
rather than vendoring its much larger, service-oriented codebase.

The ~38-shower catalog (names, IAU codes, radiants, activity, ZHR,
population index) is transcribed from the
[International Meteor Organization](https://www.imo.net/)'s Meteor
Shower Calendar, Table 5, "Working List of Visual Meteor Showers" --
the set of showers with a real, currently-maintained ZHR behind them.
publishActivity decay rates -- required, alongside ZHR/population index,
for an actual rate estimate -- come from the
[Global Meteor Network](https://globalmeteornetwork.org/flux/)'s
operational flux-monitoring data (21 showers), Jenniskens (1994,
*Astronomy & Astrophysics*, 287, 990) for a few more, and for the
remaining minor showers an estimate from each one's IMO activity period
(see "Activity decay rates" below). Parent bodies are cross-referenced from the
[IAU Meteor Data Center](https://www.iaumeteordatacenter.org/)'s List of
Established Showers; per its citation request, work using it should cite
Jenniskens et al. 2020 (*Planetary and Space Science*, 182, 104821) and
Jopek & Kanuchova 2017 (*Planetary and Space Science*, 143, 3-6) -- see
`catalog.py`'s docstring for both citations and exact source editions.

Runtime dependencies are kept minimal: [`bortlefinder`](https://pypi.org/project/bortlefinder/)
(coordinates -> Bortle class/SQM, extracted from this project's own
earlier coordinate-based Bortle estimation and published standalone) and
`timezonefinder` (coordinates -> IANA timezone, for local-time display).

## Install

```
pip install meteor-shower-forecast
```

## Usage

With just coordinates, the CLI reports the next upcoming shower, evaluated
at *this observer's* best upcoming viewing opportunity -- not the
shower's catalog peak. The catalog peak is a global, radiant-independent
instant (when Earth crosses the dust trail most densely); it says nothing
about whether the radiant is even above your horizon then. A bracketed
search across the shower's active window (restricted to astronomical
night, so it never picks a daytime moment) finds the actual best time.
`peak` is still reported alongside `best` for reference. Sky brightness
and moon illumination are estimated automatically, and dates/times are
shown in local time with the 3-letter timezone abbreviation (EDT, PST,
JST, ...) looked up from the coordinates:

```
meteor-shower-forecast --lat 35.7796 --lon -78.6382
```

```
Orionids (ORI)
  parent body:         1P/Halley
  best:                Wed, Oct 21, 2026, 5:47 AM EDT
  peak:                Wed, Oct 21, 2026, 11:13 AM EDT
  days from peak:      -0.2
  effective ZHR:       18.8
  radiant altitude:    70.0° (az 188.6°)
  sky brightness:      Bortle 8 (City sky, SQM 17.75) (estimated from coordinates)
  moon:                78% illuminated (estimated for this date/time)
  limiting magnitude:  3.67
  faint-meteor factor: 0.075
  ~ estimated rate:    1.3 meteors/hour
  note: moonlight brightens the sky by 0.10 mag/arcsec²
  note: +1.0 more meteors/hour than observing at the shower's catalog peak from this location
```

This matters most when the catalog peak itself is a bad time to look --
e.g. the Quadrantids' 2027 peak falls with the radiant just below the
horizon from this location (an estimated 3.4 meteors/hour), but a few
hours later, once the radiant has climbed well clear of the horizon, the
same shower produces over five times the rate:

```
meteor-shower-forecast --lat 35.0 --lon -78.6 --shower Quadrantids
```

```
Quadrantids (QUA)
  parent body:         2003 EH1
  best:                Mon, Jan 4, 2027, 5:51 AM EST
  peak:                Sun, Jan 3, 2027, 7:21 PM EST
  days from peak:      +0.4
  effective ZHR:       34.8
  radiant altitude:    56.4° (az 52.3°)
  ...
  ~ estimated rate:    18.8 meteors/hour
  note: +15.4 more meteors/hour than observing at the shower's catalog peak from this location
```

Pass `--year YYYY` to look at a different year's occurrence instead of the
next upcoming one (past years work too) -- it still searches for that
year's best time, unlike `--when`, which pins one exact instant (the two
are mutually exclusive):

```
meteor-shower-forecast --lat 35.0 --lon -78.6 --shower Quadrantids --year 2020
```

`--shower NAME` without `--when` works the same way: it searches that
shower's own upcoming active window for the best time. Pass `--when`
explicitly to evaluate one specific date and time instead (e.g. checking a
shower a day past peak) -- the field is then labeled `time`, not `best`,
since it's exactly what you asked for rather than a search result.

Override the automatic moon estimate, or pass 0 to ignore moonlight
entirely:

```
meteor-shower-forecast --lat 35.0 --lon -78.6 \
  --shower Perseids --when 2026-08-12T05:00:00 --moon-illumination 80
```

Compare every shower's realistic best-case rate, each at its own best
upcoming observing time, sorted chronologically by (catalog) peak date
(pass `--when` to instead compare what's active on one specific night):

```
meteor-shower-forecast --lat 35.0 --lon -78.6 --shower all
```

Pass `--bortle <1-9>` or `--sqm <value>` to override the coordinate-based
sky-brightness estimate with a known Bortle class or a direct
[sky quality meter](https://en.wikipedia.org/wiki/Sky_quality_meter)
reading (mag/arcsec²).

List the known showers (all ~38 on the IMO Working List, each with its
IAU code and parent body when known; look one up by name, prefix, or IAU
code -- e.g. `--shower SDA`):

```
meteor-shower-forecast --list-showers
```

```
QUA   Quadrantids                      peak 01-04  ZHR 80, parent 2003 EH1
GUM   gamma-Ursae Minorids             peak 01-18  ZHR 3 (estimated decay rate)
...
PPU   pi-Puppids                       peak 04-23  no rate data (variable/outburst shower), parent 26P/Grigg-Skjellerup
```

### Activity decay rates

A rate estimate needs ZHR, population index, *and* an activity decay
rate -- how fast ZHR rises toward and falls away from the peak. IMO's
calendar publishes the first two for every shower on its Working List,
but not decay rates, so they come from elsewhere, and each shower's
source is recorded as `ShowerDef.decay_source` (and the CSV's
`decay_source` column).

#### The model

Activity follows the asymmetric log-linear profile used by both GMN
(`RMS/Formats/Showers.py`, `Shower.computeZHRFloat`) and Jenniskens
(1994):

```
ZHR(t) = ZHR_peak * 10^(-B * |t - t_peak|)
```

with `B = b_rise` before the peak and `B = b_decline` after it. Both
sources define B per degree of solar longitude; this package applies it
per day (the Sun moves ~0.986 degrees/day, so ~1.5% error most of the
year).

#### Sources, in order of preference

| `decay_source` | Showers | What it is |
|---|---|---|
| `gmn` | 21 | Measured: Global Meteor Network flux-monitoring fits |
| `jenniskens1994` | EGE, ACE | Measured: Jenniskens (1994) visual-observation fits |
| `imo_activity_estimate` | GUM, ELY, JPE, GDR, ERI, SLY, OCT, NOO, AND, PUP, COM | Estimated from the IMO activity period |
| *(none)* | PPU, JBO, AMO, PHO | Variable/outburst-only: no baseline ZHR to decay from |

**1. Global Meteor Network.** Transcribed from `share/flux_showers.csv`
in [CroatianMeteorNetwork/RMS](https://github.com/CroatianMeteorNetwork/RMS),
the "NASA Meteoroid Environment Office" section only. The file's other
sections use an undifferentiated `Bp = Bm = 0.2` placeholder rather than a
per-shower fit, so they're not used. Cite:

- Vida, D.; Segon, D.; Gural, P.S.; et al. 2021, "The Global Meteor
  Network -- Methodology and First Results", *MNRAS* 506(4), 5046-5074.
- Vida, D.; Blaauw Erskine, R.C.; Brown, P.G.; et al. 2022, "Computing
  optical meteor flux using Global Meteor Network data", *MNRAS* 515(2),
  2322-2339.

**2. Jenniskens (1994).** Measured B values for many minor annual
showers, from multi-year visual observations by Dutch Meteor Society
and Australian observers:

- Jenniskens, P. 1994, "Meteor stream activity I. The annual streams",
  *Astronomy & Astrophysics* 287, 990-1013.

The values here are transcribed from the Dutch Meteor Society's
[abridged edition of its data table](https://faculty.kfupm.edu.sa/PHYS/alshukri/Leonids/datalist.html),
not the paper itself -- verify against the original before citing. It
gives one symmetric B per shower. GMN's NASA-MEO set appears to derive
largely from this paper -- for the showers both cover, many values are
identical, so the two sources are consistent with each other:

| Shower | Jenniskens B | GMN b_rise / b_decline |
|---|---|---|
| Taurids | 0.026 | 0.026 / 0.026 (STA and NTA) |
| kappa-Cygnids | 0.069 | 0.069 / 0.069 |
| sigma-Hydrids | 0.10 | 0.10 / 0.10 |
| Orionids | 0.12 | 0.120 / 0.119 |
| Monocerotids | 0.25 | 0.25 / 0.25 |

Where they differ, it's mostly major showers (Perseids 0.20 vs 0.35;
Geminids 0.39/0.72 vs 0.15/0.462), which Jenniskens fit as a main peak
plus a separate broad background component.

Only two of the showers GMN lacks are covered: epsilon-Geminids (B 0.082,
ZHR 2.9 vs IMO's 3) and alpha-Centaurids (B 0.18, ZHR 7.3 vs IMO's 6).
Jenniskens' Puppid-Velids value (0.034) is deliberately *not* used: his
PUP is a broader multi-radiant complex (ZHR 4.5, maximum at 251 degrees)
than IMO's (ZHR 10, and IMO's 255 degrees is a radiant reference date
only), so his slope doesn't fit IMO's peak.

**3. Estimate from the IMO activity period.** For the remaining minor
showers, B is derived from the activity period (start and end dates)
that IMO's Table 5 lists for every shower. The assumption: those limits
mark where a shower stops being distinguishable, which happens at
roughly the same *absolute* ZHR for every minor shower. So on each side
of the peak:

```
B_side = log10(ZHR_peak / ZHR_edge) / (days from peak to activity limit)
```

`ZHR_edge` is calibrated from the GMN-measured showers -- the median,
over both sides of each, of `ZHR_peak * 10^(-B * days to limit)`. It
currently comes out at **0.46**, and `scripts/build_shower_catalog.py`
recomputes it on every run, so it tracks updated GMN or IMO data.

The calibration uses only the 12 GMN showers with ZHR <= 10 (AUR, CAP,
DRA, DSX, HYD, KCG, LMI, MON, NTA, SPE, STA, URS), since every shower
being estimated is in that range. Major showers' IMO activity limits
trace a broad, low-level background rather than the core peak GMN's B
fits: the Perseids' listed activity spans 38 days, but GMN's B = 0.35
takes them from ZHR 110 to 1 in about 6.

**Validation.** Each GMN shower was left out in turn, its B predicted
from the rest, and compared with GMN's measured value (both sides of
each shower, so 24 sides for the minor set, 42 for all 21):

| Edge assumption | Calibrated on | Median error | Within 2x |
|---|---|---|---|
| **Fixed absolute ZHR (used)** | **ZHR <= 10** | **0.20 dex (1.6x)** | **67%** |
| Fixed fraction of peak ZHR | ZHR <= 10 | 0.22 dex (1.6x) | 58% |
| Fixed absolute ZHR | all 21 | 0.28 dex (1.9x) | 57% |
| Fixed fraction of peak ZHR | all 21 | 0.35 dex (2.2x) | 43% |

As an independent check against the Jenniskens values (not used in the
calibration):

| Shower | Estimated b_rise / b_decline | Jenniskens B |
|---|---|---|
| epsilon-Geminids | 0.176 / 0.086 | 0.082 |
| alpha-Centaurids | 0.128 / 0.090 | 0.18 |
| Puppid-Velids | 0.210 / 0.154 | 0.034 |

EGE and ACE land within about 2x of the measured value; PUP is off by
about 5x, consistent with the definitional mismatch above. Where a
measured value exists it's used instead of the estimate.

Alternatives considered and rejected:

- **One B for every minor shower** (e.g. the median of the minor GMN
  showers, 0.1) -- simplest, but measured minor-shower values span
  0.026 (Taurids) to 0.25 (Monocerotids), so one number would be badly
  wrong for many showers.
- **GMN's 0.2 placeholder** -- not shower-specific; GMN itself doesn't
  present it as a fit.

Details of the estimate:

- Activity dates are taken as whole days: the start date from 00:00 UTC
  and the end date through 24:00 UTC.
- Each side is at least 1 day long. Without this, October
  Camelopardalids (listed activity Oct 5-6, peaking on the 6th) would get
  an arbitrarily steep decline driven by timing noise; it still gets
  B ~1.0, consistent with it being a brief shower.
- A lopsided activity period gives a lopsided B. September Lyncids
  (3 days before the peak, 25 after) gets 0.27 / 0.031, and Andromedids
  (24 before, 4 after) gets 0.042 / 0.223. That's what IMO's data
  implies, not a measured profile.

Every rate estimate for a shower with an estimated decay rate says so
in its `notes`, and `--list-showers` marks it "(estimated decay rate)".

#### How much the decay rate matters

For a minor shower, B barely affects the peak-night forecast -- at the
peak, the rate depends only on ZHR, radiant altitude, and sky
brightness. A ZHR-3 shower gives about one meteor an hour at the peak
whatever B is, against a sporadic background of ~5-10 an hour.

What B does decide is how many nights around the peak a shower counts
as active, since anything below ZHR 2 (`forecast.ZHR_DECAY_FLOOR`) is
treated as indistinguishable from the sporadic background. For a ZHR-3
shower that's `log10(3 / 2) / B` days either side of the peak: about
+/-7 days at B = 0.026, +/-1.8 days at B = 0.1, and +/-0.7 days at
B = 0.25. A ~1.6x error in B gives a proportional error in that window.

#### Showers with no rate estimate

The variable/outburst-only showers (e.g. alpha-Monocerotids) have no
baseline ZHR, so they get no decay rate and no rate estimate. Looking
one up by name/code still reports its radiant, population index, parent
body, and next peak date, with `estimated_rate_per_hour` (and the
fields that feed it) `None`.

### Estimating Bortle class from coordinates

By default, sky brightness is derived from `--lat`/`--lon` via the
[`bortlefinder`](https://pypi.org/project/bortlefinder/) package, which
reads a small, locally-fetched grid based on the Falchi et al. (2016)
*New World Atlas of Artificial Night Sky Brightness*
([doi.org/10.5880/GFZ.1.4.2016.001](https://doi.org/10.5880/GFZ.1.4.2016.001),
CC BY-NC 4.0) -- built primarily from the VIIRS Day/Night Band instrument
aboard the Suomi NPP satellite (a joint NASA/NOAA mission). Fetch it once
(downloads ~684MB, reduced to a ~19MB local grid; needs the `build`
extra):

```
pip install meteor-shower-forecast[build]
bortlefinder-fetch-grid
```

This derivation is approximate: satellite atlases measure *zenith*
brightness, while the Bortle scale is a subjective whole-sky rating
dominated by horizon light domes. David Lorenz's own validation against
paired dark-sky observations found real disagreement -- often a full
class, worse in the Bortle 5-7 range
([source](https://djlorenz.github.io/astronomy/lp/bortle.html)). Treat it
as a starting estimate, not a substitute for a real SQM meter reading --
every result marks whether its Bortle class was estimated or given
directly. See `bortlefinder`'s own README for more on its accuracy and
licensing (the fetched grid data is CC BY-NC 4.0, non-commercial; the
package code itself is MIT).

Moon illumination is likewise a low-precision approximation (mean lunar
elongation, no ephemeris) good to within a few percent -- see
`astro.moon_illumination_pct`.

The best-time search only considers
[astronomical night](https://en.wikipedia.org/wiki/Twilight#Astronomical_twilight)
(Sun below 18 degrees altitude), matching the fully-dark-sky assumption
already baked into the Bortle/SQM model, and never proposes a time before
now. If a
shower's whole active window at a given location never reaches
astronomical darkness (e.g. high-latitude summer), it falls back to the
least-bright moment found and says so in a note.

## Library

```python
from meteor_shower_forecast import estimate_shower_rate, estimate_all_showers, next_shower

estimate = estimate_shower_rate("Perseids", lat=35.0, lon=-78.6, bortle=4)
print(estimate.iau_code, estimate.parent_body, estimate.estimated_rate_per_hour)

for e in estimate_all_showers(lat=35.0, lon=-78.6, bortle=4):
    print(e.shower, e.estimated_rate_per_hour)

upcoming = next_shower()
print(upcoming.name)

# Any of the ~38 IMO-listed showers can be looked up by name/code;
# estimated_rate_per_hour is None for the variable/outburst-only showers,
# which have no baseline ZHR (e.g. alpha-Monocerotids).
monocerotids = estimate_shower_rate("AMO", lat=35.0, lon=-78.6, bortle=4)
print(monocerotids.radiant_alt_deg, monocerotids.estimated_rate_per_hour)

# reference anchors the best-time search to a given year (or any datetime)
# instead of now -- past years work too. Ignored if when_utc is passed.
from datetime import datetime, timezone
past = estimate_shower_rate(
    "Geminids", lat=35.0, lon=-78.6, bortle=4, reference=datetime(2020, 1, 1, tzinfo=timezone.utc)
)
print(past.when_utc, past.estimated_rate_per_hour)
```

## Development

```
git clone https://github.com/rtphokie/meteor-shower-forecast
cd meteor-shower-forecast
uv sync
```

The shower catalog (`src/meteor_shower_forecast/data/shower_catalog.csv`)
is generated, not hand-edited -- see `scripts/build_shower_catalog.py`'s
docstring to regenerate it from a newer IMO calendar or updated GMN data.
Rerunning it also recalibrates the activity-period decay estimates.
