Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "uv"
directory: "/scripts"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Continuous Integration

on:
pull_request:
push:
branches:
- main

concurrency:
group: "ci"

jobs:
format-and-lint:
name: Format and lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: scripts
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "scripts/.python-version"

- name: Setup uv
uses: astral-sh/setup-uv@v6

- name: Check
run: uv run ruff check

- name: Format
run: uv run ruff format --check
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,23 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version-file: "scripts/.python-version"

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y tippecanoe

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v6

- name: Install Python dependencies
run: |
cd scripts
uv sync
working-directory: scripts
run: uv sync

- name: Generate satellite data
run: |
cd scripts
uv run python generate_satellite_paths.py
working-directory: scripts
run: uv run python generate_satellite_paths.py

- name: Set up Node.js
uses: actions/setup-node@v4
Expand Down
171 changes: 99 additions & 72 deletions scripts/generate_satellite_paths.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import httpx
import subprocess
Comment thread
gadomski marked this conversation as resolved.
import asyncio
import json
import os
import asyncio
from skyfield.api import load, wgs84, Timescale
import subprocess
from datetime import datetime, timedelta, timezone

import geopandas as gpd
import httpx
import pandas as pd
from shapely.geometry import Point, LineString
from shapely.geometry import LineString, Point
from skyfield.api import load, wgs84

# Get the absolute path of the script's directory
script_dir = os.path.dirname(os.path.abspath(__file__))
Expand All @@ -28,7 +29,10 @@
# List of satellite TLE data URLs from Celestrak
urls = []
for sat in satellite_info:
urls.append(f"https://celestrak.org/NORAD/elements/gp.php?CATNR={sat['norad_id']}&FORMAT=tle")
urls.append(
f"https://celestrak.org/NORAD/elements/gp.php?CATNR={sat['norad_id']}&FORMAT=tle"
)


# Async function to fetch a single TLE URL
async def fetch_tle(client, url):
Expand All @@ -44,12 +48,14 @@ async def fetch_tle(client, url):
print(f" Failed to fetch {url} (Request error: {e})")
return ""


# Async function to fetch all TLE URLs concurrently
async def fetch_all_tles(urls):
async with httpx.AsyncClient() as client:
tasks = [fetch_tle(client, url) for url in urls]
return await asyncio.gather(*tasks)


# Fetch and combine TLE data
print("Fetching TLE data...")
combined_tle_content = ""
Expand Down Expand Up @@ -79,7 +85,7 @@ async def fetch_all_tles(urls):
sat.swath_km = sat_props["swath_km"]
sat.operator = sat_props["operator"]
sat.sensor_type = sat_props["sensor_type"]
sat.spatial_res_m = sat_props["spatial_res_cm"] / 100 # Convert cm to meters
sat.spatial_res_m = sat_props["spatial_res_cm"] / 100 # Convert cm to meters
sat.data_access = sat_props["data_access"]
filtered_satellites.append(sat)
satellites = filtered_satellites
Expand All @@ -92,86 +98,104 @@ async def fetch_all_tles(urls):
time_1 = now
time_2 = now + timedelta(days=2)


# Function to calculate satellite positions
def get_satellite_positions(sat, start_time, end_time, step_minutes):
positions = []
current_time = start_time
while current_time <= end_time:
geocentric = sat.at(ts.from_datetime(current_time))
lat, lon = wgs84.latlon_of(geocentric)
positions.append({
"satellite": sat.name,
"timestamp": current_time,
"coordinates": Point(lon.degrees, lat.degrees),
"swath_km": sat.swath_km,
"constellation": sat.constellation,
"operator": sat.operator,
"sensor_type": sat.sensor_type,
"spatial_res_m": sat.spatial_res_m, # Use meters
"data_access": sat.data_access
})
positions.append(
{
"satellite": sat.name,
"timestamp": current_time,
"coordinates": Point(lon.degrees, lat.degrees),
"swath_km": sat.swath_km,
"constellation": sat.constellation,
"operator": sat.operator,
"sensor_type": sat.sensor_type,
"spatial_res_m": sat.spatial_res_m, # Use meters
"data_access": sat.data_access,
}
)
current_time += timedelta(minutes=step_minutes)
return positions


# Calculate positions for all satellites
print("\nCalculating satellite positions...")
all_positions = []
for i, sat in enumerate(satellites):
print(f" ({i+1}/{len(satellites)}) Calculating positions for {sat.name}")
print(f" ({i + 1}/{len(satellites)}) Calculating positions for {sat.name}")
all_positions.extend(get_satellite_positions(sat, time_1, time_2, 5))

# Create a DataFrame from the positions
print("\nCreating DataFrame from positions...")
positions_df = pd.DataFrame(all_positions, columns=[
"satellite", "timestamp", "coordinates", "swath_km", "constellation",
"operator", "sensor_type", "spatial_res_m", "data_access" # Use meters
])
positions_df = pd.DataFrame(
all_positions,
columns=[
"satellite",
"timestamp",
"coordinates",
"swath_km",
"constellation",
"operator",
"sensor_type",
"spatial_res_m",
"data_access", # Use meters
],
)

# Create LineString paths for each satellite
print("Creating LineString paths for each satellite...")
path_segments = []
for sat_name, group in positions_df.groupby('satellite'):
group = group.sort_values('timestamp').reset_index(drop=True)
for sat_name, group in positions_df.groupby("satellite"):
group = group.sort_values("timestamp").reset_index(drop=True)
for i in range(len(group) - 1):
pt0 = group.loc[i, 'coordinates']
pt1 = group.loc[i + 1, 'coordinates']
pt0 = group.loc[i, "coordinates"]
pt1 = group.loc[i + 1, "coordinates"]

# Skip segments that cross the antimeridian
if abs(pt0.x - pt1.x) > 180:
continue

line = LineString([pt0, pt1])
path_segments.append({
'satellite': sat_name,
'start_time': group.loc[i, 'timestamp'],
'end_time': group.loc[i + 1, 'timestamp'],
'geometry': line,
'swath_km': group.loc[i, 'swath_km'],
'constellation': group.loc[i, 'constellation'],
'operator': group.loc[i, 'operator'],
'sensor_type': group.loc[i, 'sensor_type'],
'spatial_res_m': group.loc[i, 'spatial_res_m'], # Use meters
'data_access': group.loc[i, 'data_access']
})
path_segments.append(
{
"satellite": sat_name,
"start_time": group.loc[i, "timestamp"],
"end_time": group.loc[i + 1, "timestamp"],
"geometry": line,
"swath_km": group.loc[i, "swath_km"],
"constellation": group.loc[i, "constellation"],
"operator": group.loc[i, "operator"],
"sensor_type": group.loc[i, "sensor_type"],
"spatial_res_m": group.loc[i, "spatial_res_m"], # Use meters
"data_access": group.loc[i, "data_access"],
}
)

if not path_segments:
print("\nNo satellite paths were generated. Exiting.")
else:
# Convert to a GeoDataFrame
path_gdf = gpd.GeoDataFrame(path_segments, geometry='geometry', crs='EPSG:4326')
path_gdf = gpd.GeoDataFrame(path_segments, geometry="geometry", crs="EPSG:4326")

# Buffer the lines to create polygons
print("\nBuffering paths to create polygons...")
path_gdf_proj = path_gdf.to_crs("EPSG:3395")
# Use swath_km for buffering, converting km to meters
path_gdf_proj['geometry'] = path_gdf_proj.apply(lambda row: row.geometry.buffer(row['swath_km'] * 500), axis=1) # Half of swath_km for buffer
path_gdf_proj["geometry"] = path_gdf_proj.apply(
lambda row: row.geometry.buffer(row["swath_km"] * 500), axis=1
) # Half of swath_km for buffer
path_gdf = path_gdf_proj.to_crs("EPSG:4326")

# Save metadata
print("\nSaving metadata...")

# Calculate spatial resolution ranges
spatial_resolutions = path_gdf['spatial_res_m'].unique()
spatial_resolutions = path_gdf["spatial_res_m"].unique()
spatial_resolution_ranges = []
for res in spatial_resolutions:
if res < 5:
Expand All @@ -181,55 +205,58 @@ def get_satellite_positions(sat, start_time, end_time, step_minutes):
else:
range_category = "low"
spatial_resolution_ranges.append(range_category)

# Generate base metadata with tiles URL template
base_metadata = {
"satellites": path_gdf['satellite'].unique().tolist(),
"constellations": path_gdf['constellation'].unique().tolist(),
"operators": path_gdf['operator'].unique().tolist(),
"sensor_types": path_gdf['sensor_type'].unique().tolist(),
"data_access_options": path_gdf['data_access'].unique().tolist(),
"satellites": path_gdf["satellite"].unique().tolist(),
"constellations": path_gdf["constellation"].unique().tolist(),
"operators": path_gdf["operator"].unique().tolist(),
"sensor_types": path_gdf["sensor_type"].unique().tolist(),
"data_access_options": path_gdf["data_access"].unique().tolist(),
"spatial_resolution_ranges": list(set(spatial_resolution_ranges)),
"minTime": path_gdf['start_time'].min().isoformat(),
"maxTime": path_gdf['end_time'].max().isoformat(),
"minTime": path_gdf["start_time"].min().isoformat(),
"maxTime": path_gdf["end_time"].max().isoformat(),
"lastUpdated": datetime.now(timezone.utc).isoformat(),
"tilesUrl": "/tiles/{z}/{x}/{y}.pbf"
"tilesUrl": "/tiles/{z}/{x}/{y}.pbf",
}

# Save local copy first
with open(local_metadata_path, "w") as f:
json.dump(base_metadata, f, indent=2)
print(f"\nSuccessfully generated local {local_metadata_path}")

# Save GeoJSON
print("\nSaving paths to GeoJSON file...")
path_gdf.to_file(geojson_path, driver='GeoJSON')
path_gdf.to_file(geojson_path, driver="GeoJSON")
print(f"\nSuccessfully generated {geojson_path}")

# Generate directory tiles from the GeoJSON
print("\nGenerating directory tiles from GeoJSON...")

# Remove existing tiles directory if it exists
if os.path.exists(tiles_dir):
import shutil

shutil.rmtree(tiles_dir)

subprocess.run([
"tippecanoe",
"-Z0",
"-z7", # Changed from -z12 to -z7
"--simplification=10",
"--drop-densest-as-needed",
"--extend-zooms-if-still-dropping",
"--detect-longitude-wraparound",
"--no-tile-compression", # Important for web hosting
"--output-to-directory",
tiles_dir,
geojson_path,
"--force"
])

subprocess.run(
[
"tippecanoe",
"-Z0",
"-z7", # Changed from -z12 to -z7
"--simplification=10",
"--drop-densest-as-needed",
"--extend-zooms-if-still-dropping",
"--detect-longitude-wraparound",
"--no-tile-compression", # Important for web hosting
"--output-to-directory",
tiles_dir,
geojson_path,
"--force",
]
)
print(f"\nSuccessfully generated tiles in {tiles_dir}")
print(f"\nTiles generated successfully and ready for GitHub hosting")
Comment thread
gadomski marked this conversation as resolved.

print("\nTiles generated successfully and ready for GitHub hosting")
print(f"Metadata saved to: {local_metadata_path}")
print(f"Tiles directory: {tiles_dir}")
15 changes: 10 additions & 5 deletions scripts/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
[project]
name = "scripts"
version = "0.1.0"
description = "Add your description here"
name = "eo-predictor-scripts"
version = "0.0.0"
Comment thread
gadomski marked this conversation as resolved.
description = "Scripts for the eo-predictor"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"geopandas>=1.1.1",
"httpx>=0.28.1",
"pandas>=2.3.2",
"requests>=2.32.4",
"shapely>=2.1.1",
"skyfield>=1.53",
"obstore>=0.7.0",
"boto3>=1.34.0",
]

[dependency-groups]
dev = ["ruff>=0.12.10"]

[tool.mypy]
files = ["generate_satellite_paths.py"]
Loading