Skip to content

Sight: Visibility Service

Sight is an in-process visibility engine that pre-computes and caches per-target, per-site, per-night observability data. By storing results in a PostgreSQL database, the Scheduler avoids re-running expensive trigonometric calculations on every scheduling cycle.


How it works

Sight uses a two-stage pipeline:

graph LR
    A[Target + Site + Night] --> B[Stage 1\nPositions]
    B --> C[Stage 2\nVisibility]
    C --> D[(PostgreSQL)]
    D --> E[Scheduler]
Stage What it computes Stored arrays
Stage 1 Target ephemerides for every time slot of the night RA, Dec, altitude, azimuth, hour angle, airmass, parallactic angle
Stage 2 Observation-level visibility given constraints remaining_minutes, visible_ranges (boolean mask → index ranges)

Stage 1 data is reused across all observations that share a target. Stage 2 is computed per observation because it depends on individual constraints (sky background, elevation limits, timing windows).


Configuration

Database connection

Sight uses PostgreSQL. Set DATABASE_URL in backend/.env:

DATABASE_URL=postgresql://user:password@localhost:5432/scheduler

See the Configuration page for the full list of DB_* tuning knobs (DB_POOL_SIZE, DB_POOL_OVERFLOW, DB_ECHO_SQL).

To use a Heroku remote Sight endpoint instead you can ask the correct url in the Slack channel for the Scheduler.


Local development

The database starts empty. Run the seeding script once to pre-populate it with visibility data for the bundled OCS validation programs (GN · 2018-08-01 → 2019-01-31):

cd backend
DATABASE_URL=postgresql://user:password@localhost:5432/scheduler \
  uv run python -m scheduler.scripts.fill_sight

The script is idempotent — re-running it skips targets that already exist and upserts any missing Stage 1 / Stage 2 rows without touching existing data.

Skipping local Sight altogether

If you don't need pre-computed data locally, switch the visibility strategy to local in config.yaml. The Scheduler will run the in-process calculator directly, with no database required:

collector:
  visibility_strategy: local

Calculator API

Calculator is the internal Python interface to Sight. All scheduler code that needs visibility data goes through this class. It is instantiated per-request via SQLAlchemy's AsyncSession.

from scheduler.services.sight.calculator.calculator import Calculator
from scheduler.services.sight.database.connection import session_scope

async with session_scope() as session:
    calc = Calculator(session)
    result = await calc.calculate_visibility(requests, night_date)

Data models

TargetCreate

Used when registering a new target with Sight.

Field Type Required Description
name str Yes Unique target name.
is_sidereal bool No (default True) False for non-sidereal targets.
base_ra float Yes Right ascension in degrees [0, 360).
base_dec float Yes Declination in degrees [-90, 90].
pm_ra float \| None No Proper motion in RA (mas/yr).
pm_dec float \| None No Proper motion in Dec (mas/yr).
epoch float \| None No (default 2000.0) Coordinate epoch.
horizons_id str \| None No JPL Horizons ID for non-sidereal targets.
tag str \| None No Non-sidereal type: majorbody, asteroid, or comet.

ObservationConstraints

Observation-level constraints fed into Stage 2.

Field Type Default Description
target_sb float 1.0 Sky background fraction (0.2, 0.5, 0.8, 1.0).
elevation_type ElevationType airmass none, hour_angle, or airmass.
elevation_min float 1.0 Lower bound for the chosen elevation type.
elevation_max float 2.05 Upper bound for the chosen elevation type.
timing_windows list[TimingWindow] [] UTC time windows when the observation may execute.
has_resources bool True Whether required instrument resources are available.
can_schedule bool True Whether the observation is schedulable.

ObservationRequest

The unit of work sent to the Calculator.

Field Type Description
observation_id str Unique observation identifier.
target_name str Must match a name registered via create_targets_bulk.
site_id str "GN" (Gemini North) or "GS" (Gemini South).
constraints ObservationConstraints Observing constraints.

VisibilityResult

Returned per observation by most read methods.

Field Type Description
observation_id str Mirrors the request.
target_name str Target name.
site str "GN" or "GS".
night_date date The night this result is valid for.
remaining_minutes int Total schedulable minutes on this night.
visible_ranges list[list[int]] Time-slot index ranges where the target is visible, e.g. [[10, 45], [120, 180]].

Methods

create_targets_bulk

Register targets and pre-compute Stage 1 data over a date range.

response: BulkTargetCreateResponse = await calc.create_targets_bulk(
    targets=[TargetCreate(name="NGC 1234", is_sidereal=True, base_ra=50.0, base_dec=-30.0)],
    start_date=date(2025, 2, 1),
    end_date=date(2025, 3, 31),
)
# response.created  → number of new targets
# response.failed   → number of skipped/failed targets
# response.errors   → list of error messages

Already-existing targets are skipped without error.


precompute_stage1

Compute Stage 1 arrays for existing targets across a date range. Useful after adding new nights or sites without re-creating targets.

result = await calc.precompute_stage1(
    start_date=date(2025, 2, 1),
    end_date=date(2025, 2, 28),
    target_names=["NGC 1234", "HD 12345"],   # None → all targets
    site_ids=["GN"],                          # None → GN + GS
)
# result → {"targets": 2, "sites": 1, "nights": 28, "total_computations": 56}

calculate_visibility

On-demand Stage 2 calculation for a single night. Results are not persisted.

response: CalculationResponse = await calc.calculate_visibility(
    requests=[
        ObservationRequest(
            observation_id="GN-2025A-Q-1-1",
            target_name="NGC 1234",
            site_id="GN",
            constraints=ObservationConstraints(target_sb=0.5, elevation_min=1.0, elevation_max=2.0),
        )
    ],
    night_date=date(2025, 2, 15),
)
for result in response.results:
    print(result.observation_id, result.remaining_minutes, result.visible_ranges)

store_visibility

Calculate and persist Stage 2 results across a date range. Used by fill_sight.py to seed the database.

stats = await calc.store_visibility(
    requests=requests,
    start_date=date(2025, 2, 1),
    end_date=date(2025, 3, 31),
)
# stats → {"stored": 840, "nights": 59}

get_visible_observations

Return only observations that are visible on a given night. Checks stored Stage 2 data first; falls back to on-demand calculation for observations without stored results.

visible: list[VisibilityResult] = await calc.get_visible_observations(
    requests=requests,
    night_date=date(2025, 2, 15),
)

get_precalculated_visibility

Fetch stored Stage 2 rows filtered by observation or target over a date range.

results = await calc.get_precalculated_visibility(
    start_date=date(2025, 2, 1),
    end_date=date(2025, 2, 28),
    observation_id="GN-2025A-Q-1-1",  # optional
    target_name="NGC 1234",            # optional; at least one filter required
)

get_precalculated_visibility_bulk

Batch fetch stored Stage 2 rows for many observations, grouped by observation → target → night.

data = await calc.get_precalculated_visibility_bulk(
    observation_ids=["GN-2025A-Q-1-1", "GN-2025A-Q-2-1"],
    start_date=date(2025, 2, 1),
    end_date=date(2025, 2, 28),
)
# data["GN-2025A-Q-1-1"]["targets"]["NGC 1234"]["nights"]["2025-02-15"]
# → {"night_date": ..., "site": "GN", "remaining_minutes": 120, "visible_ranges": [[10, 45]]}

get_cumulative_remaining_visibility

Sum remaining_minutes across all nights in a range, grouped by observation → target.

data = await calc.get_cumulative_remaining_visibility(
    observation_ids=["GN-2025A-Q-1-1"],
    start_date=date(2025, 2, 1),
    end_date=date(2025, 2, 28),
)
# data["GN-2025A-Q-1-1"]["targets"]["NGC 1234"]
# → {"site": "GN", "cumulative_remaining_minutes": 1680, "nights_with_visibility": 14}

get_stage1_bulk

Retrieve full Stage 1 arrays (all seven fields) for a set of targets, sites, and nights.

data = await calc.get_stage1_bulk(
    target_names=["NGC 1234"],
    site_ids=["GN"],
    start_date=date(2025, 2, 1),
    end_date=date(2025, 2, 28),
)
# data["NGC 1234"]["nights"]["GN_2025-02-15"]
# → {"night_date", "site", "night_duration_minutes",
#    "ra"[], "dec"[], "alt"[], "az"[], "hourangle"[], "airmass"[], "par_ang"[]}
# Angular arrays are in radians.

get_stage1_greedymax_bulk

Same as get_stage1_bulk but returns only the fields needed by the GreedyMax optimizer (RA, Dec, alt, az, airmass, hour angle — no parallactic angle), reducing payload size.

data = await calc.get_stage1_greedymax_bulk(
    target_names=["NGC 1234"],
    site_ids=["GN", "GS"],
    start_date=date(2025, 2, 1),
    end_date=date(2025, 2, 28),
)