Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions app_lsst.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

from apps.routes.v1.lsst.sources.api import ns as ns_sources
from apps.routes.v1.lsst.objects.api import ns as ns_objects
from apps.routes.v1.lsst.fp.api import ns as ns_fp
from apps.routes.v1.lsst.conesearch.api import ns as ns_conesearch
from apps.routes.v1.lsst.cutouts.api import ns as ns_cutouts
from apps.routes.v1.lsst.schema.api import ns as ns_schema
Expand Down Expand Up @@ -82,6 +83,7 @@ def after_request(response):
# Register namespace
api.add_namespace(ns_sources)
api.add_namespace(ns_objects)
api.add_namespace(ns_fp)
api.add_namespace(ns_conesearch)
api.add_namespace(ns_cutouts)
api.add_namespace(ns_schema)
Expand Down
Empty file.
80 changes: 80 additions & 0 deletions apps/routes/v1/lsst/fp/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright 2026 AstroLab Software
# Author: Julien Peloton
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from flask import Response, request
from flask_restx import Namespace, Resource, fields

from apps.utils.utils import check_args
from apps.utils.utils import send_tabular_data

from apps.routes.v1.lsst.fp.utils import extract_fp_data

ns = Namespace("api/v1/fp", "Get forced photometry data based on Rubin diaObjectId")

ARGS = ns.model(
"fp",
{
"diaObjectId": fields.String(
description='single Rubin Object ID as STRING, or a comma-separated list of object ID, e.g. "169298433216610349"',
example="169298433216610349",
required=True,
),
"columns": fields.String(
description="Comma-separated data columns to transfer, e.g. 'i:midpointMjdTai,i:psfFlux,i:band'. If not specified, transfer all columns (slow).",
example="r:midpointMjdTai,r:psfFlux,r:band",
required=False,
),
"output-format": fields.String(
description="Output format among json[default], csv, parquet, votable.",
example="json",
required=False,
),
},
)


@ns.route("")
@ns.doc(params={k: ARGS[k].description for k in ARGS})
class Fp(Resource):
def get(self):
"""Retrieve forced photometry data from the Fink/LSST database based on their name"""
payload = request.args
if len(payload) > 0:
# POST from query URL
return self.post()
else:
return Response(ns.description, 200)

@ns.expect(ARGS, location="json", as_dict=True)
def post(self):
"""Retrieve forced photometry data from the Fink/LSST database based on their name"""
# get payload from the query URL
payload = request.args

if payload is None or len(payload) == 0:
# if no payload, try the JSON blob
payload = request.json

rep = check_args(ARGS, payload)
if rep["status"] != "ok":
return Response(str(rep), 400)

out = extract_fp_data(payload)

# Error propagation
if isinstance(out, Response):
return out

output_format = payload.get("output-format", "json")
return send_tabular_data(out, output_format)
86 changes: 86 additions & 0 deletions apps/routes/v1/lsst/fp/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Copyright 2026 AstroLab Software
# Author: Julien Peloton
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pandas as pd

from apps.utils.client import connect_to_hbase_table
from apps.utils.decoding import format_lsst_hbase_output

from line_profiler import profile


@profile
def extract_fp_data(payload: dict) -> pd.DataFrame:
"""Extract data returned by HBase and format it in a Pandas dataframe

Data is from /api/v1/fp

Parameters
----------
payload: dict
See https://api.lsst.fink-portal.org

Return
----------
out: pandas dataframe
"""
if "columns" in payload:
cols = payload["columns"].replace(" ", "")
else:
cols = "*"

if "," in payload["diaObjectId"]:
# multi-objects search
splitids = payload["diaObjectId"].split(",")
splitids = [i.strip() for i in splitids]
# add salt
objectids = [f"key:key:{i[-3:]}_{i}" for i in splitids]
else:
# single object search
salt = payload["diaObjectId"][-3:]
key = payload["diaObjectId"]
objectids = ["key:key:{}_{}".format(salt, key)]

if cols == "*":
truncated = False
else:
truncated = True

client = connect_to_hbase_table("rubin.fp")

# Get data from the main table
results = {}
for to_evaluate in objectids:
result = client.scan(
"",
to_evaluate,
cols,
0,
True,
True,
)
results.update(result)

schema_client = client.schema()

pdf = format_lsst_hbase_output(
results,
schema_client,
group_alerts=False,
truncated=truncated,
)

client.close()

return pdf
20 changes: 20 additions & 0 deletions apps/routes/v1/lsst/schema/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ def extract_schema(payload: dict) -> Response:
)
diaSource_schema = r_diaSource.json()["fields"]

r_diaForcedSource = requests.get(
"{}/{}/{}/lsst.v{}_{}.diaSource.avsc".format(
base_url, major_version, minor_version, major_version, minor_version
)
)
forcedDiaSource_schema = r_diaForcedSource.json()["fields"]

r_diaObject = requests.get(
"{}/{}/{}/lsst.v{}_{}.diaObject.avsc".format(
base_url, major_version, minor_version, major_version, minor_version
Expand Down Expand Up @@ -576,6 +583,16 @@ def extract_schema(payload: dict) -> Response:
}
),
}
if payload["endpoint"] == "/api/v1/fp":
# root, diaSOurce, fink
types = {
"LSST original fields (r:)": sort_dict(
{
i["name"]: {"type": i["type"], "doc": i.get("doc", "TBD")}
for i in forcedDiaSource_schema
}
),
}
elif payload["endpoint"] == "/api/v1/objects":
# root, diaObject, fink
types = {
Expand Down Expand Up @@ -710,8 +727,11 @@ def extract_schema(payload: dict) -> Response:
root_list
+ reconstruct_lsst_schema(diaObject_schema, "diaObject.")
+ reconstruct_lsst_schema(diaSource_schema, "diaSource.")
+ reconstruct_lsst_schema(diaSource_schema, "prvDiaSources.")
+ reconstruct_lsst_schema(forcedDiaSource_schema, "prvDiaForcedSources.")
+ reconstruct_lsst_schema(ssSource_schema, "ssSource.")
+ reconstruct_lsst_schema(mpc_orbits_schema, "mpc_orbits.")
+ cutout_list
)
types = {
"LSST": sort_dict(
Expand Down
4 changes: 2 additions & 2 deletions apps/routes/v1/lsst/sources/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
required=False,
),
"columns": fields.String(
description="Comma-separated data columns to transfer, e.g. 'i:midpointMjdTai,i:psfFlux,i:band'. If not specified, transfer all columns (slow).",
example="i:midpointMjdTai,i:psfFlux,i:band",
description="Comma-separated data columns to transfer, e.g. 'r:midpointMjdTai,r:psfFlux,r:band'. If not specified, transfer all columns (slow).",
example="r:midpointMjdTai,r:psfFlux,r:band",
required=False,
),
"output-format": fields.String(
Expand Down
6 changes: 5 additions & 1 deletion apps/routes/v1/ztf/schema/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def check_recent_columns(columns, objectId):
"cutoutDifference_stampData",
"cutoutScience_stampData",
"cutoutTemplate_stampData",
"anomaly_score",
]
]

Expand All @@ -74,8 +75,11 @@ def check_recent_columns(columns, objectId):
)

# `spicy_name` was introduced by mistake instead of `spicy_class` for 2024/02/01
# Anomaly can be null...
outside_definition = [
i for i in obtained if (i not in definition) and (i != "spicy_name")
i
for i in obtained
if (i not in definition) and (i != "spicy_name") and (i != "anomaly_score")
]
assert len(outside_definition) == 0, (
"Not in defined fields",
Expand Down