Skip to content
Open
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: 1 addition & 1 deletion src/alert/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ pub use ztf::{
deserialize_fp_hists, deserialize_prv_candidate, deserialize_prv_candidates, Candidate, FpHist,
PrvCandidate, ZtfAlert, ZtfAlertWorker, ZtfAliases, ZtfCandidate, ZtfForcedPhot, ZtfObject,
ZtfPrvCandidate, ZtfRawAvroAlert, ZTF_DECAM_XMATCH_RADIUS, ZTF_DEC_RANGE,
ZTF_LSST_XMATCH_RADIUS,
ZTF_LSST_XMATCH_RADIUS, ZTF_POSITION_UNCERTAINTY,
};
106 changes: 98 additions & 8 deletions src/api/routes/babamul/surveys/objects.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::alert::{
LsstCandidate, LsstForcedPhot, LsstObject, LsstPrvCandidate, ZtfCandidate, ZtfForcedPhot,
ZtfObject, ZtfPrvCandidate, LSST_ZTF_XMATCH_RADIUS, ZTF_LSST_XMATCH_RADIUS,
ZTF_POSITION_UNCERTAINTY,
};
use crate::api::models::response;
use crate::api::routes::babamul::surveys::alerts::{EnrichedLsstAlert, EnrichedZtfAlert};
Expand Down Expand Up @@ -498,6 +499,10 @@ fn infer_survey_from_objectid(value: &str) -> Result<(Survey, String), String> {
#[derive(Debug, serde::Deserialize)]
pub struct SearchObjectsQuery {
object_id: Option<String>,
/// MPC provisional/permanent designation of a Solar System (moving) object. Looked up
/// independently against LSST (`designation`) and ZTF (`ssnamenr`) — an object may exist in
/// only one of the two surveys, so results from each are returned as-is, not merged.
designation: Option<String>,
ra: Option<f64>,
dec: Option<f64>,
radius: Option<f64>,
Expand Down Expand Up @@ -527,15 +532,19 @@ struct ObjectMini {
coordinates: Coordinates,
}

/// Search for objects by partial object ID or sky position across surveys.
/// Search for objects by partial object ID, Solar System object designation, or sky position
/// across surveys.
///
/// Provide either `object_id` (survey is auto-inferred) or all three of `ra` / `dec` / `radius`
/// for a cross-survey cone search over both ZTF and LSST. The two modes are mutually exclusive.
/// Provide exactly one of: `object_id` (survey is auto-inferred), `designation` (looked up
/// independently against LSST and ZTF, since a moving object may only exist in one of the two),
/// or all three of `ra` / `dec` / `radius` for a cross-survey cone search. The modes are
/// mutually exclusive.
#[utoipa::path(
get,
path = "/babamul/objects",
params(
("object_id" = Option<String>, Query, description = "Partial object ID to search for (mutually exclusive with ra/dec/radius)"),
("object_id" = Option<String>, Query, description = "Partial object ID to search for (mutually exclusive with designation and ra/dec/radius)"),
("designation" = Option<String>, Query, description = "MPC designation of a Solar System object to search for (mutually exclusive with object_id and ra/dec/radius)"),
("ra" = Option<f64>, Query, description = "Right ascension in degrees [0, 360) for cone search"),
("dec" = Option<f64>, Query, description = "Declination in degrees [-90, 90] for cone search"),
("radius" = Option<f64>, Query, description = "Search radius in arcseconds (0, 600] for cone search"),
Expand Down Expand Up @@ -568,13 +577,94 @@ pub async fn get_objects(
};

let has_object_id = query.object_id.is_some();
let has_designation = query.designation.is_some();
let has_position = query.ra.is_some() || query.dec.is_some() || query.radius.is_some();

if has_object_id && has_position {
return response::bad_request("Provide either object_id or ra/dec/radius, not both");
if (has_object_id as u8 + has_designation as u8 + has_position as u8) > 1 {
return response::bad_request(
"Provide only one of: object_id, designation, or ra/dec/radius",
);
}
if !has_object_id && !has_designation && !has_position {
return response::bad_request("Must provide one of: object_id, designation, ra/dec/radius");
}
if !has_object_id && !has_position {
return response::bad_request("Must provide either object_id or ra/dec/radius");

if has_designation {
let designation = query.designation.as_deref().unwrap();
let mut results: Vec<SearchObjectResult> = vec![];

let lsst_collection = db.collection::<ObjectMini>("LSST_alerts_aux");
match lsst_collection
.find(doc! { "designation": designation })
.limit(limit)
.await
{
Ok(mut cursor) => {
while let Ok(Some(obj)) = cursor.try_next().await {
let (ra, dec) = obj.coordinates.get_radec();
results.push(SearchObjectResult {
object_id: obj.object_id,
ra,
dec,
survey: Survey::Lsst,
distance_arcsec: None,
});
}
}
Err(error) => {
return response::internal_error(&format!(
"error searching LSST objects by designation: {}",
error
));
}
}

// `ssnamenr` is ZTF's *nearest* known Solar System object within 30 arcsec of the
// detection — it does not mean the detection actually is that object (e.g. a transient
// or artifact that happens to fall near a catalogued asteroid's track would also get a
// non-null ssnamenr). `ssdistnr` is the distance to that nearest object; a genuine
// detection of the named object should agree with its predicted position to within
// ZTF's own astrometric uncertainty, so we require ssdistnr to be within
// ZTF_POSITION_UNCERTAINTY (the same threshold used for real cross-survey matches)
// before treating the designation match as trustworthy. $elemMatch is required so both
// conditions are checked against the *same* prv_candidates/prv_nondetections entry.
let ss_elem_match = doc! {
"ssnamenr": designation,
"ssdistnr": { "$lte": ZTF_POSITION_UNCERTAINTY },
};
let ztf_collection = db.collection::<ObjectMini>("ZTF_alerts_aux");
match ztf_collection
.find(doc! {
"$or": [
{ "prv_candidates": { "$elemMatch": ss_elem_match.clone() } },
{ "prv_nondetections": { "$elemMatch": ss_elem_match } },
]
})
.limit(limit)
.await
{
Ok(mut cursor) => {
while let Ok(Some(obj)) = cursor.try_next().await {
let (ra, dec) = obj.coordinates.get_radec();
results.push(SearchObjectResult {
object_id: obj.object_id,
ra,
dec,
survey: Survey::Ztf,
distance_arcsec: None,
});
}
}
Err(error) => {
return response::internal_error(&format!(
"error searching ZTF objects by designation: {}",
error
));
}
}

results.truncate(limit as usize);
return response::ok_ser(&format!("Found {} objects", results.len()), results);
}

if has_object_id {
Expand Down
20 changes: 20 additions & 0 deletions src/utils/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ pub async fn initialize_survey_indexes(
create_index(&alerts_aux_collection, index, false).await?;
}

// if survey is ZTF, index the nearest-known-SSO designation (ssnamenr) together with its
// distance (ssdistnr), so a moving object can be looked up by designation without a spatial
// query. Both fields are indexed together (and queried via $elemMatch) because ssnamenr
// alone only means "nearest known SSO within 30 arcsec" — ssdistnr must also be checked, on
// the same prv_candidates/prv_nondetections entry, to confirm the alert is actually that
// object rather than merely near it.
if survey == &Survey::Ztf {
let index = doc! {
"prv_candidates.ssnamenr": 1,
"prv_candidates.ssdistnr": 1,
};
create_index(&alerts_aux_collection, index, false).await?;

let index = doc! {
"prv_nondetections.ssnamenr": 1,
"prv_nondetections.ssdistnr": 1,
};
create_index(&alerts_aux_collection, index, false).await?;
}

Ok(())
}

Expand Down
Loading
Loading