Skip to content

Commit 9c55f51

Browse files
committed
Add exposure_list DF to lvmcore in post-observing recipe
1 parent e84e35b commit 9c55f51

File tree

5 files changed

+1051
-737
lines changed

5 files changed

+1051
-737
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Next release
4+
5+
### ✨ Improved
6+
7+
* Add an `exposure_list_<MJD>.parquet` file to `LVMCORE_DIR/exposure_list` at the end of the night with the list of exposures taken.
8+
9+
310
## 1.11.5 - December 26, 2025
411

512
### ✨ Improved
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# @Author: José Sánchez-Gallego (gallegoj@uw.edu)
5+
# @Date: 2026-02-27
6+
# @Filename: exposure_list_to_lvmcore.py
7+
# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause)
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import os
13+
import pathlib
14+
15+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn
16+
17+
from sdsstools.time import get_sjd
18+
19+
from gort.tools import get_exposure_list
20+
21+
22+
async def exposure_list_to_lvmcore(overwrite: bool = False):
23+
"""Populates ``lvmcore`` with ``exposure_list`` files for all MJDs."""
24+
25+
lvmcore_dir = os.environ.get("LVMCORE_DIR", None)
26+
if not lvmcore_dir:
27+
raise RuntimeError("LVMCORE_DIR environment variable is not set.")
28+
29+
lvmcore_dir = pathlib.Path(lvmcore_dir)
30+
if not lvmcore_dir.exists():
31+
raise RuntimeError(f"LVMCORE_DIR {lvmcore_dir} does not exist.")
32+
33+
exp_list_dir = lvmcore_dir / "exposure_list"
34+
exp_list_dir.mkdir(exist_ok=True)
35+
36+
progress = Progress(
37+
TextColumn("[progress.description]{task.description}"),
38+
BarColumn(bar_width=None),
39+
MofNCompleteColumn(),
40+
expand=True,
41+
auto_refresh=True,
42+
)
43+
44+
mjd0 = 60007
45+
mjd1 = get_sjd("LCO")
46+
47+
task = progress.add_task(f"Processing MJD {mjd0}", total=(mjd1 - mjd0 + 1))
48+
49+
with progress:
50+
for mjd in range(mjd0, mjd1 + 1):
51+
exp_list_file = exp_list_dir / f"exposure_list_{mjd}.parquet"
52+
if exp_list_file.exists() and not overwrite:
53+
progress.update(task, advance=1)
54+
continue
55+
56+
progress.update(task, description=f"Processing MJD {mjd}")
57+
58+
try:
59+
df = await get_exposure_list(mjd)
60+
df.write_parquet(exp_list_file)
61+
except Exception:
62+
pass
63+
64+
progress.update(task, advance=1)
65+
66+
67+
if __name__ == "__main__":
68+
asyncio.run(exposure_list_to_lvmcore())

src/gort/recipes/operations.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,22 @@
1010

1111
import asyncio
1212
import json
13+
import os
14+
import pathlib
1315

1416
from typing import TYPE_CHECKING, ClassVar, Coroutine
1517

1618
from rich.prompt import Confirm
1719

20+
from sdsstools.time import get_sjd
21+
1822
from gort.overwatcher.helpers import get_actor_ping, restart_actors
19-
from gort.tools import decap, get_lvmapi_route, overwatcher_is_running
23+
from gort.tools import (
24+
decap,
25+
get_exposure_list,
26+
get_lvmapi_route,
27+
overwatcher_is_running,
28+
)
2029

2130
from .base import BaseRecipe
2231

@@ -459,6 +468,29 @@ async def recipe(self, send_email: bool = True, force_park: bool = False):
459468
else:
460469
self.gort.log.info("Overwatcher has been disabled.")
461470

471+
# Add the list of exposures to lvmcore
472+
lvmcore_dir = os.environ.get("LVMCORE_DIR", None)
473+
if not lvmcore_dir:
474+
self.gort.log.warning("LVMCORE_DIR environment variable is not set.")
475+
return
476+
477+
lvmcore_dir = pathlib.Path(lvmcore_dir)
478+
if not lvmcore_dir.exists():
479+
self.gort.log.warning(f"LVMCORE_DIR {lvmcore_dir} does not exist.")
480+
return
481+
482+
exp_list_dir = lvmcore_dir / "exposure_list"
483+
exp_list_dir.mkdir(exist_ok=True)
484+
485+
mjd = get_sjd("LCO")
486+
exp_list_file = exp_list_dir / f"exposure_list_{mjd}.parquet"
487+
488+
try:
489+
df = await get_exposure_list(mjd)
490+
df.write_parquet(exp_list_file)
491+
except Exception as err:
492+
self.gort.log.warning(f"Failed to write exposure list: {err}")
493+
462494

463495
class RebootAGsRecipe(BaseRecipe):
464496
"""Reboots the AG cameras."""

src/gort/tools.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
TypedDict,
3737
TypeVar,
3838
cast,
39+
overload,
3940
)
4041

4142
import httpx
@@ -106,6 +107,7 @@
106107
"record_overheads",
107108
"OverheadDict",
108109
"ping_host",
110+
"get_exposure_list",
109111
]
110112

111113
AnyPath = str | os.PathLike
@@ -1224,3 +1226,62 @@ async def ping_host(host: str):
12241226

12251227
return_code = await cmd.wait()
12261228
return return_code == 0
1229+
1230+
1231+
@overload
1232+
async def get_exposure_list(
1233+
mjd: int | None = None,
1234+
as_dataframe: bool = True,
1235+
) -> polars.DataFrame: ...
1236+
1237+
1238+
@overload
1239+
async def get_exposure_list(
1240+
mjd: int | None = None,
1241+
as_dataframe: bool = False,
1242+
) -> dict: ...
1243+
1244+
1245+
async def get_exposure_list(
1246+
mjd: int | None = None,
1247+
as_dataframe: bool = True,
1248+
) -> dict | polars.DataFrame:
1249+
"""Retrieves the list of exposures for a given MJD from the LVM API."""
1250+
1251+
mjd = mjd or get_sjd("LCO")
1252+
data: dict = await get_lvmapi_route(f"/logs/exposures/data/{mjd}", timeout=15)
1253+
1254+
if not as_dataframe:
1255+
return data
1256+
1257+
data_df: list[dict[str, Any]] = []
1258+
1259+
for exposure in data.values():
1260+
lamps = exposure.pop("lamps")
1261+
data_df.append({**exposure, **lamps})
1262+
1263+
schema = {
1264+
"exposure_no": polars.Int32,
1265+
"mjd": polars.Int32,
1266+
"obstime": polars.String,
1267+
"image_type": polars.String,
1268+
"exposure_time": polars.Float32,
1269+
"ra": polars.Float64,
1270+
"dec": polars.Float64,
1271+
"airmass": polars.Float32,
1272+
"n_standards": polars.Int16,
1273+
"n_cameras": polars.Int16,
1274+
"object": polars.String,
1275+
"dpos": polars.Int16,
1276+
"Argon": polars.Boolean,
1277+
"Neon": polars.Boolean,
1278+
"LDLS": polars.Boolean,
1279+
"Quartz": polars.Boolean,
1280+
"HgNe": polars.Boolean,
1281+
"Xenon": polars.Boolean,
1282+
}
1283+
1284+
if len(data_df) == 0:
1285+
return polars.DataFrame(schema=schema)
1286+
1287+
return polars.DataFrame(data_df, schema=schema)

0 commit comments

Comments
 (0)