ISS Pass Predictor for Your Location
Predict when the ISS will cross your sky using real orbital math.
Introduction
30 Second Summary
The International Space Station passes over your location several times a day, but most of those passes are completely invisible. The station might be hiding in Earth's shadow, or the sun might still be up and washing it out.
In this project, you will build a personal ISS pass predictor that calculates exactly when the station crosses your sky and filters for only the passes worth stepping outside for.
What You'll Build
Your finished app lets you click a button, detect your location, and see a timeline of upcoming visible ISS passes complete with compass directions and a "Tonight" badge on the ones you can watch right now.
By the end of this project, you'll have:
- A location-aware predictor that detects your coordinates and shows exactly when the ISS will next cross your sky.
- Pass cards with time windows, peak elevation, and compass directions so you know exactly where to look (like "SW to NE").
- A visibility filter that removes invisible passes and only shows the ones where the ISS is sunlit while you stand in darkness.
- Secret Mission: browser notifications that alert you 5 minutes before a visible pass so you never miss one.
Are there any prerequisites?
This project builds on OrbitX Projects 1-3. You should have an existing project with a React + Vite frontend and a FastAPI backend with a TLE cache and SQLite database.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you.
Set Up for Pass Predictions
Your backend can cache TLE orbital data from CelesTrak. But those raw numbers don't answer the question you actually care about: when will the ISS be overhead?
Translating a TLE into a concrete prediction requires computing the satellite's position over time relative to a specific spot on Earth. Skyfield is already installed in your virtual environment. In this step, you'll build a prediction module that uses it to calculate exactly when the ISS rises, peaks, and sets above your horizon.
In this step, get ready to:
- Create a pass prediction module that computes ISS horizon crossings from TLE data.
- Write and run a test script that prints upcoming passes for a hardcoded location.
Build the pass prediction module
The prediction module will take raw TLE lines and observer coordinates, then use Skyfield to compute every pass event over a time window. You'll build it piece by piece across four code chunks.
- Create a new file called pass_predictor.py inside your backend/app/ directory.
- Add the imports and module setup by pasting this code:
from skyfield.api import wgs84, EarthSatellite, Loader
from datetime import datetime, timezone
from pathlib import Path
# Set up a data directory for ephemeris files
data_dir = Path(__file__).parent.parent / "data"
data_dir.mkdir(exist_ok=True)
# Create a Skyfield loader, timescale, and ephemeris
sky_load = Loader(str(data_dir))
ts = sky_load.timescale()
eph = sky_load("de421.bsp")
What does this setup do?
Skyfield's Loader class manages file downloads and stores them in a data/ directory inside your backend folder.
The ts variable is a timescale object for all time conversions. The eph variable loads de421.bsp, a NASA JPL ephemeris file containing Earth and Sun position data. This file is about 17 MB and covers dates from 1900 to 2050.
- Save pass_predictor.py.
- Switch back to the terminal where your backend virtual environment is active.
- Make sure you are in the backend/ directory, then verify the module loads by running this command:
python -c "from app.pass_predictor import *; print('Ephemeris loaded')"
The first time you run this, Skyfield downloads the de421.bsp file. You should see a progress bar followed by Ephemeris loaded.
Seeing an ImportError?
- Make sure your virtual environment is active. You should see the environment name in your terminal prompt.
- Make sure you are running the command from inside the backend/ directory, not from inside backend/app/.
- Confirm Skyfield is installed by running pip list | grep skyfield. You should see version 1.54.
Still stuck? Help me debug this ImportError when loading pass_predictor.
- Now add the predict_passes() function below the module setup. Paste this code at the bottom of pass_predictor.py:
def predict_passes(
tle_line1: str,
tle_line2: str,
lat: float,
lng: float,
alt: float = 0.0,
days: int = 2,
visible_only: bool = False,
):
satellite = EarthSatellite(tle_line1, tle_line2, "ISS", ts)
observer = wgs84.latlon(lat, lng, elevation_m=alt)
t0 = ts.now()
t1 = t0 + days
# Find horizon crossing events in the time window
t, events = satellite.find_events(observer, t0, t1, altitude_degrees=10.0)
difference = satellite - observer
passes = []
current_pass = {}
peak_skyfield_time = None
for ti, event in zip(t, events):
if event == 0: # Rise above 10 degrees
topocentric = difference.at(ti)
_, rise_az, _ = topocentric.altaz()
current_pass = {
"rise_time": ti.utc_datetime().isoformat(),
"rise_azimuth": round(rise_az.degrees, 1),
}
What does this code do?
Skyfield's find_events() searches a time window for three types of horizon crossings. Event 0 means the satellite rose above 10 degrees. Event 1 means it reached its peak elevation (culmination). Event 2 means it dropped back below 10 degrees.
The altitude_degrees=10.0 threshold filters out low passes that are hard to see through buildings and trees. So far this code handles the rise event, recording the time and compass direction (azimuth).
- Continue the function by pasting this code directly below what you just added, still inside pass_predictor.py:
elif event == 1: # Culmination (peak elevation)
topocentric = difference.at(ti)
peak_alt, _, _ = topocentric.altaz()
current_pass["peak_time"] = ti.utc_datetime().isoformat()
current_pass["peak_elevation"] = round(peak_alt.degrees, 1)
peak_skyfield_time = ti
elif event == 2: # Set below 10 degrees
topocentric = difference.at(ti)
_, set_az, _ = topocentric.altaz()
current_pass["set_time"] = ti.utc_datetime().isoformat()
current_pass["set_azimuth"] = round(set_az.degrees, 1)
if "rise_time" in current_pass and "peak_time" in current_pass:
rise_dt = datetime.fromisoformat(current_pass["rise_time"])
set_dt = ti.utc_datetime()
current_pass["duration_seconds"] = round(
(set_dt - rise_dt).total_seconds()
)
# Filter by visibility if requested
if visible_only:
if _check_visibility(satellite, lat, lng, alt, peak_skyfield_time):
current_pass["visible"] = True
passes.append(current_pass)
else:
passes.append(current_pass)
current_pass = {}
peak_skyfield_time = None
return passes
What does this second half do?
Event 1 records the peak elevation. Event 2 records the set time and direction, then assembles the complete pass with a computed duration.
When visible_only is True, the function calls _check_visibility() before adding the pass. You'll add that helper next.
- Save pass_predictor.py.
- Verify the function loads by running this command:
python -c "from app.pass_predictor import predict_passes; print('predict_passes ready')"
You should see predict_passes ready with no errors.
Seeing a SyntaxError?
- Check that the indentation is consistent. The elif blocks must align with the if event == 0: block above them.
- Make sure the second code block starts at the same indentation level where the first block ended (inside the for loop).
- Verify that return passes is indented at the function level, not inside the for loop.
Still stuck? Help me fix this SyntaxError in predict_passes.
- Add the visibility check helper at the bottom of pass_predictor.py, below the predict_passes() function:
def _check_visibility(satellite, lat, lng, alt, peak_time):
"""A pass is visible when the satellite is sunlit AND the observer is in darkness."""
# Check if the satellite is illuminated by the Sun
sunlit = satellite.at(peak_time).is_sunlit(eph)
# Check if the observer is in darkness (Sun below -6 degrees)
earth = eph["earth"]
sun = eph["sun"]
observer_pos = earth + wgs84.latlon(lat, lng, elevation_m=alt)
sun_apparent = observer_pos.at(peak_time).observe(sun).apparent()
sun_alt, _, _ = sun_apparent.altaz()
in_darkness = sun_alt.degrees < -6
return bool(sunlit and in_darkness)
What makes a pass visible?
A satellite pass is only visible to the naked eye when three conditions are met at the same time: the satellite is above your horizon, the satellite is in sunlight, AND you (the observer) are in darkness.
The is_sunlit() method checks if the ISS catches sunlight at its orbital altitude. The Sun altitude check uses -6 degrees as the threshold for civil twilight. If the Sun is below that angle from your position, the sky is dark enough to spot a sunlit satellite.
- Save pass_predictor.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved pass_predictor.py.
ⓧ I'd like to double check the full code
Scroll up and confirm your pass_predictor.py contains all four sections in this order:
- Imports and module setup (from from skyfield.api through eph = sky_load("de421.bsp")).
- The predict_passes() function signature, setup, and event 0 (rise) handling.
- Event 1 (culmination) and event 2 (set) handling, through return passes.
- The _check_visibility() helper function at the bottom.
Test the predictions
The prediction module is ready. Now you'll write a quick test script to verify it produces real pass data from TLE lines.
- Create a new file called test_passes.py in your backend/ directory (not inside app/).
- Paste this code into the file:
"""Quick test script to verify Skyfield pass prediction works."""
from app.pass_predictor import predict_passes
# Example: London coordinates (replace with your own)
TEST_LAT = 51.5
TEST_LNG = -0.1
TEST_ALT = 0.0
# You will need a valid ISS TLE from your database.
# These are example lines - replace with current TLE from your cache.
TLE_LINE1 = "1 25544U 98067A 24001.00000000 .00016717 00000-0 10270-3 0 9002"
TLE_LINE2 = "2 25544 51.6400 208.5000 0007417 17.6667 85.6398 15.50103472000000"
print("Predicting ISS passes (all, no visibility filter)...")
passes = predict_passes(TLE_LINE1, TLE_LINE2, TEST_LAT, TEST_LNG, TEST_ALT, days=2)
print(f"Found {len(passes)} passes above 10 degrees:\n")
for i, p in enumerate(passes, 1):
print(f"Pass {i}:")
print(f" Rise: {p['rise_time']} (azimuth {p['rise_azimuth']})")
print(f" Peak: {p['peak_time']} (elevation {p['peak_elevation']})")
print(f" Set: {p['set_time']} (azimuth {p['set_azimuth']})")
print(f" Duration: {p['duration_seconds']}s")
print()
print("\nNow filtering for VISIBLE passes only...")
visible = predict_passes(TLE_LINE1, TLE_LINE2, TEST_LAT, TEST_LNG, TEST_ALT, days=2, visible_only=True)
print(f"Only {len(visible)} passes are actually visible!")
What does this test script do?
The script calls predict_passes() twice. The first call finds ALL passes above 10 degrees elevation. The second call adds visible_only=True to filter for only passes where the ISS is sunlit and you are in darkness.
The hardcoded TLE lines are sample data. For the most accurate results, replace them with a current ISS TLE from your SQLite cache (NORAD ID 25544).
Use your own TLE data
You can query your SQLite database for a fresh ISS TLE. Open a Python shell from your backend/ directory and run:
from app.tle_cache import get_db_connection
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT tle_line1, tle_line2 FROM satellites WHERE norad_id = ?", (25544,))
print(cursor.fetchone())
conn.close()
Copy the two TLE strings into your test script for more accurate predictions.
Before you run the test, think about this: the ISS orbits Earth roughly every 90 minutes. Over 2 days, how many passes do you think reach above 10 degrees at a single location?
- Save test_passes.py.
- Run the test script with this command:
python test_passes.py
You should see 5-15 pass events printed, each with rise time, peak elevation, set time, azimuth, and duration. Then the visibility filter reduces the count to just 2-5 passes.
Even though the ISS completes about 32 orbits in 2 days, only a fraction take it above 10 degrees from any single spot on Earth. The visibility filter cuts even deeper because most passes happen when the ISS is in Earth's shadow.
Not seeing any passes?
- Make sure you are running python test_passes.py from the backend/ directory, not from backend/app/.
- If you see 0 passes, try using a more recent TLE from your database. Old TLE lines produce predictions at historical dates, not the current time window.
- If you see a ModuleNotFoundError, confirm that pass_predictor.py is inside backend/app/ and test_passes.py is inside backend/.
Still stuck? Help me debug why my test_passes.py is not producing pass data.
✔️ Awesome, I've got everything!
Great. Double check you've saved test_passes.py.
ⓧ I'd like to double check the full code
"""Quick test script to verify Skyfield pass prediction works."""
from app.pass_predictor import predict_passes
# Example: London coordinates (replace with your own)
TEST_LAT = 51.5
TEST_LNG = -0.1
TEST_ALT = 0.0
# You will need a valid ISS TLE from your database.
# These are example lines - replace with current TLE from your cache.
TLE_LINE1 = "1 25544U 98067A 24001.00000000 .00016717 00000-0 10270-3 0 9002"
TLE_LINE2 = "2 25544 51.6400 208.5000 0007417 17.6667 85.6398 15.50103472000000"
print("Predicting ISS passes (all, no visibility filter)...")
passes = predict_passes(TLE_LINE1, TLE_LINE2, TEST_LAT, TEST_LNG, TEST_ALT, days=2)
print(f"Found {len(passes)} passes above 10 degrees:\n")
for i, p in enumerate(passes, 1):
print(f"Pass {i}:")
print(f" Rise: {p['rise_time']} (azimuth {p['rise_azimuth']})")
print(f" Peak: {p['peak_time']} (elevation {p['peak_elevation']})")
print(f" Set: {p['set_time']} (azimuth {p['set_azimuth']})")
print(f" Duration: {p['duration_seconds']}s")
print()
print("\nNow filtering for VISIBLE passes only...")
visible = predict_passes(TLE_LINE1, TLE_LINE2, TEST_LAT, TEST_LNG, TEST_ALT, days=2, visible_only=True)
print(f"Only {len(visible)} passes are actually visible!")
Your prediction engine is working. You can compute exactly when the ISS crosses any observer's sky. Next up, you'll get the user's real location so these predictions are personalized to where they actually stand.
Get the User's Location
Your backend can now compute ISS pass times from real orbital data. You proved this in the last step when Skyfield printed rise, peak, and set times for a hardcoded London location.
The keyword there is "hardcoded." Pass predictions are observer-specific. The ISS might be directly overhead for someone in London while below the horizon for someone in Tokyo. Your app needs the user's actual latitude and longitude to give them accurate predictions.
In this step, get ready to:
- Build a LocationPicker component with browser geolocation and a manual coordinate form.
- Wire the component into your App so location state is stored and displayed.
- Test error handling by denying the browser permission prompt.
Create the LocationPicker component
The Geolocation API is a browser feature that lets a webpage request the user's coordinates. You will build a React component that uses this API with a manual form as fallback.
- Create a new file at frontend/src/components/LocationPicker.tsx.
- Start the file with the imports, type definitions, and component scaffolding by pasting this code:
import { useState } from "react";
interface Location {
lat: number;
lng: number;
alt: number;
}
interface LocationPickerProps {
onLocationSet: (location: Location) => void;
}
export default function LocationPicker({ onLocationSet }: LocationPickerProps) {
const [manualLat, setManualLat] = useState("");
const [manualLng, setManualLng] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
What does this code do?
The Location interface defines the shape of location data: latitude, longitude, and altitude.
LocationPickerProps takes a callback function onLocationSet that sends coordinates up to the parent App component. The four state hooks track the manual form inputs, error messages, and whether a location request is in progress.
- Directly below the state hooks, add the useMyLocation function that calls the browser Geolocation API:
// Request browser location, show error if denied
function useMyLocation() {
setLoading(true);
setError(null);
if (!navigator.geolocation) {
setError("Geolocation is not supported by your browser.");
setLoading(false);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
onLocationSet({
lat: position.coords.latitude,
lng: position.coords.longitude,
alt: position.coords.altitude ?? 0,
});
setLoading(false);
},
() => {
setError("Location access denied. Please enter coordinates manually.");
setLoading(false);
}
);
}
What does this function do?
- navigator.geolocation.getCurrentPosition() is the browser's built-in method for requesting location. It takes two callbacks: one for success, one for failure.
- On success, it sends the latitude, longitude, and altitude to the parent via onLocationSet. The ?? 0 handles devices that do not report altitude.
- On failure (permission denied), it sets an error message guiding the user to the manual form instead.
- Below useMyLocation, add the handleManualSubmit function for the manual coordinate form:
// Validate and submit manually entered coordinates
function handleManualSubmit(e: React.FormEvent) {
e.preventDefault();
const lat = parseFloat(manualLat);
const lng = parseFloat(manualLng);
if (isNaN(lat) || isNaN(lng)) {
setError("Please enter valid numbers for latitude and longitude.");
return;
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
setError("Latitude must be -90 to 90, longitude -180 to 180.");
return;
}
setError(null);
onLocationSet({ lat, lng, alt: 0 });
}
What does this function do?
This handler parses the text inputs into numbers, then validates that latitude is between -90 and 90 and longitude is between -180 and 180. If either value is outside these ranges or not a number, the user sees a specific error message instead of a silent failure.
- Finally, add the JSX return block that closes out the component. Paste this directly below handleManualSubmit:
return (
<div className="location-picker">
<button
className="location-btn"
onClick={useMyLocation}
disabled={loading}
>
{loading ? "Getting location..." : "Use My Location"}
</button>
{error && <p className="location-error">{error}</p>}
<form className="manual-location" onSubmit={handleManualSubmit}>
<p>Or enter coordinates manually:</p>
<div className="manual-inputs">
<input
type="text"
placeholder="Latitude (e.g., 51.5)"
value={manualLat}
onChange={(e) => setManualLat(e.target.value)}
/>
<input
type="text"
placeholder="Longitude (e.g., -0.1)"
value={manualLng}
onChange={(e) => setManualLng(e.target.value)}
/>
<button type="submit">Set Location</button>
</div>
</form>
</div>
);
}
What does this JSX render?
- A Use My Location button that triggers the Geolocation API. It disables itself while loading to prevent double-clicks.
- An error paragraph that only appears when error is not null.
- A manual form with latitude and longitude inputs, always visible as a fallback option.
- Save LocationPicker.tsx.
✔️ Awesome, I've got everything!
Great. Make sure you have saved LocationPicker.tsx before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these four sections appears in your file, in order:
- The import statement, Location and LocationPickerProps interfaces, the component declaration, and four useState hooks.
- The useMyLocation() function with getCurrentPosition and both success/error callbacks.
- The handleManualSubmit() function with parseFloat and range validation.
- The return block with the button, error display, and manual form, ending with a closing } for the component.
Wire LocationPicker into your App
The component is ready, but it is not rendered anywhere yet. You need to import it into your App component and give it a state variable to write the coordinates into.
- Open frontend/src/App.tsx in your editor.
- Add this import at the top of the file, alongside your existing imports:
import LocationPicker from "./components/LocationPicker";
- Inside your App component function, add a state variable to hold the user's location. Place this alongside your existing state declarations:
// Store the user's location when they set it
const [location, setLocation] = useState<{
lat: number;
lng: number;
alt: number;
} | null>(null);
- In your App's JSX return, add the LocationPicker component and a coordinate display. Place these wherever your UI layout fits best (for example, below your existing globe or telemetry panel):
<!-- Location picker and coordinate display -->
<LocationPicker onLocationSet={setLocation} />
{location && (
<p>
Location: {location.lat.toFixed(4)}°, {location.lng.toFixed(4)}°
</p>
)}
What does this wiring do?
When LocationPicker calls onLocationSet (either from the browser API or the manual form), it writes the coordinates into App's location state. The conditional paragraph only renders when location is not null, confirming the coordinates were stored.
- Save App.tsx.
- Make sure your frontend dev server is still running (npm run dev in the frontend folder). Refresh your browser.
You should see a Use My Location button and the manual coordinate form with latitude and longitude inputs.
- Click Use My Location.
- Your browser will ask for permission to share your location. Click Allow.
Your coordinates should appear below the button, showing your latitude and longitude to four decimal places.
Don't see the button or form?
- Check your terminal for TypeScript errors. A missing closing brace or a typo in a type annotation will prevent the component from rendering.
- Make sure the import path in App.tsx matches the file location: ./components/LocationPicker.
- Confirm that useState is imported from "react" in both LocationPicker.tsx and App.tsx.
Why isn't my LocationPicker component rendering in the browser?
Test the error handling path
The auto-detect path works when the user clicks Allow. But what happens when they click Block? A broken error path means stranded users with no way to enter their location.
Why test the denial path?
Many users deny location permission by default, especially on first visit. If your component does not handle this gracefully, those users see nothing and cannot use your app at all. The manual form is your safety net.
Before you test this, think about what you expect to happen. When you deny location permission, will the button stay in its loading state? Will the page go blank? Or will the error callback fire?
- You need to reset the permission so the browser asks again. Open your browser's site settings for localhost and reset the location permission to its default state.
- Refresh the page.
- Click Use My Location.
- When the browser asks for location permission, click Block (or Don't Allow on some browsers).
You should see the error message "Location access denied. Please enter coordinates manually." appear in red below the button. The manual form is right there, ready to use.
- Now test the manual path. Enter 51.5 in the latitude field and -0.1 in the longitude field.
- Click Set Location.
The coordinates should appear in the UI, just like they did with the auto-detect path. Both routes lead to the same result: a stored location that your app can use for pass predictions.
Error message not appearing after blocking?
- If the button stays on "Getting location..." forever, check that the error callback in getCurrentPosition calls both setError() and setLoading(false).
- Some browsers silently deny without prompting if you previously blocked the permission. Clear site data for localhost and try again.
My Geolocation error callback is not firing when I block the permission. What could be wrong?
Your app now knows exactly where the user is standing. Next up, you will build the backend endpoint that takes these coordinates and returns a list of upcoming ISS passes.
Build the Pass Prediction Endpoint
Your frontend can capture the user's coordinates, but there is nothing on the backend to turn those coordinates into actual pass predictions. The question "when will the ISS be overhead?" depends entirely on where the observer is standing.
In this step, you'll build a prediction module that uses Skyfield's find_events() method to compute ISS horizon crossings, then expose those predictions through a FastAPI endpoint your frontend can call.
In this step, get ready to:
- Build the predict_passes() function that computes ISS horizon crossings for an observer.
- Add a POST /api/passes endpoint that fetches the ISS TLE and returns passes.
- Test the endpoint with your coordinates and see real pass data.
Create the pass predictor module
Skyfield needs a few pieces of setup before it can predict passes: a timescale for date math, and a planetary ephemeris file so it knows where the Sun is (you'll need that later for visibility checks).
- Create a new file called pass_predictor.py inside your backend/app/ folder.
- Add the module setup by pasting this code:
from skyfield.api import wgs84, EarthSatellite, Loader
from datetime import datetime, timezone
from pathlib import Path
# Set up a data directory for Skyfield's downloaded files
data_dir = Path(__file__).parent.parent / "data"
data_dir.mkdir(exist_ok=True)
# Create a Skyfield loader, timescale, and load the planetary ephemeris
sky_load = Loader(str(data_dir))
ts = sky_load.timescale()
eph = sky_load("de421.bsp")
What does this setup do?
Loader(str(data_dir)) tells Skyfield to save downloaded files into backend/data/. The de421.bsp file is a NASA JPL planetary ephemeris (~17 MB) that tells Skyfield where the Sun and Earth are at any given time.
The first time this module loads, Skyfield auto-downloads de421.bsp. After that, it uses the cached copy.
- Save pass_predictor.py.
- Trigger the ephemeris download by importing the module. Run this from your backend/ directory:
python -c "from app import pass_predictor; print('Module loaded')"
You should see Skyfield download de421.bsp and then print Module loaded. The download only happens once.
Module not loading?
- Make sure you are running the command from the backend/ directory, not the project root.
- Check that your virtual environment is activated. Skyfield must be installed in it.
- Verify the file is saved as backend/app/pass_predictor.py (inside app/, not directly in backend/).
Still stuck? Ask for help.
Now you'll build the core function that finds ISS passes over an observer's location. Skyfield's find_events() detects three types of events: rise (event 0), culmination (event 1), and set (event 2). Your function groups these into complete passes with timing and direction data.
- Add the first part of the predict_passes() function below the module setup in pass_predictor.py:
def predict_passes(
tle_line1: str,
tle_line2: str,
lat: float,
lng: float,
alt: float = 0.0,
days: int = 2,
visible_only: bool = False,
):
satellite = EarthSatellite(tle_line1, tle_line2, "ISS", ts)
observer = wgs84.latlon(lat, lng, elevation_m=alt)
t0 = ts.now()
t1 = t0 + days
# Find all pass events above 10 degrees elevation
t, events = satellite.find_events(observer, t0, t1, altitude_degrees=10.0)
difference = satellite - observer
passes = []
current_pass = {}
peak_skyfield_time = None
for ti, event in zip(t, events):
if event == 0:
# Rise: satellite crosses above the horizon
topocentric = difference.at(ti)
_, rise_az, _ = topocentric.altaz()
current_pass = {
"rise_time": ti.utc_datetime().isoformat(),
"rise_azimuth": round(rise_az.degrees, 1),
}
What does this code do?
- EarthSatellite() builds a satellite object from TLE lines. wgs84.latlon() places the observer on Earth's surface.
- find_events() scans the time window and returns every moment the satellite rises, peaks, or sets. The altitude_degrees=10.0 filter skips passes too low on the horizon to see clearly.
- satellite - observer creates a vector difference used to compute the satellite's direction (azimuth) and height (altitude) from the observer's perspective.
- Continue the function by adding the culmination and set event handling. Paste this code directly after the rise event block, still inside the for loop:
elif event == 1:
# Culmination: satellite reaches peak elevation
topocentric = difference.at(ti)
peak_alt, _, _ = topocentric.altaz()
current_pass["peak_time"] = ti.utc_datetime().isoformat()
current_pass["peak_elevation"] = round(peak_alt.degrees, 1)
peak_skyfield_time = ti
elif event == 2:
# Set: satellite drops below the horizon
topocentric = difference.at(ti)
_, set_az, _ = topocentric.altaz()
current_pass["set_time"] = ti.utc_datetime().isoformat()
current_pass["set_azimuth"] = round(set_az.degrees, 1)
if "rise_time" in current_pass and "peak_time" in current_pass:
rise_dt = datetime.fromisoformat(current_pass["rise_time"])
set_dt = ti.utc_datetime()
current_pass["duration_seconds"] = round(
(set_dt - rise_dt).total_seconds()
)
if visible_only:
if _check_visibility(satellite, lat, lng, alt, peak_skyfield_time):
current_pass["visible"] = True
passes.append(current_pass)
else:
passes.append(current_pass)
current_pass = {}
peak_skyfield_time = None
return passes
What's happening in the event loop?
Each pass has three events: rise (0), culmination (1), and set (2). The loop collects all three into a single current_pass dictionary.
At culmination, peak_elevation records how high the ISS gets. At rise and set, rise_azimuth and set_azimuth record the compass direction. When the set event fires, the function calculates duration and appends the completed pass.
- Save pass_predictor.py.
- Test that the prediction function works by running this from your backend/ directory:
python -c "from app.pass_predictor import predict_passes; print('predict_passes loaded OK')"
You should see predict_passes loaded OK. The function references _check_visibility which does not exist yet, but that is fine because Python only resolves it when the function is actually called with visible_only=True.
The last piece of the module is the visibility checker. You'll use it heavily in a later step, but the function needs to exist now so the module is complete.
- Add the _check_visibility() helper function at the bottom of pass_predictor.py, after the predict_passes() function:
def _check_visibility(satellite, lat, lng, alt, peak_time):
"""A pass is visible when the satellite is sunlit AND the observer is in darkness."""
# Check if the satellite is illuminated by the Sun
sunlit = satellite.at(peak_time).is_sunlit(eph)
# Check if the observer is in darkness (Sun below -6 degrees)
earth = eph["earth"]
sun = eph["sun"]
observer_pos = earth + wgs84.latlon(lat, lng, elevation_m=alt)
sun_apparent = observer_pos.at(peak_time).observe(sun).apparent()
sun_alt, _, _ = sun_apparent.altaz()
in_darkness = sun_alt.degrees < -6
return bool(sunlit and in_darkness)
What makes a pass visible?
is_sunlit(eph) checks whether the ISS (400 km up) is lit by the Sun at peak time. Then the function computes the Sun's position from the observer and checks if the Sun is below -6 degrees, which is the threshold for civil twilight.
Both conditions must be true: the satellite catches sunlight while you stand in darkness. This geometry only happens around dawn and dusk.
- Save pass_predictor.py.
- Run the test script from Step 1 to verify everything works together:
python test_passes.py
You should see 5-15 passes listed for the next 2 days, each with rise/peak/set times and azimuths. At the bottom, the visible-only count should be smaller (often 2-5).
Seeing zero passes or an error?
- Check indentation carefully. The elif event == 1: and elif event == 2: blocks must be at the same indentation level as if event == 0:, all inside the for loop.
- If you see a NameError for _check_visibility, make sure you added the helper function after predict_passes() and saved the file.
- Zero passes may mean the TLE lines in test_passes.py are too old. Replace them with a fresh TLE from your database.
Need help debugging?
✔️ Awesome, I've got everything!
Great. Double check you've saved pass_predictor.py and that test_passes.py prints real pass data.
ⓧ I'd like to double check the full code
Your pass_predictor.py should contain these sections in order. Scroll up and confirm each one is present:
- Module setup: imports from skyfield.api, datetime, and pathlib. Then data_dir, sky_load, ts, and eph setup.
- predict_passes() function: takes TLE lines, coordinates, and optional days/visible_only params. Contains the find_events() call and the for ti, event in zip(t, events) loop handling events 0, 1, and 2.
- _check_visibility() helper: checks is_sunlit(eph) and Sun altitude below -6 degrees. Returns bool(sunlit and in_darkness).
Add the passes endpoint to your API
The prediction module works in isolation, but the frontend needs to call it over HTTP. You need a POST endpoint that accepts a location and returns the pass list.
- Switch to backend/app/main.py.
- Add these imports at the top of the file, alongside your existing imports:
# Add these imports at the top of your existing main.py
from pydantic import BaseModel
from .pass_predictor import predict_passes
- Add the request model and endpoint below your existing code in main.py:
# Add this request model alongside your existing models
class PassRequest(BaseModel):
lat: float
lng: float
alt: float = 0.0
days: int = 2
visible_only: bool = False
# Add this endpoint alongside your existing endpoints
@app.post("/api/passes")
async def get_passes(request: PassRequest):
from .tle_cache import get_db_connection
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT tle_line1, tle_line2 FROM satellites WHERE norad_id = ?",
(25544,),
)
row = cursor.fetchone()
conn.close()
if not row:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="ISS TLE not found. Refresh TLE cache.")
passes = predict_passes(
tle_line1=row[0],
tle_line2=row[1],
lat=request.lat,
lng=request.lng,
alt=request.alt,
days=request.days,
visible_only=request.visible_only,
)
return {"passes": passes}
What does this endpoint do?
- PassRequest uses Pydantic to validate the incoming JSON body. The lat and lng fields are required. The rest have defaults.
- The endpoint queries your SQLite TLE cache for NORAD ID 25544 (the ISS), then passes the TLE lines and coordinates to predict_passes().
- If the ISS TLE is not in the database, it returns a 404 error reminding you to refresh the TLE cache.
- Save main.py. Your backend server (running with --reload) should automatically pick up the changes.
Server showing an import error?
- Make sure pass_predictor.py is saved inside backend/app/ (the same folder as main.py).
- Check that the import uses a relative import with a dot: from .pass_predictor import predict_passes.
- Verify your tle_cache module has a get_db_connection() function. If your function has a different name, update the import in the endpoint to match.
Still stuck? Ask for help.
Test the endpoint with your coordinates
Before you run this, think about how many ISS passes you expect to see over 2 days. The ISS orbits Earth roughly every 90 minutes, but altitude_degrees=10.0 filters out low passes. What would you guess?
- Test the endpoint by sending a POST request with curl. Open a new terminal in your backend/ directory and run:
curl -X POST http://localhost:8000/api/passes \
-H "Content-Type: application/json" \
-d '{"lat": 51.5, "lng": -0.1}'
You should see a JSON response containing a passes array with 5-15 pass objects. Each pass includes rise_time, set_time, peak_elevation, rise_azimuth, set_azimuth, and duration_seconds.
What do these fields mean?
- rise_time and set_time are the time window when the ISS is above your horizon.
- peak_elevation is how high the ISS gets in degrees (90 = directly overhead).
- rise_azimuth and set_azimuth are compass directions in degrees (0 = North, 90 = East, 180 = South, 270 = West).
- duration_seconds is how long the pass lasts. Most ISS passes are 2-6 minutes.
Getting a 404 or connection error?
- A 404 with "ISS TLE not found" means NORAD ID 25544 is not in your SQLite database. Run your TLE cache refresh endpoint to pull fresh data.
- A connection refused error means the backend is not running. Start it with python -m uvicorn app.main:app --reload from your backend/ directory.
- If the column names in the SQL query don't match your schema from Project 3, update tle_line1 and tle_line2 in the query to match your actual column names.
Need help troubleshooting?
✔️ Awesome, I've got everything!
Great. Double check you've saved both pass_predictor.py and main.py, and that your curl test returned pass data.
ⓧ I'd like to double check the full code
Here is the complete code you added to main.py. Confirm these additions appear in your file alongside your existing code:
# Add these imports at the top of your existing main.py
from pydantic import BaseModel
from .pass_predictor import predict_passes
# Add this request model alongside your existing models
class PassRequest(BaseModel):
lat: float
lng: float
alt: float = 0.0
days: int = 2
visible_only: bool = False
# Add this endpoint alongside your existing endpoints
@app.post("/api/passes")
async def get_passes(request: PassRequest):
from .tle_cache import get_db_connection
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT tle_line1, tle_line2 FROM satellites WHERE norad_id = ?",
(25544,),
)
row = cursor.fetchone()
conn.close()
if not row:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="ISS TLE not found. Refresh TLE cache.")
passes = predict_passes(
tle_line1=row[0],
tle_line2=row[1],
lat=request.lat,
lng=request.lng,
alt=request.alt,
days=request.days,
visible_only=request.visible_only,
)
return {"passes": passes}
For pass_predictor.py, scroll up and confirm these three sections are present in order: the module setup (imports, Loader, timescale, ephemeris), the predict_passes() function, and the _check_visibility() helper.
Your backend can now predict ISS passes for any location on Earth. Next up, you'll build the frontend timeline that turns this raw JSON into a visual schedule showing exactly when and where to look.
Build the Pass Timeline UI
Your backend crunches orbital math and returns pass predictions as JSON. That works great for testing with curl, but raw UTC timestamps and azimuth degrees are useless when you are standing outside at dusk trying to spot the ISS.
You need a visual timeline that answers two questions: "when tonight?" and "where do I look?" In this step, you will build a PassTimeline component that turns raw API data into readable pass cards with times, directions, and elevation info.
In this step, get ready to:
- Create a PassTimeline component that fetches predictions and renders them as pass cards.
- Wire PassTimeline into your App alongside the existing globe and location picker.
- Discover that most listed passes are invisible and enable the visibility filter.
Create the PassTimeline component
This component calls your POST /api/passes endpoint whenever the user's location changes, then renders each pass as a card showing the time window, peak elevation, and compass direction.
- Create a new file at frontend/src/components/PassTimeline.tsx and paste the data types and helper function:
import { useState, useEffect } from "react";
// Shape of a single pass returned by the API
interface Pass {
rise_time: string;
set_time: string;
peak_time: string;
peak_elevation: number;
rise_azimuth: number;
set_azimuth: number;
duration_seconds: number;
visible?: boolean;
}
// Props: observer location passed from the parent
interface PassTimelineProps {
lat: number;
lng: number;
alt: number;
}
// Convert azimuth angle (0-360) to compass direction
function azimuthToDirection(degrees: number): string {
const directions = [
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
];
const index = Math.round(((degrees % 360) + 360) % 360 / 22.5) % 16;
return directions[index];
}
What does this code do?
The Pass interface mirrors the JSON shape your backend returns for each pass event: rise/set times, peak elevation, and azimuth angles.
The azimuthToDirection() helper converts a raw azimuth angle (like 225.3) into a human-readable compass label (like "SW"). It divides the 360-degree compass into 16 evenly spaced slices.
- Below the azimuthToDirection function, add the component with its data-fetching logic:
// Check if a pass happens on today's date
function isTonight(timeStr: string): boolean {
const passDate = new Date(timeStr);
const now = new Date();
return passDate.toDateString() === now.toDateString();
}
export default function PassTimeline({ lat, lng, alt }: PassTimelineProps) {
const [passes, setPasses] = useState<Pass[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchPasses();
}, [lat, lng, alt]);
// Fetch all pass predictions from the backend
async function fetchPasses() {
setLoading(true);
setError(null);
try {
const response = await fetch("http://localhost:8000/api/passes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lat, lng, alt, days: 2, visible_only: false }),
});
if (!response.ok) throw new Error("Failed to fetch passes");
const data = await response.json();
setPasses(data.passes);
} catch {
setError("Failed to load pass predictions.");
} finally {
setLoading(false);
}
}
What does this code do?
The isTonight() helper compares a pass's date string against today's date. Passes happening today get a special "Tonight" badge in the UI.
The component uses a useEffect hook that re-runs fetchPasses() whenever the observer's latitude, longitude, or altitude changes. The request sends visible_only: false for now, so you see every pass above 10 degrees, whether or not it is actually visible to the naked eye.
- Continue below fetchPasses and add the loading state, error handling, and the start of the return JSX:
if (loading) {
return (
<div className="pass-timeline">
<p className="loading">Calculating visible passes...</p>
</div>
);
}
if (error) {
return (
<div className="pass-timeline">
<p className="error">{error}</p>
</div>
);
}
return (
<div className="pass-timeline">
<h3>Upcoming Visible Passes</h3>
{passes.length === 0 ? (
<p className="no-passes">No visible passes in the next 2 days.</p>
) : (
<ul className="pass-list">
{passes.map((pass, i) => {
const riseDate = new Date(pass.rise_time);
const tonight = isTonight(pass.rise_time);
return (
<li key={i} className={`pass-card${tonight ? " tonight" : ""}`}>
{tonight && <span className="tonight-badge">Tonight</span>}
What is happening in the render flow?
The component handles three states before reaching the pass list. If data is still loading, it shows a "Calculating" message. If the fetch failed, it shows an error. If no passes were found, it shows an empty-state message.
For each pass, the code checks isTonight() and conditionally adds a tonight CSS class and a badge so today's passes stand out visually.
- Complete the pass card by adding the date, time window, and details section directly below the tonight badge:
<div className="pass-date">
{riseDate.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}
</div>
<div className="pass-time">
{riseDate.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
{" - "}
{new Date(pass.set_time).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</div>
<div className="pass-details">
<span className="pass-elevation">
Peak: {pass.peak_elevation}°
</span>
<span className="pass-direction">
{azimuthToDirection(pass.rise_azimuth)} →{" "}
{azimuthToDirection(pass.set_azimuth)}
</span>
<span className="pass-duration">
{Math.round(pass.duration_seconds / 60)} min
</span>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
What does each card section show?
Each pass card has three sections. The pass-date div shows the day (e.g., "Fri, Aug 1"). The pass-time div shows the rise-to-set window (e.g., "21:34 - 21:41").
The pass-details div packs three facts into one row: how high the ISS gets (peak elevation in degrees), which direction it travels across the sky (e.g., "SW to NE"), and how long the pass lasts in minutes.
- Save PassTimeline.tsx.
Wire PassTimeline into the app
The component exists but is not rendered anywhere yet. You need to import it into your App component and show it when the user's location is available.
- Switch back to your App component file (the one where you imported LocationPicker in the previous step).
- Add this import alongside your existing imports:
import PassTimeline from "./components/PassTimeline";
- In your JSX, find where you render the location-dependent section of your app. Add the PassTimeline component so it renders when location is set:
// Add this where location-dependent content renders
{location && (
<PassTimeline lat={location.lat} lng={location.lng} alt={location.alt} />
)}
What does this wiring do?
The {location && ...} pattern only renders PassTimeline after the user has set their location (either via the browser or the manual form). The component immediately calls your /api/passes endpoint with those coordinates.
The ISS orbits Earth roughly every 90 minutes, so it crosses your sky many times over 2 days. Before you refresh your browser, think about this: how many passes do you expect to see in a 2-day window?
- Save your App file.
- Return to your browser and refresh the page.
- Set your location using the Use My Location button or the manual input form.
You should see 10-15 pass cards appear in a list. Each card shows the date, a time window, peak elevation, compass direction, and duration.
Not seeing any pass cards?
- Make sure your backend is still running. Run cd backend && python -m uvicorn app.main:app --reload if it stopped.
- Open the browser console (F12) and check for CORS errors. If you see one, confirm your backend has CORS middleware allowing http://localhost:5173.
- Check that the ISS TLE (NORAD ID 25544) exists in your SQLite database. If your TLE cache is empty, refresh it first.
Why aren't my pass cards showing up after setting my location?
Discover the visibility problem
Scroll through your pass list. Notice anything odd? Some passes are at 2 AM. Some are in broad daylight. Some are in the middle of the afternoon.
Here is the problem: most of these passes are invisible. The ISS is above your horizon, yes, but it is either in Earth's shadow (reflecting no sunlight) or the sky is too bright for you to see it. Your timeline is showing passes you would never actually see.
Why are most passes invisible?
A satellite is only visible to the naked eye when it reflects sunlight down to you. The ISS, 400 km up, stays sunlit for a wider arc than you do on the ground. The sweet spot is around dawn and dusk: you stand in darkness, but the ISS, high above, still catches the Sun.
Passes at 2 AM fail because the ISS is deep in Earth's shadow. Midday passes fail because the sky is too bright. Your backend already has a visibility filter built in from the last step. Time to turn it on.
- In PassTimeline.tsx, scroll to the fetchPasses function and find this line:
body: JSON.stringify({ lat, lng, alt, days: 2, visible_only: false }),
- Change false to true so the line looks like this:
body: JSON.stringify({ lat, lng, alt, days: 2, visible_only: true }),
Before you refresh, predict: how many of those 10-15 passes do you think will survive the visibility filter?
- Save PassTimeline.tsx and refresh your browser.
Your list drops from 10-15 passes to just 2-5. Every surviving pass clusters around dawn or dusk. These are the only times when the geometry is right: you stand in darkness, but the ISS still catches sunlight 400 km overhead.
Seeing zero passes after enabling the filter?
- This can happen legitimately. Depending on the current orbital geometry and your latitude, there may be no visible passes in the next 2 days. Try changing days: 2 to days: 5 temporarily to confirm the filter is working.
- Check that de421.bsp downloaded correctly in your backend/data/ directory. The visibility check needs this ephemeris file to compute the Sun's position.
Why am I seeing zero visible passes even with a 5-day window?
✔️ Awesome, I've got everything!
Great. Double check you have saved PassTimeline.tsx with visible_only: true in the fetch request.
ⓧ I'd like to double check the full code
Your PassTimeline.tsx file is built from four code blocks. Scroll up and confirm each one is present, in this order:
- Block 1: Imports, Pass interface, PassTimelineProps interface, and the azimuthToDirection() helper function.
- Block 2: isTonight() helper, PassTimeline component declaration with state variables, useEffect, and fetchPasses() function. Confirm visible_only: true in the request body.
- Block 3: Loading guard, error guard, opening return with the heading, empty-state check, and the pass list map callback start through the tonight badge.
- Block 4: Pass card date, time window, details (peak elevation, compass direction, duration), and all closing tags through the component's final }.
Your pass timeline now shows only the passes worth stepping outside for. But how does the visibility filter actually decide which passes make the cut? In the next step, you will dig into the three conditions that must all be true at the same moment for a satellite pass to be visible to the naked eye.
Filter for Visible Passes Only
Your pass timeline shows upcoming ISS passes with timing, direction, and elevation details. Users can set their location and see a list of passes over the next two days.
Most of those passes are invisible to the naked eye. The ISS does not emit light, so you only see it when it reflects sunlight while you stand in darkness.
In this step, you will understand the three conditions for satellite visibility and verify that your app filters for only the passes worth watching.
In this step, get ready to:
- Learn the three conditions that make a satellite pass visible to the naked eye.
- Test the visibility filter against your full pass list.
- Confirm only truly visible passes appear in your UI.
Understand the three visibility conditions
A satellite pass is only visible when three conditions are met at the same time. Miss any one of them and you will stare at an empty sky.
What are the three visibility conditions?
First, the satellite must be above your horizon. Your pass predictor already handles this with Skyfield's find_events() function, which only reports passes that cross the 10-degree elevation threshold.
Second, the satellite must be in sunlight. The ISS is not a light source. You see it because it reflects sunlight, like a slow-moving star gliding across the sky.
Third, you (the observer) must be in darkness. If the Sun is up, the sky is too bright to spot the station. The threshold is civil twilight: the Sun must be more than 6 degrees below your horizon.
Your pass_predictor.py already contains a _check_visibility() function that tests conditions two and three. Let's walk through how it works.
- In your editor, open backend/app/pass_predictor.py.
- Scroll to the bottom of the file and find the _check_visibility() function:
def _check_visibility(satellite, lat, lng, alt, peak_time):
"""A pass is visible when the satellite is sunlit AND the observer is in darkness."""
sunlit = satellite.at(peak_time).is_sunlit(eph)
earth = eph["earth"]
sun = eph["sun"]
observer_pos = earth + wgs84.latlon(lat, lng, elevation_m=alt)
sun_apparent = observer_pos.at(peak_time).observe(sun).apparent()
sun_alt, _, _ = sun_apparent.altaz()
in_darkness = sun_alt.degrees < -6
return bool(sunlit and in_darkness)
What does this code do?
- satellite.at(peak_time).is_sunlit(eph) checks condition 2. It uses the de421.bsp ephemeris file (precise Sun and planet positions from NASA JPL) to determine if the ISS is illuminated at peak elevation.
- The next four lines compute the Sun's altitude from your location at the same moment. If the Sun is below -6 degrees (civil twilight), you are in darkness. That checks condition 3.
- return bool(sunlit and in_darkness) returns True only when both conditions are met. Condition 1 (above horizon) is already guaranteed by find_events().
What is de421.bsp?
de421.bsp is a NASA JPL ephemeris file (~17 MB) containing precise positions of the Sun, Moon, and planets. Skyfield auto-downloads it the first time your code runs and caches it in the backend/data/ directory. It never needs re-downloading.
Now look at how predict_passes() uses this function. Scroll up to the elif event == 2: block inside the event loop and find these lines:
if visible_only:
if _check_visibility(satellite, lat, lng, alt, peak_skyfield_time):
current_pass["visible"] = True
passes.append(current_pass)
else:
passes.append(current_pass)
What is happening here?
When visible_only is True, each completed pass is checked against the three visibility conditions before it gets added to the results list. Passes that fail the check are silently dropped.
When visible_only is False, every pass above 10 degrees gets included regardless of lighting conditions.
Test the filter with the test script
The test script you created earlier runs the prediction both with and without the visibility filter. This is the clearest way to see how many passes the filter removes.
Before you run it, think about this: the ISS crosses your horizon roughly 6-8 times per day. Over 2 days, that is roughly 12-16 passes above 10 degrees. How many of those do you think will be truly visible?
- Switch back to the terminal where your backend virtual environment is active.
- Make sure you are in the backend directory, then run the test script:
python test_passes.py
The script prints two sections. First, it lists every pass above 10 degrees with rise, peak, and set details. Then it runs the same prediction with visible_only=True and prints the filtered count.
You should see something like 10-15 total passes, but only 2-5 of them are actually visible. The majority are eliminated because the ISS is in Earth's shadow or the Sun is still up in your sky.
Why so few visible passes?
The ISS spends roughly half of each orbit in Earth's shadow, receiving no sunlight at all. Of the remaining sunlit passes, most happen during your daytime when the sky is too bright.
The visible passes cluster around dawn and dusk. During these windows, you are in darkness but the ISS, orbiting 400 km above, still catches sunlight above the shadow line.
Seeing zero visible passes?
This can happen if the ISS orbit geometry does not align with your location's twilight windows during the 2-day window. Try changing days=2 to days=5 in test_passes.py and running it again.
Also check that the TLE lines in the script are recent. Stale TLEs produce inaccurate predictions. Replace them with fresh lines from your SQLite cache if needed.
Help me figure out why my visibility filter returns zero passes.
Confirm your UI shows only visible passes
Your PassTimeline component already sends visible_only: true in its request to the backend. The filter you just reviewed runs every time the pass timeline loads.
- Switch to your browser where the frontend is running.
- Set your location using Use My Location or the manual coordinates form.
Before the page finishes loading, predict: based on your test script results, how many passes do you expect to see in the timeline?
The timeline now shows only 2-5 passes instead of the 10-15 you would see without the filter. Every pass in the list occurs near dawn or dusk, when the geometry is right for visual observation.
Why do all the passes cluster around dawn and dusk?
At dusk, the Sun has just set below your horizon but the ISS, 400 km above, is still catching sunlight from over the curve of the Earth. At dawn, the geometry reverses: the Sun is about to rise for you but the station is already illuminated from above.
During the middle of the night, both you and the ISS are in Earth's shadow. During the middle of the day, the sky is too bright. Only the transition windows around twilight produce visible passes.
Pass list not updating?
Make sure your backend server is still running. The first request after a restart may take a few seconds while Skyfield loads the ephemeris data.
If you see an error about the ISS TLE not being found, refresh your TLE cache from the endpoint you built in a previous project.
Why is my pass timeline not loading visible passes?
Secret mission
Browser Notifications for Passes
Your app shows when the ISS will be visible, but what if you forget to check? Add browser notifications that alert you 5 minutes before a visible pass, so you never miss the chance to step outside and look up.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no cloud resources or paid services. There are no ongoing costs to worry about.
Resources you used:
- de421.bsp ephemeris file (~17 MB) in backend/data/.
- skyfield and sgp4 Python packages in your backend virtual environment.
- New project files: pass_predictor.py, test_passes.py, LocationPicker.tsx, PassTimeline.tsx, and PassNotifier.tsx.
Keep everything running
No action needed. Choose this if you plan to continue building on the OrbitX project. Everything is stored locally on your machine with zero ongoing cost.
- The de421.bsp file stays cached in backend/data/ so Skyfield does not need to re-download it.
- Your backend and frontend dev servers are not running unless you start them manually, so they consume no resources when idle.
Pause - I'll come back to this later
Stop any running dev servers to free up terminal sessions and memory. Your files stay on disk ready for when you come back.
- Switch to any terminal running the backend or frontend dev server.
- Press Ctrl+C in each terminal to stop the server.
- All project files remain on disk. When you are ready to resume, activate your virtual environment and start the servers again.
Delete - I don't want to use this again
Remove the files and packages this project added. Since this is part of the OrbitX series, these steps remove only what Project 4 introduced.
- Stop any running dev servers by pressing Ctrl+C in each terminal.
- Make sure your backend virtual environment is activated, then uninstall the Skyfield packages by running:
pip uninstall skyfield sgp4 -y
- Delete the ephemeris file to reclaim ~17 MB. It will re-download automatically if you ever run the pass predictor again.
macOS
rm backend/data/de421.bsp
Windows
del backend\data\de421.bsp
- Delete the new source files added in this project by running:
macOS
rm backend/app/pass_predictor.py
rm backend/test_passes.py
rm frontend/src/components/LocationPicker.tsx
rm frontend/src/components/PassTimeline.tsx
rm frontend/src/components/PassNotifier.tsx
Windows
del backend\app\pass_predictor.py
del backend\test_passes.py
del frontend\src\components\LocationPicker.tsx
del frontend\src\components\PassTimeline.tsx
del frontend\src\components\PassNotifier.tsx
- Open backend/app/main.py and remove the PassRequest model, the predict_passes import, and the /api/passes endpoint you added in this project.
- Save main.py after removing those additions.
That's a Wrap!
That's a Wrap!
Nice work! You just built a personal ISS visibility tool that answers "when should I look up tonight?" with real orbital math running on your own machine.
You've learned how to:
- Built a pass prediction backend using Skyfield's find_events() to compute ISS horizon crossings from real TLE orbital data stored in your SQLite cache.
- Created a location-aware frontend that detects your coordinates with the browser Geolocation API and falls back gracefully to manual input when permission is denied.
- Discovered that a satellite pass has three visibility conditions (above horizon, satellite sunlit, observer in darkness) and built a filter so your timeline only shows passes worth stepping outside for.
- Secret Mission: Added browser notifications that alert you 5 minutes before a visible pass so you never miss one.
Ready to quiz yourself?