TALOS v0.5 -- Scheduling Architecture¶
Date: April 2026 Scope: Automated pass scheduling design for multi-campaign ground station networks Author: Engineering (automated review)
1. Problem Statement¶
TALOS currently requires operators to manually assign stations to campaigns. An operator must:
- Know which satellites a campaign targets.
- Check which stations have line-of-sight to those satellites and when.
- Ensure no two campaigns overlap on the same station at the same time.
- Account for antenna slew time between consecutive passes.
This process does not scale. With 10 stations and 5 campaigns, the combinatorial space is manageable. With 50 stations and 20 campaigns, manual assignment becomes impractical and error-prone. Automated scheduling is a prerequisite for network growth.
2. Pass Window Computation¶
Before scheduling can occur, the system needs predicted pass windows -- the time intervals during which a satellite is above a station's local horizon.
2.1 Pass Prediction Algorithm¶
For each (station, satellite) pair:
- Propagate the satellite position using SGP4 (Skyfield or dSGP4).
- Compute the topocentric position relative to the station.
- Find rise/set events by searching for zero crossings of the elevation angle.
- Record:
(station_id, satellite_id, aos_time, los_time, max_elevation).
AOS = Acquisition of Signal (rise above horizon). LOS = Loss of Signal (set below horizon).
2.2 Pass Window Data Model¶
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PassWindow:
"""A predicted pass of a satellite over a ground station."""
station_id: str
satellite_id: int # NORAD catalog number
campaign_id: str | None # Assigned campaign, or None if unassigned
aos: datetime # Acquisition of signal (UTC)
los: datetime # Loss of signal (UTC)
max_elevation: float # Peak elevation in degrees
duration_seconds: float # los - aos
priority: int # Inherited from campaign (1=highest, 10=lowest)
2.3 Prediction Horizon¶
The scheduler operates on a rolling window:
| Parameter | Default | Rationale |
|---|---|---|
SCHEDULE_HORIZON_HOURS |
24 | One day of upcoming passes |
SCHEDULE_REFRESH_MINUTES |
15 | Re-run scheduler as TLEs update |
MIN_ELEVATION_DEGREES |
10 | Ignore low-elevation passes (atmospheric loss) |
3. Conflict Detection¶
A conflict occurs when two pass windows overlap on the same station and both are assigned to different campaigns.
3.1 Overlap Test¶
Two passes A and B on the same station conflict if:
This is the standard interval overlap check.
3.2 Slew Time Constraint¶
Even non-overlapping passes conflict if the gap between them is shorter than the antenna slew time. A rotator moving at 3 degrees/second needs time to reposition between consecutive targets.
def slew_time(az1: float, el1: float, az2: float, el2: float,
rate_deg_per_sec: float = 3.0) -> float:
"""Minimum time in seconds to slew from one position to another."""
delta_az = abs(az2 - az1)
delta_el = abs(el2 - el1)
max_delta = max(delta_az, delta_el)
return max_delta / rate_deg_per_sec
Effective conflict check with slew margin:
3.3 Conflict Graph¶
Build an undirected conflict graph where:
- Each node is a
PassWindow. - An edge connects two nodes if they conflict on the same station (overlap + slew).
The scheduling problem is then: select a maximum-weight independent set from this graph, where weight encodes campaign priority and pass quality.
4. Greedy Scheduler (Phase 1)¶
The initial implementation uses a greedy algorithm -- simple, deterministic, and easy to debug. This covers single-campaign scenarios and provides a working baseline before introducing OR-Tools.
4.1 Algorithm¶
def greedy_schedule(passes: list[PassWindow],
slew_margin_sec: float = 30.0) -> list[PassWindow]:
"""
Assign passes to campaigns using a priority-first greedy algorithm.
1. Sort passes by (priority ASC, max_elevation DESC).
2. For each pass, check if the station is free during the window + slew margin.
3. If free, assign the pass. If not, skip it.
"""
# Sort: highest priority first, then best elevation
sorted_passes = sorted(passes, key=lambda p: (p.priority, -p.max_elevation))
assigned: list[PassWindow] = []
# station_id -> list of assigned time intervals
station_timeline: dict[str, list[tuple[datetime, datetime]]] = {}
for pw in sorted_passes:
sid = pw.station_id
window_start = pw.aos
window_end = pw.los + timedelta(seconds=slew_margin_sec)
if sid not in station_timeline:
station_timeline[sid] = []
conflict = False
for (busy_start, busy_end) in station_timeline[sid]:
if window_start < busy_end and busy_start < window_end:
conflict = True
break
if not conflict:
station_timeline[sid].append((window_start, window_end))
assigned.append(pw)
return assigned
4.2 Complexity¶
- Time: O(n^2) in the worst case (each pass checked against all assigned passes on the same station).
- Space: O(n) for the timeline.
- Acceptable for n < 1,000 passes (typical 24-hour horizon for 50 stations).
4.3 Limitations¶
- No global optimization. A greedy choice early in the sort order may block a better combination later.
- Cannot express complex constraints (e.g., "campaign X needs at least 3 passes per day").
- No backtracking.
5. OR-Tools CP-SAT Scheduler (Phase 2)¶
For multi-campaign optimization with complex constraints, the OR-Tools CP-SAT (Constraint Programming with Satisfiability) solver is the right tool. It handles interval scheduling natively and scales to thousands of variables.
5.1 Decision Variables¶
For each pass window i:
from ortools.sat.python import cp_model
model = cp_model.CpModel()
# Binary: is this pass assigned to its campaign?
assigned = {}
for i, pw in enumerate(pass_windows):
assigned[i] = model.new_bool_var(f"assigned_{i}")
# Interval variable (only active when assigned)
intervals = {}
for i, pw in enumerate(pass_windows):
start = int(pw.aos.timestamp())
end = int(pw.los.timestamp()) + slew_margin
size = end - start
intervals[i] = model.new_optional_fixed_size_interval_var(
start=start,
size=size,
is_present=assigned[i],
name=f"interval_{i}",
)
5.2 Constraints¶
No-overlap per station:
# Group intervals by station
from collections import defaultdict
station_intervals = defaultdict(list)
for i, pw in enumerate(pass_windows):
station_intervals[pw.station_id].append(intervals[i])
# Add no-overlap constraint for each station
for station_id, ivs in station_intervals.items():
model.add_no_overlap(ivs)
Minimum passes per campaign (optional):
# Campaign X needs at least 3 passes in the scheduling horizon
campaign_passes = [i for i, pw in enumerate(pass_windows)
if pw.campaign_id == campaign_x_id]
model.add(sum(assigned[i] for i in campaign_passes) >= 3)
Maximum station utilization (optional):
# No station should be busy more than 80% of the scheduling horizon
for station_id in all_stations:
station_pass_indices = [i for i, pw in enumerate(pass_windows)
if pw.station_id == station_id]
total_busy = sum(
(int(pass_windows[i].los.timestamp()) - int(pass_windows[i].aos.timestamp()))
* assigned[i]
for i in station_pass_indices
)
horizon_seconds = int(schedule_horizon.total_seconds())
model.add(total_busy <= int(horizon_seconds * 0.8))
5.3 Objective Function¶
Maximize total weighted pass quality:
# Weight = priority_weight * elevation_weight
# Higher priority campaigns and higher elevation passes score higher
objective_terms = []
for i, pw in enumerate(pass_windows):
priority_weight = 100 - (pw.priority * 10) # priority 1 -> 90, priority 10 -> 0
elevation_weight = int(pw.max_elevation) # 0-90 degrees
score = priority_weight + elevation_weight
objective_terms.append(score * assigned[i])
model.maximize(sum(objective_terms))
5.4 Solver Invocation¶
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 10.0 # Hard timeout
solver.parameters.num_workers = 4 # Parallel search
status = solver.solve(model)
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
schedule = []
for i, pw in enumerate(pass_windows):
if solver.value(assigned[i]):
schedule.append(pw)
return schedule
else:
# Fall back to greedy scheduler
return greedy_schedule(pass_windows)
5.5 Performance Expectations¶
| Scenario | Variables | Constraints | Expected Solve Time |
|---|---|---|---|
| 10 stations, 5 campaigns, 24h | ~200 | ~50 | < 100ms |
| 50 stations, 20 campaigns, 24h | ~2,000 | ~500 | < 2s |
| 100 stations, 50 campaigns, 24h | ~10,000 | ~2,500 | < 10s |
| 200 stations, 100 campaigns, 48h | ~50,000 | ~12,000 | 10-30s |
CP-SAT is designed for this scale. The 10-second timeout provides a hard guarantee for API response times.
6. API Design¶
6.1 Scheduler Endpoints¶
POST /api/v1/org/{slug}/schedule/generate
Body: { "horizon_hours": 24, "min_elevation": 10.0 }
Response: { "schedule_id": "...", "passes": [...], "conflicts_resolved": 7 }
GET /api/v1/org/{slug}/schedule/{schedule_id}
Response: Full schedule with assigned passes
POST /api/v1/org/{slug}/schedule/{schedule_id}/approve
Body: { "pass_ids": ["...", "..."] }
Response: Creates Assignment records for approved passes
GET /api/v1/org/{slug}/schedule/conflicts
Response: List of detected conflicts in current manual assignments
6.2 Workflow¶
- Operator triggers schedule generation (or it runs on a cron cycle every 15 minutes).
- Scheduler computes pass windows for all active campaigns in the organization.
- OR-Tools solver (or greedy fallback) produces an optimal assignment.
- Operator reviews the proposed schedule in the dashboard.
- Operator approves all or selected passes, which creates Assignment records in the database.
- Director picks up new assignments on its next tick.
6.3 Dashboard Integration¶
The schedule view shows:
- Timeline view (Gantt-style) of station utilization over the next 24 hours.
- Color-coded passes by campaign.
- Conflict indicators for overlapping manual assignments.
- One-click approval for proposed schedules.
7. Integration with Existing Models¶
7.1 Assignment Model¶
The current Assignment model links a station to a campaign:
class Assignment(Base):
__tablename__ = "assignments"
id: Mapped[str]
station_id: Mapped[str]
campaign_id: Mapped[str]
organization_id: Mapped[str]
created_at: Mapped[datetime]
The scheduler does not replace this model. It creates Assignment records as output. The distinction is:
- Manual assignment: Operator creates an assignment directly. No time window -- the station tracks whenever the satellite is visible.
- Scheduled assignment: Scheduler creates an assignment with an explicit time window (aos/los). The Director only commands the station during that window.
7.2 Schema Extension¶
Add optional time window fields to the Assignment model:
class Assignment(Base):
__tablename__ = "assignments"
# ... existing fields ...
scheduled_aos: Mapped[datetime | None] = mapped_column(default=None)
scheduled_los: Mapped[datetime | None] = mapped_column(default=None)
max_elevation: Mapped[float | None] = mapped_column(default=None)
scheduler_run_id: Mapped[str | None] = mapped_column(default=None)
This is backward-compatible. Existing manual assignments have scheduled_aos = None and continue to work as before.
7.3 Director Changes¶
The Director's get_active_assignments() query must be updated to respect time windows:
# Current: all assignments for active campaigns
# New: all assignments where NOW is within [scheduled_aos, scheduled_los]
# OR scheduled_aos is NULL (manual assignment, always active)
8. Testing Strategy¶
8.1 Unit Tests¶
- Pass window overlap detection (edge cases: adjacent, touching, nested).
- Slew time calculation with various antenna positions.
- Greedy scheduler with known inputs and expected outputs.
- OR-Tools formulation correctness (small problem, verify optimality).
8.2 Integration Tests¶
- End-to-end schedule generation via API.
- Conflict detection with existing manual assignments.
- Schedule approval creating correct Assignment records.
- Director respecting scheduled time windows.
8.3 Performance Tests¶
- Solver runtime with 50/100/200 station scenarios.
- Verify 10-second timeout is respected.
- Greedy fallback activation when solver times out.
9. Implementation Plan¶
| Phase | Scope | Effort |
|---|---|---|
| Phase 1 | Pass window computation + conflict detection | 2-3 days |
| Phase 2 | Greedy scheduler + API endpoints | 2-3 days |
| Phase 3 | OR-Tools CP-SAT formulation | 3-5 days |
| Phase 4 | Dashboard timeline view | 2-3 days |
| Phase 5 | Director time-window enforcement | 1-2 days |
| Total | 10-16 days |
Phase 1-2 deliver immediate value (conflict detection + basic scheduling). Phase 3 unlocks multi-campaign optimization. Phases 4-5 complete the user experience.
10. Dependencies¶
| Package | Version | Purpose |
|---|---|---|
ortools |
>= 9.9 | CP-SAT constraint solver |
skyfield |
>= 1.48 | Pass prediction (existing) |
dsgp4 |
>= 1.1.5 | Batch propagation (optional, Phase 3+) |
OR-Tools is available via pip (pip install ortools) and has no native compilation requirements on Linux/macOS/Windows. The package is ~60 MB installed.
Summary¶
Automated scheduling transforms TALOS from a tool that requires expert operators into a system that can manage complex multi-campaign networks autonomously. The two-phase approach (greedy first, OR-Tools second) ensures rapid delivery of basic scheduling while building toward globally optimal solutions. The design is backward-compatible with existing manual assignments and requires minimal Director changes.