Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
fcf1df3
Fix mypy issues
ugyballoons Oct 28, 2025
342e6c4
Add seqNum highlighting from URL query string
ugyballoons Oct 29, 2025
ba8cc60
Make the highlighted row stand out more
ugyballoons Oct 30, 2025
bd03ead
Allow seqNum ranges in query
ugyballoons Oct 30, 2025
7d48837
Refactor websocket notifiers
ugyballoons Oct 31, 2025
e90d1f5
Implement prev/next day buttons
ugyballoons Nov 4, 2025
e175f44
Add aria labels and tests
ugyballoons Nov 4, 2025
8629985
Style jump to date buttons
ugyballoons Nov 4, 2025
2115af9
Show/hide calendar and move channels view to component
ugyballoons Nov 4, 2025
4da96ce
Fix faulty display conditional in PerDay
ugyballoons Nov 5, 2025
1cfb7ff
Revise query types and rename seqNums
ugyballoons Nov 5, 2025
df86597
Fix broken table jump buttons
ugyballoons Nov 5, 2025
274e7c7
Use set for seqNum range and tighten type constraints
ugyballoons Nov 5, 2025
723f295
Handle seq_num query more robustly
ugyballoons Nov 5, 2025
1d13a75
Scroll to opening calendar
ugyballoons Nov 5, 2025
eccb394
Provide better accessibility for calendar toggle
ugyballoons Nov 5, 2025
b70046e
Scroll to seq_num range even if range limits don't exist
ugyballoons Nov 5, 2025
66a5475
Revise test
ugyballoons Nov 5, 2025
c619c60
Preserve time-since clock after only new channel data on day rollover
ugyballoons Nov 13, 2025
05cb4fa
Add historical breadcrumb and smooth calendar animation
ugyballoons Jan 22, 2026
5d3b5ca
Improve row highlighting and dodge Filter colours
ugyballoons Jan 23, 2026
7c90041
Specify button style only for clickable date
ugyballoons Feb 5, 2026
c257804
Store historical Events as strings
ugyballoons Sep 18, 2025
61a54d6
Add linting modules for dev
ugyballoons Sep 18, 2025
ee9b23e
Add tests for HistoricalData poller
ugyballoons Sep 18, 2025
da5e5df
Remove untestable test
ugyballoons Sep 18, 2025
3460a74
Refactor registering client services in websocket
ugyballoons Sep 18, 2025
0aab06c
Demote websocket logs info -> debug
ugyballoons Sep 18, 2025
00e3f30
Make historical event storage more effective
ugyballoons Oct 7, 2025
51ed1d8
Fix mypy issues
ugyballoons Oct 9, 2025
8cb8d31
Retrieve metadata by request
ugyballoons Oct 9, 2025
12dd392
Edit tests for HistoricalData
ugyballoons Oct 9, 2025
f5ee11f
Prepare to pass structured data over API
ugyballoons Oct 10, 2025
5846cc3
Add missing metadata initialisation
ugyballoons Oct 10, 2025
46b7708
Tidy test and add mypy ignore
ugyballoons Oct 10, 2025
1c6eec9
Add TS table data re-creation functionality
ugyballoons Oct 10, 2025
5128d26
Send and process compact structured data for tables
ugyballoons Oct 11, 2025
b817350
Apply co-pilot PR review recommendations
ugyballoons Oct 28, 2025
6dbf2dc
Fix dataclass tests
ugyballoons Oct 28, 2025
79709fb
Prefetch and cache metadata
ugyballoons Nov 6, 2025
70bd52e
Remove type juggling and comments
ugyballoons Nov 6, 2025
3446fb5
Finish rebase
ugyballoons Dec 17, 2025
be41482
Integrate today's data at day rollover
ugyballoons Jan 5, 2026
51b318f
Reinstate manual historical reload
ugyballoons Jan 6, 2026
73104f8
Remove test prefix
ugyballoons Jan 6, 2026
f2bedeb
Refactor historical metadata collection
ugyballoons Jan 8, 2026
ef16181
Patch rollover integration tests
ugyballoons Jan 8, 2026
f136d85
Repoll the bucket periodically for historical changes
ugyballoons Jan 9, 2026
5d7cbc6
Revise update strategy and add tests
ugyballoons Jan 9, 2026
773a1ad
Create api for consolidated data requests
ugyballoons Jan 12, 2026
a949d1b
Refactor structured data making and use for current table data too
ugyballoons Jan 19, 2026
bb66234
Remove redundant test
ugyballoons Jan 19, 2026
1f93a64
Restore historical not busy notification
ugyballoons Jan 26, 2026
9aa8059
Add browser console websocket message debug logging
ugyballoons Jan 26, 2026
ab7ff3b
Add docstring
ugyballoons Jan 27, 2026
358bbe7
Fix test to look for current day obs not today
ugyballoons Jan 27, 2026
91297ff
Consolidate camera page data structure
ugyballoons Jan 28, 2026
ae38021
Remove unnecessary quoting of classes
ugyballoons Feb 5, 2026
f6b5634
Fix: Restore notify_controls_readback_change function removed in refa…
ugyballoons Jan 21, 2026
6fbc200
Send structured event data via websocket too
ugyballoons Feb 9, 2026
fa0561d
Serialize websocket data with orjson
ugyballoons Feb 9, 2026
02827ef
Refactor api logic
ugyballoons Feb 11, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,6 @@ local_scripts/

# jest
coverage/

# github prompts
.github/prompts/*
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ The codebase aims to be modular and easy to extend: a backend built with FastAPI

This repository contains the core services and documentation to get started quickly and to follow project conventions.

## API discovery

As per every FastAPI project, the full API can be found at `/docs`
e.g. for the [dev deployment at USDF](https://usdf-rsp-dev.slac.stanford.edu/rubintv/docs)

## Project layout (typical)

- `python/lsst/ts/rubintv` - FastAPI application and service code
Expand Down
25 changes: 24 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,28 @@ dev = [
"types-PyYAML",
"beautifulsoup4",
"types-python-dateutil",
"types-redis"
"types-redis",
"flake8"
]

[tool.mypy]
python_version = "3.12"
strict = false
warn_return_any = false
warn_unused_configs = true
disallow_untyped_defs = false
disallow_incomplete_defs = false
check_untyped_defs = true
disallow_untyped_decorators = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
ignore_missing_imports = true
show_error_codes = true
disable_error_code = "type-arg"

[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true
98 changes: 95 additions & 3 deletions python/lsst/ts/rubintv/background/background_helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from lsst.ts.rubintv.config import rubintv_logger
from lsst.ts.rubintv.models.models import Event
from lsst.ts.rubintv.models.models import Event, ExtensionInfo, StructuredData

logger = rubintv_logger()

Expand Down Expand Up @@ -33,12 +33,17 @@ async def get_next_previous_from_table(
if chan_table == {}:
return (None, None)

# this should never happen
if isinstance(event.seq_num, str):
logger.warning("Looking for prev/next for Per Day Event")
return (None, None)

# creates a 'None' padded list of seq. nums
all_seqs = sorted(set(chan_table.keys() | {event.seq_num_force_int()}))
all_seqs = sorted(set(chan_table.keys() | {event.seq_num}))
padded_seqs = [None, *all_seqs, None]

# find the index of event's seq num in that padded list
index = padded_seqs.index(event.seq_num_force_int())
index = padded_seqs.index(event.seq_num)

next_seq = padded_seqs[index + 1]
prev_seq = padded_seqs[index - 1]
Expand All @@ -49,3 +54,90 @@ async def get_next_previous_from_table(
)

return nxt_prv


async def make_structured_data(
events: list[Event],
) -> StructuredData:
"""Build structured data from events.

Structure: {channel_name: {seq_num1, seq_num2, ...}}

This is a compressed format suitable for WebSocket transmission.

Parameters
----------
events : list[Event]
List of events to build structured data from.

Returns
-------
structured: StructuredData
Structured data mapping channel names to sets of sequence numbers.
"""
structured: StructuredData = {}
for event in events:
channel_name = event.channel_name
seq_num = event.seq_num
if channel_name not in structured:
structured[channel_name] = set()
structured[channel_name].add(seq_num)
return structured


async def make_extension_info(
events: list[Event],
) -> ExtensionInfo:
"""Build extension info from events.

Structure:
{channel_name: {"default": "ext", "exceptions": {seq_num: "ext"}}}

This is needed for the frontend to reconstruct filenames from
structuredData.

Parameters
----------
events : list[Event]
List of events to build extension info from.

Returns
-------
extension_info: ExtensionInfo
Extension info mapping channel names to extension metadata.
Each channel has a default extension and exceptions for specific
seq_nums.
"""
extension_info: ExtensionInfo = {}
extension_counts: dict[str, dict[str, int]] = {}

for event in events:
channel_name = event.channel_name
ext = event.ext
if channel_name not in extension_info:
extension_info[channel_name] = {"default": "jpg", "exceptions": {}}
extension_counts[channel_name] = {}

# Track extension frequency to determine default
if ext not in extension_counts[channel_name]:
extension_counts[channel_name][ext] = 0
extension_counts[channel_name][ext] += 1

# Set default to most common extension, record others as exceptions
for channel_name, ext_counts in extension_counts.items():
if ext_counts:
default_ext = max(ext_counts.items(), key=lambda x: x[1])[0]
extension_info[channel_name]["default"] = default_ext

# Mark extensions different from default as exceptions
for event in events:
channel_name = event.channel_name
ext = event.ext
default_ext = extension_info[channel_name]["default"]
if ext != default_ext:
seq_num = (
int(event.seq_num) if isinstance(event.seq_num, str) else event.seq_num
)
extension_info[channel_name]["exceptions"][seq_num] = ext

return extension_info
75 changes: 64 additions & 11 deletions python/lsst/ts/rubintv/background/currentpoller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,26 @@
from asyncio import Event as AsyncioEvent
from asyncio import sleep
from time import time
from typing import AsyncGenerator
from typing import TYPE_CHECKING, AsyncGenerator

from lsst.ts.rubintv.background.background_helpers import get_next_previous_from_table
from lsst.ts.rubintv.background.background_helpers import (
get_next_previous_from_table,
make_extension_info,
make_structured_data,
)
from lsst.ts.rubintv.config import rubintv_logger
from lsst.ts.rubintv.handlers.websocket_notifiers import notify_ws_clients
from lsst.ts.rubintv.models.models import (
Camera,
Event,
ExtensionInfo,
Location,
NightReport,
NightReportData,
)
from lsst.ts.rubintv.models.models import ServiceMessageTypes as MessageType
from lsst.ts.rubintv.models.models import ServiceTypes as Service
from lsst.ts.rubintv.models.models import get_current_day_obs
from lsst.ts.rubintv.models.models import StructuredData, get_current_day_obs
from lsst.ts.rubintv.models.models_helpers import (
all_objects_to_events,
make_table_from_event_list,
Expand All @@ -25,6 +30,9 @@
from lsst.ts.rubintv.s3_connection_pool import get_shared_s3_client
from lsst.ts.rubintv.s3client import S3Client

if TYPE_CHECKING:
from lsst.ts.rubintv.background.historicaldata import HistoricalPoller

logger = rubintv_logger()


Expand All @@ -44,10 +52,13 @@ def __init__(
test_mode: bool = False,
) -> None:
self._s3clients: dict[str, S3Client] = {}
self._historical_poller: HistoricalPoller | None = None
self._objects: dict[str, list] = {}
self._events: dict[str, list[Event]] = {}
self._metadata: dict[str, dict] = {}
self._table: dict[str, dict[int, dict[str, dict]]] = {}
self._structured_events: dict[str, StructuredData] = {}
self._extension_info: dict[str, ExtensionInfo] = {}
self._per_day: dict[str, dict[str, dict]] = {}
self._yesterday_prefixes: dict[str, list[str]] = {}
self._most_recent_events: dict[str, Event] = {}
Expand All @@ -61,17 +72,23 @@ def __init__(
self.completed_first_poll_event = first_pass_event

self.locations = locations
self._current_day_obs = get_current_day_obs()
self._last_day_obs = get_current_day_obs()
for location in locations:
self._s3clients[location.name] = get_shared_s3_client(
location.profile_name, location.bucket_name, location.endpoint_url
)

def set_historical_poller(self, hp: "HistoricalPoller") -> None:
"""Set reference to HistoricalPoller for day rollover integration."""
self._historical_poller = hp

async def clear_todays_data(self) -> None:
self._objects = {}
self._events = {}
self._metadata = {}
self._table = {}
self._structured_events = {}
self._extension_info = {}
self._per_day = {}
self._most_recent_events = {}
self._nr_metadata = {}
Expand Down Expand Up @@ -102,18 +119,22 @@ async def check_for_empty_per_day_channels(self) -> None:
]
loc_prefixes = self._yesterday_prefixes[location.name]
for chan in missing_chans:
prefix = f"{camera.name}/{self._current_day_obs}/{chan.name}"
prefix = f"{camera.name}/{self._last_day_obs}/{chan.name}"
loc_prefixes.append(prefix)

async def poll_buckets_for_todays_data(self, test_day: str = "") -> None:
time_total = 0.0
while True:
timer_start = time()
try:
if self._current_day_obs != get_current_day_obs():
if self._last_day_obs != get_current_day_obs():
# Day has changed - integrate with historical before
# clearing
if self._historical_poller:
await self._historical_poller.integrate_todays_data(self)
await self.check_for_empty_per_day_channels()
await self.clear_todays_data()
day_obs = self._current_day_obs = get_current_day_obs()
day_obs = self._last_day_obs = get_current_day_obs()

for location in self.locations:
client = self._s3clients[location.name]
Expand Down Expand Up @@ -237,8 +258,18 @@ async def process_channel_objects(

table = await self.make_channel_table(camera, events)
self._table[loc_cam] = table

structured = await make_structured_data(events)
self._structured_events[loc_cam] = structured

ext_info = await make_extension_info(events)
self._extension_info[loc_cam] = ext_info
ws_payload = {
"structuredData": structured,
"extensionInfo": ext_info,
}
await notify_ws_clients(
Service.CAMERA, MessageType.CAMERA_TABLE, loc_cam, table
Service.CAMERA, MessageType.CAMERA_TABLE, loc_cam, ws_payload
)

# clear all relevant prefixes from the store looking for
Expand Down Expand Up @@ -473,6 +504,21 @@ async def get_current_channel_table(
table = self._table.get(loc_cam, {})
return table

async def get_current_structured_data(
self, location_name: str, camera: Camera
) -> dict[str, set[int | str]]:
"""Get compressed structured data for current day.

Returns:
{channel_name: [seq_num1, seq_num2, ...]}
"""
loc_cam = self._get_loc_cam(location_name, camera)
structured = self._structured_events.get(loc_cam, {})
# replaced_sets: dict[str, list[int | str]] = {
# chan: list(seq_nums) for chan, seq_nums in structured.items()
# }
return structured

async def get_current_per_day_data(
self, location_name: str, camera: Camera
) -> dict[str, dict[str, dict]]:
Expand Down Expand Up @@ -563,10 +609,17 @@ async def get_latest_data(
) -> AsyncGenerator:
match service:
case Service.CAMERA:
channel_data = await self.get_current_channel_table(
structured = await self.get_current_structured_data(
location.name, camera
)
yield MessageType.CAMERA_TABLE, channel_data
ext_info = self._extension_info.get(
f"{location.name}/{camera.name}", {}
)
ws_payload = {
"structuredData": structured,
"extensionInfo": ext_info,
}
yield MessageType.CAMERA_TABLE, ws_payload

metadata = await self.get_current_metadata(location.name, camera)
yield MessageType.CAMERA_METADATA, metadata
Expand Down Expand Up @@ -620,7 +673,7 @@ async def get_latest_data(
yield MessageType.CAMERA_PER_DAY, latest_per_day

async def get_all_channel_names_for_seq_num(
self, location_name: str, camera_name: str, seq_num: int
self, location_name: str, camera_name: str, seq_num: int | str
) -> list[str]:
"""Get all channel names for a given sequence number.
Parameters
Expand Down
Loading