BotShelf Vampire BOTSHELF VAMPIRE Register

Space: build with public space data

Turn NASA, NOAA, CelesTrak and Copernicus data into everyday practice: NEO briefs, ISS now, sunrise/sunset, Julian Day, toy ground tracks, satellite passes, Sentinel-2 scene finding, Kp and X-ray space-weather logs, GP group snapshots, SBDB lookups and ISS track samples. Research and education only.

Research and education only. Civil astronomy and open orbit data — not operational space-domain awareness, not targeting, and not a hazard or flare-warning service.

Interactive tool, runs in your browser (English / Japanese)
Satellite observation designer →
Compare public satellite data against your observation constraints.

Build recipes

Step-by-step workflows written by BSV. Each one combines real open tools into something you can make today.

Daily close-approach brief for near-Earth objects (with an optional AI summary)

Tested by BSV

Pull NASA/JPL close-approach data for the coming week and write a clean Markdown table you can read in a minute or hand to an AI agent.

Input
Start date (default today, UTC), days ahead (default 7), distance limit in au (default 0.05). No API key.
Output
neo_brief_<date>.md (closest first: distance in km and lunar distances, speed, absolute magnitude H) and neo_brief_<date>.json.
Prerequisites
Python 3.10 or later; standard library only.
Steps
  1. Save the code below as neo_brief.py.
  2. Run python neo_brief.py (or python neo_brief.py 2026-10-10 14 for another window).
  3. Open the .md file; check one object on JPL's Small-Body Database before quoting it anywhere.
  4. Optional AI step: pass the Markdown to a local model, for example ollama run llama3.2 "Summarise this table in three plain sentences. Do not add facts." < neo_brief_<date>.md.
  5. Schedule it (cron or Task Scheduler) to get a fresh brief every morning.
Expected result
A table of every approach inside the distance limit, closest first. Size is not published for most small objects, so the brief says so instead of guessing.
Next step
Add a check against JPL's Sentry impact-risk table for any object in your brief, or post the brief to your own channel with an AI agent from BSV's toolkit list. Open

The optional AI-summary step was not run by BSV.

Code (BSV original, MIT licence)

Download .py · neo_brief.py

#!/usr/bin/env python3
"""BSV recipe: near-Earth-object close-approach brief (NASA/JPL SBDB Close-Approach Data API).

Input : start date (default today UTC), days ahead (default 7), max distance in au (default 0.05).
Output: neo_brief_<date>.md (closest first) and neo_brief_<date>.json (the rows used). No API key needed.
Original BSV code, MIT. Data: NASA/JPL Center for Near Earth Object Studies (ssd-api.jpl.nasa.gov/cad.api).
"""
import datetime as dt, json, sys, urllib.parse, urllib.request

AU_KM = 149_597_870.7   # IAU 2012 definition of the astronomical unit
LD_KM = 384_400         # mean Earth-Moon distance used here for "lunar distances"

def fetch(start: str, days: int, dist_max: str) -> list[dict]:
    end = (dt.date.fromisoformat(start) + dt.timedelta(days=days)).isoformat()
    q = urllib.parse.urlencode({"date-min": start, "date-max": end, "dist-max": dist_max, "body": "Earth",
                                "sort": "dist", "fullname": "true", "diameter": "true"})
    with urllib.request.urlopen(f"https://ssd-api.jpl.nasa.gov/cad.api?{q}", timeout=30) as r:
        data = json.load(r)
    f = data.get("fields", [])
    rows = []
    for x in data.get("data", []):
        d = dict(zip(f, x))
        au = float(d["dist"])
        rows.append({"object": (d.get("fullname") or d["des"]).strip(), "time_tdb": d["cd"],
                     "dist_au": au, "dist_km": au * AU_KM, "dist_ld": au * AU_KM / LD_KM,
                     "v_rel_km_s": float(d["v_rel"]), "h_mag": d.get("h"),
                     "diameter_km": d.get("diameter"), "uncertainty": d.get("t_sigma_f")})
    return rows

def brief(start: str, days: int, rows: list[dict]) -> str:
    out = [f"# Close approaches to Earth, {start} + {days} days", "",
           f"{len(rows)} approaches within the distance limit (NASA/JPL CNEOS). Closest first.", "",
           "| Object | Time (TDB) | Distance | Lunar distances | Speed | H (mag) | Diameter |",
           "|---|---|---|---|---|---|---|"]
    for r in rows:
        dia = f"{r['diameter_km']} km" if r["diameter_km"] else "not published"
        out.append(f"| {r['object']} | {r['time_tdb']} ± {r['uncertainty']} | {r['dist_km']:,.0f} km | {r['dist_ld']:.2f} | "
                   f"{r['v_rel_km_s']:.1f} km/s | {r['h_mag']} | {dia} |")
    out += ["", "H is absolute magnitude (smaller = larger object). Size is unknown for most objects; do not guess it.",
            "Source: https://ssd-api.jpl.nasa.gov/cad.api — verify an object on JPL's Small-Body Database before quoting it."]
    return "\n".join(out) + "\n"

if __name__ == "__main__":
    start = sys.argv[1] if len(sys.argv) > 1 else dt.datetime.now(dt.timezone.utc).date().isoformat()
    days = int(sys.argv[2]) if len(sys.argv) > 2 else 7
    rows = fetch(start, days, sys.argv[3] if len(sys.argv) > 3 else "0.05")
    open(f"neo_brief_{start}.md", "w").write(brief(start, days, rows))
    json.dump(rows, open(f"neo_brief_{start}.json", "w"), indent=1)
    print(f"wrote neo_brief_{start}.md with {len(rows)} approaches")
Output of BSV's own test run

Run on 2026-10-06 23:37 JST · Python 3.13.5

wrote neo_brief_2026-10-06.md with 14 approaches

When can you see the ISS? A pass table for your city

Tested by BSV

Fetch fresh orbital elements from CelesTrak and let Skyfield compute rise, highest point and set times above your location, with a sunlit flag.

Input
NORAD catalogue number (default 25544, the ISS), latitude, longitude, hours ahead.
Output
passes_<number>.csv with rise / culmination / set times in UTC, maximum elevation and a sunlit flag.
Prerequisites
Python 3.10 or later and pip install skyfield. The first run downloads a 17 MB planetary ephemeris for the sunlit flag.
Steps
  1. Save the code below as iss_passes.py.
  2. Run it for your location, for example Tokyo: python iss_passes.py 25544 35.68 139.77 48.
  3. Open the CSV. Passes high in the sky while you are in darkness and the satellite is sunlit are the ones worth going outside for.
  4. Convert the UTC times to your time zone when you share them.
  5. Try another object by its NORAD number, such as the Hubble Space Telescope (20580).
Expected result
A short list of passes for the next hours with times and elevations. The script also prints the age of the orbital elements; predictions drift as they get older, so refresh daily.
Next step
Turn the CSV into calendar reminders, or combine it with the Sentinel-2 recipe to see what a satellite actually photographed over your area. Open
Code (BSV original, MIT licence)

Download .py · iss_passes.py

#!/usr/bin/env python3
"""BSV recipe: visible-pass table for a satellite over your location (CelesTrak GP data + Skyfield).

Input : NORAD catalog number (default 25544 = ISS), latitude, longitude, hours ahead.
Output: passes_<catnr>.csv with rise / culmination / set times (UTC), max elevation and whether the satellite is sunlit.
Original BSV code, MIT. Orbit data: CelesTrak GP (TLE format). Library: Skyfield (MIT).
"""
import csv, sys, urllib.request
from skyfield.api import EarthSatellite, load, wgs84

def main(catnr="25544", lat=35.6812, lon=139.7671, hours=24, min_el=10.0):
    url = f"https://celestrak.org/NORAD/elements/gp.php?CATNR={catnr}&FORMAT=TLE"
    with urllib.request.urlopen(url, timeout=30) as r:
        name, l1, l2 = [x.strip() for x in r.read().decode().strip().splitlines()[:3]]
    ts = load.timescale()
    sat = EarthSatellite(l1, l2, name, ts)
    here = wgs84.latlon(float(lat), float(lon))
    t0 = ts.now(); t1 = ts.tt_jd(t0.tt + float(hours) / 24)
    age_days = t0 - sat.epoch
    times, events = sat.find_events(here, t0, t1, altitude_degrees=float(min_el))
    eph = None
    try:
        eph = load("de421.bsp")  # ~17 MB once; needed only for the sunlit flag
    except Exception as e:
        print("sunlit flag skipped:", e)
    rows, cur = [], {}
    for t, ev in zip(times, events):
        key = ("rise", "culminate", "set")[ev]
        cur[key] = t.utc_strftime("%Y-%m-%d %H:%M:%S")
        if ev == 1:
            alt, _, _ = (sat - here).at(t).altaz()
            cur["max_el_deg"] = round(alt.degrees, 1)
            cur["sunlit"] = bool(sat.at(t).is_sunlit(eph)) if eph else ""
        if ev == 2 and "rise" in cur:
            rows.append(cur); cur = {}
    with open(f"passes_{catnr}.csv", "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=["rise", "culminate", "set", "max_el_deg", "sunlit"])
        w.writeheader(); w.writerows(rows)
    print(f"{name}: TLE age {age_days:.2f} d, {len(rows)} passes above {min_el} deg in {hours} h -> passes_{catnr}.csv")

if __name__ == "__main__":
    main(*sys.argv[1:])
Output of BSV's own test run

Run on 2026-10-06 23:37 JST · Python 3.13.5, skyfield 1.55

ISS (ZARYA): TLE age 0.59 d, 4 passes above 10.0 deg in 24 h -> passes_25544.csv

Find a cloud-free Sentinel-2 image of your area

Tested by BSV

Search the public Earth Search STAC catalogue for Sentinel-2 scenes over a bounding box, sorted by cloud cover, with direct links to the preview and the true-colour image.

Input
Bounding box (min lon, min lat, max lon, max lat), date range, maximum cloud cover %.
Output
scenes.csv: scene id, date, cloud %, preview URL, true-colour Cloud-Optimised GeoTIFF URL.
Prerequisites
Python 3.10 or later and pip install pystac-client. No account needed for searching.
Steps
  1. Save the code below as s2_scene_finder.py.
  2. Run it for your area: python s2_scene_finder.py 139.70,35.62,139.82,35.72 2026-08-01/2026-09-30 20.
  3. Open the preview URL of the first row to check the scene by eye.
  4. Open the visual COG URL in QGIS (Layer → Add Raster Layer → protocol HTTP) to view it at full resolution without downloading the whole file.
  5. Widen the date range or raise the cloud limit if nothing comes back.
Expected result
A CSV of matching scenes, least cloudy first. In a rainy season the list can be short; that is the data, not a bug.
Next step
Compare two dates of the same place to see change, or move on to Copernicus Data Space Ecosystem for processing in the cloud. Open
Code (BSV original, MIT licence)

Download .py · s2_scene_finder.py

#!/usr/bin/env python3
"""BSV recipe: find low-cloud Sentinel-2 L2A scenes for an area (Earth Search STAC API + pystac-client).

Input : bounding box (min_lon min_lat max_lon max_lat), date range, max cloud cover %.
Output: scenes.csv (id, date, cloud %, thumbnail URL, true-colour COG URL), least cloudy first.
Original BSV code, MIT. Catalogue: Element 84 Earth Search (Sentinel-2 COGs on AWS Open Data).
Data: Copernicus Sentinel data, subject to the Copernicus data terms.
"""
import csv, sys
from pystac_client import Client

def main(bbox="139.70,35.62,139.82,35.72", dates="2026-08-01/2026-09-30", max_cloud="20"):
    cat = Client.open("https://earth-search.aws.element84.com/v1")
    search = cat.search(collections=["sentinel-2-l2a"], bbox=[float(x) for x in bbox.split(",")],
                        datetime=dates, query={"eo:cloud_cover": {"lt": float(max_cloud)}}, max_items=50)
    items = sorted(search.items(), key=lambda it: it.properties.get("eo:cloud_cover", 100))
    with open("scenes.csv", "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "date", "cloud_pct", "thumbnail", "visual_cog"])
        for it in items:
            a = it.assets
            w.writerow([it.id, it.datetime.date().isoformat(), round(it.properties.get("eo:cloud_cover", -1), 2),
                        a["thumbnail"].href if "thumbnail" in a else "", a["visual"].href if "visual" in a else ""])
    print(f"{len(items)} scenes under {max_cloud}% cloud -> scenes.csv")
    if items:
        print("least cloudy:", items[0].id, items[0].properties.get("eo:cloud_cover"))

if __name__ == "__main__":
    main(*sys.argv[1:])
Output of BSV's own test run

Run on 2026-10-06 23:37 JST · Python 3.13.5, pystac-client 0.9.0

1 scenes under 20% cloud -> scenes.csv
least cloudy: S2B_54SUE_20260824_0_L2A 10.244737

A geomagnetic storm watch log from the Kp index

Tested by BSV

Read NOAA SWPC's planetary K-index feed, append new 3-hour readings to a CSV and flag anything at storm level.

Input
Threshold Kp (default 5, the level NOAA's space weather scales call a G1 minor storm).
Output
kp_log.csv (time, Kp), growing with each run, plus printed lines for readings at or above the threshold.
Prerequisites
Python 3.10 or later; standard library only.
Steps
  1. Save the code below as kp_watch.py.
  2. Run python kp_watch.py once to create kp_log.csv.
  3. Schedule it every 3 hours; only new readings are added.
  4. Plot the CSV to see quiet and stormy periods; compare them with aurora reports or GNSS issues you noticed.
  5. Lower the threshold to 4 if you want early notice.
Expected result
A growing log of real readings. Whether any line crosses the threshold depends entirely on the Sun that week.
Next step
Snapshot a CelesTrak GP group next, or look up one body on JPL SBDB. Open
Code (BSV original, MIT licence)

Download .py · kp_watch.py

#!/usr/bin/env python3
"""BSV recipe: geomagnetic storm watch log from NOAA SWPC's planetary K-index feed.

Input : threshold Kp (default 5 = NOAA G1 'minor storm' level on the NOAA space weather scales).
Output: appends new 3-hour readings to kp_log.csv and prints any reading at or above the threshold.
Original BSV code, MIT. Data: NOAA Space Weather Prediction Center (public domain, US Government).
"""
import csv, json, os, sys, urllib.request

URL = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"

def main(threshold="5"):
    with urllib.request.urlopen(URL, timeout=30) as r:
        data = json.load(r)
    if data and isinstance(data[0], list):   # older table form: header row + rows
        hdr, rows = data[0], data[1:]
        rows = [dict(zip(hdr, x)) for x in rows]
    else:
        rows = data
    seen = set()
    if os.path.exists("kp_log.csv"):
        seen = {r["time_tag"] for r in csv.DictReader(open("kp_log.csv"))}
    new = [r for r in rows if r["time_tag"] not in seen]
    write_header = not os.path.exists("kp_log.csv")
    with open("kp_log.csv", "a", newline="") as f:
        w = csv.writer(f)
        if write_header:
            w.writerow(["time_tag", "kp"])
        for r in new:
            w.writerow([r["time_tag"], r.get("Kp", r.get("kp_index"))])
    hits = [r for r in rows if float(r.get("Kp", r.get("kp_index", 0))) >= float(threshold)]
    print(f"{len(rows)} readings in feed, {len(new)} new logged; latest {rows[-1]['time_tag']} Kp={rows[-1].get('Kp', rows[-1].get('kp_index'))}")
    for r in hits:
        print("AT/ABOVE THRESHOLD:", r["time_tag"], r.get("Kp", r.get("kp_index")))

if __name__ == "__main__":
    main(*sys.argv[1:])
Output of BSV's own test run

Run on 2026-10-06 23:37 JST · Python 3.13.5

60 readings in feed, 60 new logged; latest 2026-10-06T09:00:00 Kp=2.67
AT/ABOVE THRESHOLD: 2026-10-04T09:00:00 5.0
AT/ABOVE THRESHOLD: 2026-10-04T18:00:00 5.67
AT/ABOVE THRESHOLD: 2026-10-05T03:00:00 5.33

Snapshot a CelesTrak GP group to CSV

Tested by BSV

Download the current GP JSON for a named group (default stations) into celestrak_<group>.csv. Civil/education orbit-data handling only.

Input
CelesTrak GROUP name (default stations).
Output
celestrak_<group>.csv with name, NORAD id, epoch, mean motion, inclination, eccentricity.
Prerequisites
Python 3 stdlib + network access to celestrak.org.
Steps
  1. Save the script.
  2. Run: python celestrak_group_snapshot.py stations
  3. Respect CelesTrak terms; this is not an operational SSA product.
Expected result
Dozens of rows for stations. Epochs are as published that moment.
Next step
Look up one small body on JPL SBDB next. Open
Code (BSV original, MIT licence)

Download .py · celestrak_group_snapshot.py

#!/usr/bin/env python3
"""BSV recipe: snapshot a CelesTrak GP group (default: stations) into a CSV.

Civil / education orbit-data handling only. Not an operational SSA product and not for targeting.
Input : CelesTrak GROUP name (default stations).
Output: celestrak_<group>.csv with name, NORAD id, epoch, mean motion, inclination, eccentricity.
Original BSV code, MIT. Data: CelesTrak GP (see CelesTrak terms of use).
"""
import csv, json, sys, urllib.request

UA = {"User-Agent": "BSV-fields-recipe/1.0 (education; +https://botshelfvampire.com)"}

def main(group="stations"):
    group = (group or "stations").strip().lower()
    url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=45) as r:
        data = json.loads(r.read().decode("utf-8"))
    if not isinstance(data, list) or not data:
        raise SystemExit(f"empty or unexpected CelesTrak response for group={group!r}")
    rows = []
    for o in data:
        rows.append({
            "object_name": o.get("OBJECT_NAME"),
            "norad_cat_id": o.get("NORAD_CAT_ID"),
            "epoch": o.get("EPOCH"),
            "mean_motion": o.get("MEAN_MOTION"),
            "eccentricity": o.get("ECCENTRICITY"),
            "inclination_deg": o.get("INCLINATION"),
            "raan_deg": o.get("RA_OF_ASC_NODE"),
        })
    out = f"celestrak_{group}.csv"
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader(); w.writerows(rows)
    print(f"wrote {len(rows)} objects from CelesTrak group={group!r} -> {out}")

if __name__ == "__main__":
    main(*sys.argv[1:2])
Output of BSV's own test run

Run on 2026-10-07 02:19 JST · Python 3.13.5

wrote 23 objects from CelesTrak group='stations' -> celestrak_stations.csv

Look up one small body on JPL SBDB

Tested by BSV

Fetch designation, orbit class and a few physical parameters into markdown + JSON. Not an impact forecast.

Input
Designation or name (default 433 = Eros).
Output
sbdb_<id>.md and sbdb_<id>.json.
Prerequisites
Python 3 stdlib + network access to ssd-api.jpl.nasa.gov.
Steps
  1. Save the script.
  2. Run: python jpl_sbdb_lookup.py 433
  3. Verify the object on JPL's SBDB lookup page before quoting.
Expected result
A short markdown file and the raw API JSON. Elements are JPL's, not BSV's.
Next step
Sample an ISS ground track from a fresh TLE. Open
Code (BSV original, MIT licence)

Download .py · jpl_sbdb_lookup.py

#!/usr/bin/env python3
"""BSV recipe: look up one small body on JPL SBDB and write a short markdown + JSON.

Education / research only. Orbital elements are as published by JPL; BSV does not propagate impacts
or claim hazard. Verify on the Small-Body Database before quoting.

Input : designation or name (default 433 = Eros).
Output: sbdb_<id>.md and sbdb_<id>.json
Original BSV code, MIT. Data: NASA/JPL SSD SBDB API.
"""
import json, re, sys, urllib.parse, urllib.request

UA = {"User-Agent": "BSV-fields-recipe/1.0 (education; +https://botshelfvampire.com)"}

def main(sstr="433"):
    sstr = (sstr or "433").strip()
    q = urllib.parse.urlencode({"sstr": sstr, "phys-par": "true"})
    url = f"https://ssd-api.jpl.nasa.gov/sbdb.api?{q}"
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=45) as r:
        data = json.loads(r.read().decode("utf-8"))
    obj = data.get("object") or {}
    orbit = data.get("orbit") or {}
    phys = {p.get("name"): p.get("value") for p in (data.get("phys_par") or []) if isinstance(p, dict)}
    name = obj.get("fullname") or obj.get("des") or sstr
    safe = re.sub(r"[^A-Za-z0-9._-]+", "_", str(obj.get("des") or sstr))[:40]
    md = [f"# JPL SBDB lookup — {name}", "",
          f"Query: `{sstr}`", "",
          "**Education/research only. Not an impact forecast.**", "",
          f"- designation: {obj.get('des')}",
          f"- fullname: {obj.get('fullname')}",
          f"- kind: {obj.get('kind')}",
          f"- orbit class: {(obj.get('orbit_class') or {}).get('name') if isinstance(obj.get('orbit_class'), dict) else obj.get('orbit_class')}",
          f"- epoch: {orbit.get('epoch')}",
          f"- data arc (days): {orbit.get('data_arc')}",
          f"- condition code: {orbit.get('condition_code')}",
          "", "## Selected physical parameters (as published)", ""]
    for k in sorted(phys)[:12]:
        md.append(f"- {k}: {phys[k]}")
    md += ["", f"Source: {url}", "Verify: https://ssd.jpl.nasa.gov/tools/sbdb_lookup.html", ""]
    open(f"sbdb_{safe}.md", "w", encoding="utf-8").write("\n".join(md) + "\n")
    json.dump(data, open(f"sbdb_{safe}.json", "w", encoding="utf-8"), indent=1)
    print(f"wrote sbdb_{safe}.md + sbdb_{safe}.json for {name!r}")

if __name__ == "__main__":
    main(*sys.argv[1:2])
Output of BSV's own test run

Run on 2026-10-07 02:19 JST · Python 3.13.5

wrote sbdb_433.md + sbdb_433.json for '433 Eros (A898 PA)'

Sample ISS ground-track points from a TLE

Tested by BSV

Pull a CelesTrak TLE for a NORAD id (default ISS) and write lat/lon/alt samples. Civil visualization only — not guidance or conjunction assessment.

Input
NORAD id (default 25544), hours (default 3), step minutes (default 5).
Output
track_<catnr>.csv with utc, lat_deg, lon_deg, alt_km.
Prerequisites
Python 3 + skyfield + network access to celestrak.org.
Steps
  1. Save the script.
  2. Run: python iss_tle_track_sample.py 25544 3 5
  3. Plot the CSV if you want; do not use it for navigation.
Expected result
Tens of rows for a 3-hour run. TLE age is whatever CelesTrak published.
Next step
Snapshot recent GOES X-ray flux from NOAA SWPC. Open
Code (BSV original, MIT licence)

Download .py · iss_tle_track_sample.py

#!/usr/bin/env python3
"""BSV recipe: sample ISS (or any NORAD id) ground-track points from a fresh CelesTrak TLE.

Civil / education visualization only. Not guidance, not conjunction assessment, not targeting.
Input : NORAD catalog number (default 25544 = ISS), hours (default 3), step minutes (default 5).
Output: track_<catnr>.csv with utc, lat_deg, lon_deg, alt_km
Original BSV code, MIT. Orbit data: CelesTrak GP. Library: Skyfield (MIT).
"""
import csv, sys, urllib.request
from skyfield.api import EarthSatellite, load, wgs84

UA = {"User-Agent": "BSV-fields-recipe/1.0 (education; +https://botshelfvampire.com)"}

def main(catnr="25544", hours="3", step_min="5"):
    catnr = str(catnr).strip() or "25544"
    hours = float(hours); step_min = float(step_min)
    url = f"https://celestrak.org/NORAD/elements/gp.php?CATNR={catnr}&FORMAT=TLE"
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=30) as r:
        lines = [x.strip() for x in r.read().decode().strip().splitlines()[:3]]
    name, l1, l2 = lines
    ts = load.timescale()
    sat = EarthSatellite(l1, l2, name, ts)
    t0 = ts.now()
    n = max(1, int(hours * 60 / step_min) + 1)
    rows = []
    for i in range(n):
        t = ts.tt_jd(t0.tt + (i * step_min) / (60 * 24))
        geo = wgs84.geographic_position_of(sat.at(t))
        rows.append({
            "utc": t.utc_strftime("%Y-%m-%dT%H:%M:%SZ"),
            "lat_deg": round(geo.latitude.degrees, 4),
            "lon_deg": round(geo.longitude.degrees, 4),
            "alt_km": round(geo.elevation.km, 2),
        })
    out = f"track_{catnr}.csv"
    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["utc", "lat_deg", "lon_deg", "alt_km"])
        w.writeheader(); w.writerows(rows)
    print(f"{name}: {len(rows)} samples over {hours} h step {step_min} min -> {out}")

if __name__ == "__main__":
    main(*sys.argv[1:4])
Output of BSV's own test run

Run on 2026-10-07 02:19 JST · Python 3.13.5, skyfield 1.55

ISS (ZARYA): 37 samples over 3.0 h step 5.0 min -> track_25544.csv

Snapshot NOAA GOES X-ray flux

Tested by BSV

Download the recent primary GOES X-ray JSON into xray_flux.csv. Education / space-weather literacy only — not a flare-warning desk.

Input
Optional feed URL (default NOAA GOES primary xrays-6-hour).
Output
xray_flux.csv with time_tag, energy, flux, satellite.
Prerequisites
Python 3 stdlib + network access to services.swpc.noaa.gov.
Steps
  1. Save the script.
  2. Run: python noaa_xray_flux_snapshot.py
  3. Read NOAA's X-ray product notes before interpreting flare classes.
Expected result
Hundreds of rows for the 6-hour feed. Flux values are NOAA's.
Next step
Snapshot ISS lat/lon from Open Notify next. Open
Code (BSV original, MIT licence)

Download .py · noaa_xray_flux_snapshot.py

#!/usr/bin/env python3
"""BSV recipe: snapshot recent GOES X-ray flux from NOAA SWPC into a CSV.

Education / space-weather literacy only. Not a flare-warning service and not medical advice about radiation.
Input : optional feed URL (default: NOAA GOES primary xrays-6-hour JSON).
Output: xray_flux.csv with time_tag, energy_band, flux
Original BSV code, MIT. Data: NOAA SWPC (US Government public domain).
"""
import csv, json, sys, urllib.request

UA = {"User-Agent": "BSV-fields-recipe/1.0 (education; +https://botshelfvampire.com)"}
DEFAULT = "https://services.swpc.noaa.gov/json/goes/primary/xrays-6-hour.json"

def main(url=DEFAULT):
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=45) as r:
        data = json.loads(r.read().decode("utf-8"))
    if not isinstance(data, list):
        raise SystemExit("unexpected NOAA xray JSON shape")
    rows = []
    for o in data:
        rows.append({
            "time_tag": o.get("time_tag"),
            "energy": o.get("energy"),
            "flux": o.get("flux"),
            "satellite": o.get("satellite"),
        })
    with open("xray_flux.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["time_tag", "energy", "flux", "satellite"])
        w.writeheader(); w.writerows(rows)
    latest = rows[-1]["time_tag"] if rows else "(none)"
    print(f"wrote {len(rows)} X-ray samples (latest {latest}) -> xray_flux.csv (NOAA SWPC)")

if __name__ == "__main__":
    main(*sys.argv[1:2])
Output of BSV's own test run

Run on 2026-10-07 02:19 JST · Python 3.13.5

wrote 714 X-ray samples (latest 2026-10-06T17:15:00Z) -> xray_flux.csv (NOAA SWPC)

Snapshot ISS lat/lon (Open Notify)

Tested by BSV

Live HTTP position. Civil/education only — not SSA or targeting.

Input
None (live HTTP).
Output
iss_now.csv.
Prerequisites
Python 3 + network.
Steps
  1. Save the script.
  2. Run: python open_notify_iss_now.py
  3. Read lat/lon.
Expected result
One row when the API is reachable.
Next step
Fetch sunrise/sunset next. Open
Code (BSV original, MIT licence)

Download .py · open_notify_iss_now.py

#!/usr/bin/env python3
"""BSV recipe: snapshot the ISS lat/lon from Open Notify.

Civil / education only — not SSA or targeting.
Input : none (live HTTP).
Output: iss_now.csv with timestamp, latitude, longitude, message.
Original BSV code, MIT. Data: api.open-notify.org.
"""
import csv, json, sys, urllib.request
UA = {"User-Agent": "BSV-fields-recipe/1.0 (education; +https://botshelfvampire.com)"}

def main():
    req = urllib.request.Request("http://api.open-notify.org/iss-now.json", headers=UA)
    with urllib.request.urlopen(req, timeout=30) as r:
        d = json.loads(r.read().decode())
    pos = d.get("iss_position") or {}
    row = {"timestamp": d.get("timestamp"), "latitude": pos.get("latitude"),
           "longitude": pos.get("longitude"), "message": d.get("message")}
    with open("iss_now.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(row.keys())); w.writeheader(); w.writerow(row)
    print(f"ISS now lat={row['latitude']} lon={row['longitude']} -> iss_now.csv")

if __name__ == "__main__":
    main()
Output of BSV's own test run

Run on 2026-10-07 03:12 JST · Python 3.13.5

ISS now lat=-8.1095 lon=171.5972 -> iss_now.csv

Sunrise/sunset for a lat/lon/date

Tested by BSV

UTC times via sunrise-sunset.org. Civil astronomy literacy only.

Input
lat lon date defaults 35.68 139.76 today-UTC.
Output
sunrise_sunset.csv.
Prerequisites
Python 3 + network.
Steps
  1. Save the script.
  2. Run: python sunrise_sunset_day.py 35.68 139.76
  3. Open sunrise_utc.
Expected result
One OK row with day_length_s.
Next step
Convert a date to Julian Day next. Open
Code (BSV original, MIT licence)

Download .py · sunrise_sunset_day.py

#!/usr/bin/env python3
"""BSV recipe: sunrise/sunset times for a lat/lon/date (sunrise-sunset.org).

Civil / education only.
Input : lat, lon, date YYYY-MM-DD (defaults 35.68 139.76 today UTC date).
Output: sunrise_sunset.csv.
Original BSV code, MIT. Data: api.sunrise-sunset.org (UTC times).
"""
import csv, datetime as dt, json, sys, urllib.parse, urllib.request
UA = {"User-Agent": "BSV-fields-recipe/1.0 (education; +https://botshelfvampire.com)"}

def main(lat="35.68", lon="139.76", date=""):
    lat, lon = float(lat), float(lon)
    if not date:
        date = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d")
    q = urllib.parse.urlencode({"lat": lat, "lng": lon, "date": date, "formatted": 0})
    url = f"https://api.sunrise-sunset.org/json?{q}"
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=30) as r:
        d = json.loads(r.read().decode())
    if d.get("status") != "OK":
        raise SystemExit(f"API status={d.get('status')!r}")
    res = d["results"]
    row = {"date": date, "lat": lat, "lon": lon, "sunrise_utc": res.get("sunrise"),
           "sunset_utc": res.get("sunset"), "day_length_s": res.get("day_length"),
           "solar_noon_utc": res.get("solar_noon")}
    with open("sunrise_sunset.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(row.keys())); w.writeheader(); w.writerow(row)
    print(f"{date} lat={lat} lon={lon} sunrise={row['sunrise_utc']} -> sunrise_sunset.csv")

if __name__ == "__main__":
    main(*sys.argv[1:4])
Output of BSV's own test run

Run on 2026-10-07 03:12 JST · Python 3.13.5

2026-10-06 lat=35.68 lon=139.76 sunrise=2026-10-05T20:38:23+00:00 -> sunrise_sunset.csv

Gregorian ↔ Julian Day Number

Tested by BSV

Meeus-style civil JD. Timekeeping literacy — not flight dynamics.

Input
YYYY-MM-DD or jd:<float> (default 2026-10-07).
Output
julian_day.csv.
Prerequisites
Python 3 stdlib.
Steps
  1. Save the script.
  2. Run: python julian_day_sample.py 2026-10-07
  3. Note noon-based JD.
Expected result
2026-10-07 → JD 2461320.5 here.
Next step
Sketch a toy circular ground track next. Open
Code (BSV original, MIT licence)

Download .py · julian_day_sample.py

#!/usr/bin/env python3
"""BSV recipe: Gregorian calendar ↔ Julian Day Number (Meeus-style civil algorithm).

Space timekeeping literacy / education only — not a flight dynamics product.
Input : YYYY-MM-DD (default 2026-10-07) or JD float to invert if prefixed with jd:
Output: julian_day.csv with calendar date and JD.
Original BSV code, MIT. Dependency: none.
"""
import csv, sys

def to_jd(y, m, d):
    if m <= 2:
        y -= 1; m += 12
    A = y // 100
    B = 2 - A + A // 4
    return int(365.25 * (y + 4716)) + int(30.6001 * (m + 1)) + d + B - 1524.5

def from_jd(jd):
    Z = int(jd + 0.5)
    F = jd + 0.5 - Z
    if Z < 2299161:
        A = Z
    else:
        alpha = int((Z - 1867216.25) / 36524.25)
        A = Z + 1 + alpha - alpha // 4
    B = A + 1524
    C = int((B - 122.1) / 365.25)
    D = int(365.25 * C)
    E = int((B - D) / 30.6001)
    day = B - D - int(30.6001 * E) + F
    month = E - 1 if E < 14 else E - 13
    year = C - 4716 if month > 2 else C - 4715
    return year, month, day

def main(arg="2026-10-07"):
    arg = (arg or "2026-10-07").strip()
    if arg.lower().startswith("jd:"):
        jd = float(arg.split(":", 1)[1])
        y, m, day = from_jd(jd)
        date = f"{y:04d}-{int(m):02d}-{int(day):02d}"
        jd_out = jd
    else:
        y, m, d = [int(x) for x in arg.split("-")]
        jd_out = to_jd(y, m, d)
        date = arg
        # round-trip check
        y2, m2, d2 = from_jd(jd_out)
    with open("julian_day.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["date", "jd", "note"])
        w.writeheader()
        w.writerow({"date": date, "jd": jd_out, "note": "Meeus civil algorithm; noon-based JD"})
    print(f"{date} -> JD {jd_out} -> julian_day.csv")

if __name__ == "__main__":
    main(*sys.argv[1:2])
Output of BSV's own test run

Run on 2026-10-07 03:12 JST · Python 3.13.5

2026-10-07 -> JD 2461320.5 -> julian_day.csv

Toy circular-orbit ground track

Tested by BSV

Cartoon samples from inclination+period. Not real ephemeris / not SSA.

Input
inc_deg period_min samples (defaults 51.6 92 48).
Output
ground_track.csv.
Prerequisites
Python 3 stdlib.
Steps
  1. Save the script.
  2. Run: python toy_ground_track.py
  3. Keep the toy caveat if you plot.
Expected result
samples rows over one cartoon period.
Next step
Return to the NEO brief or CelesTrak snapshot. Open
Code (BSV original, MIT licence)

Download .py · toy_ground_track.py

#!/usr/bin/env python3
"""BSV recipe: toy circular-orbit ground-track samples (not real ephemeris).

Education / intuition only — Keplerian cartoon, not operational orbit determination.
Input : inclination_deg, period_min, samples (defaults 51.6 92.0 48).
Output: ground_track.csv with t_min, lat_deg, lon_deg.
Original BSV code, MIT. Dependency: math.
"""
import csv, math, sys

def main(inc="51.6", period="92.0", samples="48"):
    inc, period, n = math.radians(float(inc)), float(period), int(samples)
    rows = []
    for i in range(n):
        t = period * i / n
        # simplistic: arg of latitude progresses uniformly; node fixed at 0 for the cartoon
        u = 2 * math.pi * i / n
        lat = math.degrees(math.asin(math.sin(inc) * math.sin(u)))
        # longitude: Earth rotation neglected in this toy — only orbital phase
        lon = math.degrees(math.atan2(math.cos(inc) * math.sin(u), math.cos(u)))
        rows.append({"t_min": round(t, 4), "lat_deg": lat, "lon_deg": lon})
    with open("ground_track.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=["t_min", "lat_deg", "lon_deg"])
        w.writeheader(); w.writerows(rows)
    print(f"wrote {len(rows)} toy ground-track points period={period}min -> ground_track.csv")

if __name__ == "__main__":
    main(*sys.argv[1:4])
Output of BSV's own test run

Run on 2026-10-07 03:12 JST · Python 3.13.5

wrote 48 toy ground-track points period=92.0min -> ground_track.csv

Compare the tools

Difficulty is BSV's own rating for a first project. Check each licence on the official page before you ship anything.

ToolJobLicenceDifficultyLocal / cloud
JPL SBDB Close-Approach Data APIAsteroid and comet close approachesUS Government public data (NASA/JPL)BeginnerCloud / web API
NASA APIs (NeoWs and others)Public NASA data APIs; free keyUS Government public data (NASA)BeginnerCloud / web API
CelesTrak GP dataCurrent orbital elements for satellitesFree public data (CelesTrak terms)BeginnerCloud / web API
JPL Small-Body Database APILookup elements and physical parameters for one bodyUS Government public data (NASA/JPL)BeginnerCloud / web API
NOAA SWPC GOES X-raysRecent solar X-ray flux JSON productsUS Government public domainBeginnerCloud / web API
SkyfieldPositions of stars, planets and satellites; pass predictionMITBeginnerLocal
AstropyCore astronomy library: time, coordinates, units, FITSBSD-3-ClauseIntermediateLocal
pystac-client + Earth SearchSearch satellite imagery catalogues (STAC)Apache-2.0 (client); Copernicus terms (data)IntermediateLocal + cloud
NASA GMATMission design and trajectory optimisationOpen source (NASA Software Catalog)AdvancedLocal

Starter stack: verified sources

Official pages only. BSV opened each link and recorded the HTTP status and date shown. We link out and summarise; we do not copy or rehost their code.

Related on BSV