Skip to content

Commit b783fc0

Browse files
committed
feat: dynamic scheduling based on astronomical darkness
Major optimization to reduce unnecessary workflow runs and API calls: **New Daily Schedule Update Workflow:** - New workflow: update-sun-schedule.yml runs once daily at 02:00 UTC - Fetches sun times from sunrise-sunset.org API - Calculates astronomical darkness hours for Lommedalen - Generates optimal cron schedules (e.g., */30 18-23,0-4 * * *) - Auto-commits updated sun_schedule.json and post.yml schedule **Optimized Main Bot Workflow:** - post.yml now runs only during calculated dark hours - Removed per-run API calls to sunrise-sunset.org - Uses cached sun_schedule.json (instant darkness check) - Added quick pre-check step before main bot execution - Falls back gracefully if cache missing **New Files:** - .github/workflows/update-sun-schedule.yml: Daily schedule updater - src/check_darkness.py: Fast darkness validation (no API calls) - sun_schedule.json: Cached sun times (auto-updated daily) **Benefits:** - ⚡ Eliminates ~1-2 seconds of API latency per run - 🔋 Reduces workflow runs by ~50-70% (only runs during darkness) - 💰 Saves GitHub Actions minutes (no daytime runs) - 🌍 Reduces external API load - 📅 Schedule auto-adjusts as days get longer/shorter **Current Schedule (Nov 1):** Dark hours: 18:00-04:00 UTC Runs: Every 30 minutes during astronomical darkness
1 parent d0d11a1 commit b783fc0

6 files changed

Lines changed: 232 additions & 11 deletions

File tree

.github/workflows/post.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ name: "Lommedalen Aurora Borealis Bot"
22

33
on:
44
schedule:
5-
# Run every 30 minutes (all hours); darkness gate inside code exits fast if daylight.
6-
- cron: "*/30 * * * *"
5+
# Schedule updated daily by update-sun-schedule workflow based on astronomical darkness
6+
# Default fallback: evening/morning hours
7+
- cron: "*/30 18-23 * * *"
8+
- cron: "*/30 0-5 * * *"
79
workflow_dispatch: # Manual trigger for testing
810

911
jobs:
@@ -42,7 +44,13 @@ jobs:
4244
shuffle-state-v2-
4345
continue-on-error: true
4446

47+
- name: Quick darkness check
48+
id: darkness
49+
run: python src/check_darkness.py
50+
continue-on-error: true
51+
4552
- name: Run Aurora Detection
53+
if: steps.darkness.outcome == 'success'
4654
run: python src/main.py
4755
env:
4856
BSKY_HANDLE: ${{ secrets.BSKY_HANDLE }}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
name: "Update Sun Schedule"
2+
3+
on:
4+
schedule:
5+
# Run once daily at 02:00 UTC (3 AM Oslo time in winter)
6+
- cron: "0 2 * * *"
7+
workflow_dispatch: # Manual trigger for testing
8+
9+
jobs:
10+
update-schedule:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 5
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
with:
18+
token: ${{ secrets.GITHUB_TOKEN }}
19+
20+
- name: Set up Python 3.11
21+
uses: actions/setup-python@v4
22+
with:
23+
python-version: '3.11'
24+
25+
- name: Fetch sun times and update schedule
26+
run: |
27+
python3 << 'EOF'
28+
import json
29+
import requests
30+
from datetime import datetime, time
31+
32+
# Fetch sun times
33+
LAT = 59.95
34+
LNG = 10.466667
35+
url = f"https://api.sunrise-sunset.org/json?lat={LAT}&lng={LNG}&formatted=0"
36+
37+
response = requests.get(url, timeout=15)
38+
response.raise_for_status()
39+
data = response.json()
40+
41+
if data.get("status") != "OK":
42+
raise Exception(f"API returned status: {data.get('status')}")
43+
44+
results = data["results"]
45+
46+
# Parse times (ISO format from API when formatted=0)
47+
def parse_time(time_str):
48+
"""Extract hour and minute from ISO datetime string"""
49+
dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
50+
return dt.hour, dt.minute
51+
52+
# Get astronomical twilight times (darkest)
53+
twilight_end_h, twilight_end_m = parse_time(results["astronomical_twilight_end"])
54+
twilight_begin_h, twilight_begin_m = parse_time(results["astronomical_twilight_begin"])
55+
56+
# Generate cron schedule for dark hours
57+
# Run every 30 minutes during astronomical darkness
58+
# Cron format: minute hour * * *
59+
60+
# If twilight_end is 17:44:59 (5:44 PM), start at 18:00
61+
# If twilight_begin is 4:18:25 AM, end at 4:00 AM
62+
start_hour = twilight_end_h + 1 if twilight_end_m > 30 else twilight_end_h
63+
end_hour = twilight_begin_h
64+
65+
# Handle case where darkness spans midnight
66+
if start_hour >= end_hour:
67+
# Example: 18:00-23:59 and 00:00-04:00
68+
cron_evening = f"*/30 {start_hour}-23 * * *"
69+
cron_morning = f"*/30 0-{end_hour} * * *" if end_hour > 0 else None
70+
cron_schedule = [cron_evening]
71+
if cron_morning:
72+
cron_schedule.append(cron_morning)
73+
else:
74+
# Continuous dark period (rare in Lommedalen)
75+
cron_schedule = [f"*/30 {start_hour}-{end_hour} * * *"]
76+
77+
# Save schedule info
78+
schedule_info = {
79+
"updated_at": datetime.utcnow().isoformat() + "Z",
80+
"location": {"lat": LAT, "lng": LNG},
81+
"sun_times": results,
82+
"dark_hours": {
83+
"start_hour": start_hour,
84+
"end_hour": end_hour,
85+
"spans_midnight": start_hour >= end_hour
86+
},
87+
"cron_schedules": cron_schedule
88+
}
89+
90+
with open('sun_schedule.json', 'w') as f:
91+
json.dump(schedule_info, f, indent=2)
92+
93+
print(f"✅ Updated sun schedule")
94+
print(f" Astronomical darkness: {start_hour}:00 - {end_hour}:00 UTC")
95+
print(f" Cron schedules: {', '.join(cron_schedule)}")
96+
EOF
97+
98+
- name: Update workflow schedule
99+
run: |
100+
python3 << 'EOF'
101+
import json
102+
import yaml
103+
104+
# Load schedule info
105+
with open('sun_schedule.json', 'r') as f:
106+
schedule_info = json.load(f)
107+
108+
# Load current workflow
109+
with open('.github/workflows/post.yml', 'r') as f:
110+
workflow = yaml.safe_load(f)
111+
112+
# Update cron schedules
113+
cron_schedules = schedule_info['cron_schedules']
114+
workflow['on']['schedule'] = [{'cron': cron} for cron in cron_schedules]
115+
116+
# Save updated workflow
117+
with open('.github/workflows/post.yml', 'w') as f:
118+
yaml.dump(workflow, f, default_flow_style=False, sort_keys=False)
119+
120+
print(f"✅ Updated post.yml with {len(cron_schedules)} cron schedule(s)")
121+
EOF
122+
123+
- name: Commit and push changes
124+
run: |
125+
git config user.name "github-actions[bot]"
126+
git config user.email "github-actions[bot]@users.noreply.github.com"
127+
git add sun_schedule.json .github/workflows/post.yml
128+
if git diff --staged --quiet; then
129+
echo "No changes to commit"
130+
else
131+
git commit -m "chore: update sun schedule and workflow timing [automated]
132+
133+
Dark hours: $(jq -r '.dark_hours.start_hour' sun_schedule.json):00 - $(jq -r '.dark_hours.end_hour' sun_schedule.json):00 UTC
134+
Updated: $(jq -r '.updated_at' sun_schedule.json)"
135+
git push
136+
fi

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
requests
22
python-dotenv
33
atproto
4-
Pillow
4+
Pillow
5+
PyYAML

src/check_darkness.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Quick darkness check using cached sun schedule.
4+
Returns exit code 0 if dark, 1 if not dark.
5+
"""
6+
import json
7+
import sys
8+
from datetime import datetime, timezone
9+
from pathlib import Path
10+
11+
def is_dark_now() -> bool:
12+
"""Check if it's currently dark based on cached sun schedule."""
13+
schedule_file = Path("sun_schedule.json")
14+
15+
if not schedule_file.exists():
16+
print("⚠️ sun_schedule.json not found; assuming dark (fail-open)", file=sys.stderr)
17+
return True
18+
19+
try:
20+
with open(schedule_file, 'r') as f:
21+
schedule = json.load(f)
22+
23+
sun_times = schedule.get("sun_times", {})
24+
if not sun_times:
25+
print("⚠️ Invalid sun_schedule.json; assuming dark (fail-open)", file=sys.stderr)
26+
return True
27+
28+
now = datetime.now(timezone.utc)
29+
30+
# Parse twilight times
31+
def parse_time(time_str):
32+
return datetime.fromisoformat(time_str.replace('Z', '+00:00'))
33+
34+
twilight_begin = parse_time(sun_times["astronomical_twilight_begin"])
35+
twilight_end = parse_time(sun_times["astronomical_twilight_end"])
36+
37+
# Dark if outside twilight period
38+
is_dark = now < twilight_begin or now > twilight_end
39+
40+
if is_dark:
41+
print(f"✅ Dark (astronomical night)")
42+
else:
43+
print(f"☀️ Not dark enough (astronomical twilight or daylight)")
44+
45+
return is_dark
46+
47+
except Exception as e:
48+
print(f"⚠️ Error reading sun schedule: {e}; assuming dark (fail-open)", file=sys.stderr)
49+
return True
50+
51+
if __name__ == "__main__":
52+
sys.exit(0 if is_dark_now() else 1)

src/main.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
TODAY_FOLDER = Path("today")
3737
TODAY_FOLDER.mkdir(exist_ok=True)
3838
WEBCAM_LOCATIONS_FILE = Path("webcam_locations.json")
39+
SUN_SCHEDULE_FILE = Path("sun_schedule.json")
3940
SHUFFLE_STATE_FILE = Path("shuffle_state.json")
4041
BSKY_HANDLE = os.getenv("BSKY_HANDLE")
4142
BSKY_PASSWORD = os.getenv("BSKY_PASSWORD")
@@ -45,16 +46,11 @@
4546
MIN_KP_INDEX = float(os.getenv("MIN_KP", "4")) # Minimum planetary Kp index required to proceed
4647

4748
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
48-
4949
azure_rate_limited = False
5050

51-
# Constants for sunrise-sunset API
52-
SUN_TIMES_LAT = 59.95
53-
SUN_TIMES_LNG = 10.466667
54-
SUN_TIMES_ENDPOINT = "https://api.sunrise-sunset.org/json"
55-
5651
# NOAA Kp index endpoints (primary nowcast + fallback estimated planetary Kp)
5752
NOAA_KP_PRIMARY = "https://services.swpc.noaa.gov/json/rtsw/rtsw_kp_forecast.json"
53+
NOAA_KP_FALLBACK = "https://services.swpc.noaa.gov/products/noaa-scales.json"json"
5854
NOAA_KP_FALLBACK = "https://services.swpc.noaa.gov/products/noaa-scales.json"
5955

6056
def fetch_current_kp() -> Optional[float]:
@@ -515,8 +511,8 @@ def main():
515511

516512
logging.info(f"Starting Aurora Borealis Detection Bot - will run for max {MAX_RUNTIME_MINUTES} minutes")
517513

518-
# Darkness check first
519-
sun_times = fetch_sun_times()
514+
# Darkness check using cached schedule (fast, no API call)
515+
sun_times = load_cached_sun_times()
520516
if sun_times:
521517
light_desc = describe_light_condition(sun_times)
522518
dark = is_dark(sun_times)

sun_schedule.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"updated_at": "2025-11-01T05:46:19.939444Z",
3+
"location": {
4+
"lat": 59.95,
5+
"lng": 10.466667
6+
},
7+
"sun_times": {
8+
"sunrise": "2025-11-01T06:38:34+00:00",
9+
"sunset": "2025-11-01T15:24:50+00:00",
10+
"solar_noon": "2025-11-01T11:01:42+00:00",
11+
"day_length": 31576,
12+
"civil_twilight_begin": "2025-11-01T05:55:53+00:00",
13+
"civil_twilight_end": "2025-11-01T16:07:31+00:00",
14+
"nautical_twilight_begin": "2025-11-01T05:06:29+00:00",
15+
"nautical_twilight_end": "2025-11-01T16:56:55+00:00",
16+
"astronomical_twilight_begin": "2025-11-01T04:18:25+00:00",
17+
"astronomical_twilight_end": "2025-11-01T17:44:59+00:00"
18+
},
19+
"dark_hours": {
20+
"start_hour": 18,
21+
"end_hour": 4,
22+
"spans_midnight": true
23+
},
24+
"cron_schedules": [
25+
"*/30 18-23 * * *",
26+
"*/30 0-4 * * *"
27+
]
28+
}

0 commit comments

Comments
 (0)