Skip to content

Commit f80b19a

Browse files
authored
Merge pull request #772 from lincc-frameworks/from_catalog
Improve documentation for position sampling
2 parents 14326c6 + b84fc80 commit f80b19a

4 files changed

Lines changed: 299 additions & 19 deletions

File tree

docs/faq.rst

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ your simulation:
1616
You can also use `logging.DEBUG` to see even more detailed debug-level messages.
1717

1818

19-
I Am Seeing Empty Light Curves for Some Entrys. Why Is This Happening?
19+
I Am Seeing Empty Light Curves for Some Entries. Why Is This Happening?
2020
-------------------------------------------------------------------------------
2121

2222
The most common reason that LightCurveLynx produces rows with empty light curves is
@@ -80,3 +80,16 @@ Yes with some caveats. LightCurveLynx has built-in support for simulating spectr
8080
in an early stage of development and does not yet add noise to the measurements. In addition, spectra
8181
simulation is **only** compaible with models that generate data on the spectral level (not bandflux-only
8282
models). For more detail see :doc:`the spectrograph demo notebook <notebooks/spectrograph_demo>`.
83+
84+
85+
Can I Generate Points from a Catalog?
86+
--------------------------------------------------------------------------------
87+
88+
Yes. LightCurveLynx allows you to generate light curves for objects in a catalog containing
89+
positions using the``CatalogRADECSampler`` object This sampler takes in a table
90+
of information with at least "ra" and "dec" columns. The helper function
91+
``from_hats()`` is provided to load directly from a [HATS](https://www.ivoa.net/documents/Notes/HATS/)
92+
catalog.
93+
94+
See the :doc:`sampling positions demo notebook <notebooks/sampling_positions>` notebook
95+
for a detailed description of how to sample (RA, dec) positions.

docs/notebooks/sampling_positions.ipynb

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66
"source": [
77
"# Sampling (RA, dec)\n",
88
"\n",
9-
"LightCurveLynx provides multiple mechanisms for sampling (RA, dec) from choosing points uniformly on a sphere to using the footprint of a given survey. In this notebook we discuss several of the approaches and their relative tradeoffs.\n",
9+
"Sampling the position (RA, dec) of an object on the sky is a critical for in survey-based simulation. LightCurveLynx matches the sampled positions (RA, dec) with the survey information (`ObsTable`) to determine when each object is observed. Points from the object's light curve are *only* generated at those times where the object is observed. For example if an object is at (45.0, -10.0) and the survey includes this points at MJDs 60676.0 and 60678.0, the generated light curve will only have two fluxes. More importantly, if the sampled position falls outside the survey's footprint the returned light curve will be empty (the object is never observed). Therefore it is critical to choose a reasonable sampling scheme.\n",
1010
"\n",
11-
"LightCurveLynx matches the sampled positions (RA, dec) with the survey information (`ObsTable`) to determine when each object is observed. Points from the object's light curve are *only* generated at those times where the object is observed. For example if an object is at (45.0, -10.0) and the survey includes this points at MJDs 60676.0 and 60678.0, the generated light curve will only have two fluxes. More importantly, if the sampled position falls outside the survey's footprint the returned light curve will be empty (the object is never observed). Therefor it is critical to choose a reasonable sampling scheme.\n",
11+
"LightCurveLynx provides multiple mechanisms for sampling object positions, including:\n",
1212
"\n",
13-
"For almost all cases we recommend the `ApproximateMOCSampler` for generating positions that correspond to the underlying survey."
13+
" * Complete uniform over a sphere (`UniformRADEC`)\n",
14+
" * Sampling from a survey (`ObsTableRADECSampler`, `ObsTableUniformRADECSampler`\n",
15+
", and `ApproximateMOCSampler`)\n",
16+
" * Sampling from a catalog (`CatalogRADECSampler`)\n",
17+
"\n",
18+
"The choice of sampler will depend heavily on the science use case."
1419
]
1520
},
1621
{
@@ -21,9 +26,11 @@
2126
"source": [
2227
"import matplotlib.pyplot as plt\n",
2328
"import numpy as np\n",
29+
"import pandas as pd\n",
2430
"\n",
2531
"from lightcurvelynx.math_nodes.ra_dec_sampler import (\n",
2632
" ApproximateMOCSampler,\n",
33+
" CatalogRADECSampler,\n",
2734
" ObsTableRADECSampler,\n",
2835
" ObsTableUniformRADECSampler,\n",
2936
" UniformRADEC,\n",
@@ -60,15 +67,17 @@
6067
"source": [
6168
"However this approach has limited use when simulating a specific survey. Depending on the survey's coverage, a significant number of (RA, dec) points may fall outside the viewing area. In those cases, the returned light curves will be empty, indicating that there were no observations of a given object.\n",
6269
"\n",
63-
"## Sampling from a Survey\n",
70+
"## Sampling from a Survey's Footprint\n",
6471
"\n",
6572
"We can sample (RA, dec) coordinates from a survey (an `ObsTable` object) in two ways. First we could sample a pointing from the survey and then a point from that field of view. Second, we could sample uniformly from the region coverage by the survey.\n",
6673
"\n",
67-
"We consider each of these approaches below, but recommend the `ApproximateMOCSampler` for the majority of simulations that want to generate positions from a given footprint.\n",
74+
"We consider each of these approaches below, but recommend the `ApproximateMOCSampler` for the majority of simulations that want to generate positions from a given footprint as it is most efficient over a range of scenarios.\n",
75+
"\n",
76+
"### Sampling Pointings (Visted Weighted)\n",
6877
"\n",
69-
"### Sampling Pointings\n",
78+
"Sampling pointings from the survey provides a **visit-weighted** sampling of positions covered by the survey. The sampling works from a table of all the survey's pointings and randomly selecting a row for each sample.\n",
7079
"\n",
71-
"Sampling pointings from the survey provides a **visit-weighted** sampling of positions covered by the survey. For concreteness let's start with a survey that visits two fields: one centered at (45.0, -15.0) and the other at (315.0, 15.0). The first field is visited once and the second field is visited four times on four consecutive nights."
80+
"For concreteness let's start with a survey that visits two fields: one centered at (45.0, -15.0) and the other at (315.0, 15.0). The first field is visited once and the second field is visited four times on four consecutive nights."
7281
]
7382
},
7483
{
@@ -110,7 +119,10 @@
110119
"cell_type": "markdown",
111120
"metadata": {},
112121
"source": [
113-
"As we can see, the field centered in the Northern hemisphere is sampled significantly more than the one centered in the Southern hemisphere.\n",
122+
"As we can see, the field centered in the Northern hemisphere is sampled significantly more than the one centered in the Southern hemisphere, because the survey visited that field more often.\n",
123+
"\n",
124+
"Users can limit the overlap with the `dedup_threshold` parameter, which removes near duplicate rows. But for non-visit-weighted sampling of a survey, we highly recommend using one of either `ObsTableUniformRADECSampler` or `ApproximateMOCSampler`.\n",
125+
"\n",
114126
"\n",
115127
"### Sampling Survey Coverage\n",
116128
"\n",
@@ -169,6 +181,9 @@
169181
"source": [
170182
"As you can see, many of the points land outside the 1 degree radius around the center of the pointing (10, 0).\n",
171183
"\n",
184+
"We recommend the `ObsTableUniformRADECSampler` **only** for cases where there is large survey coverage.\n",
185+
"\n",
186+
"\n",
172187
"**ApproximateMOCSampler**\n",
173188
"\n",
174189
"The `ApproximateMOCSampler` samples from the area covered by a [Multi-Order Coverage Map (MOC)](https://www.ivoa.net/documents/MOC/20190215/WD-MOC-1.1-20190215.pdf), which is a collection of healpix pixels representing an area on the sky. Users can generate custom MOCs for hypothetical surveys, build a MOC from a survey, or use a helper function to create the sampler directly from the survey. **This is our recommended approach for sampling from a survey.**\n",
@@ -219,7 +234,7 @@
219234
"cell_type": "markdown",
220235
"metadata": {},
221236
"source": [
222-
"For example, if we use depth=8, the survey's coverage is approximated by a grid of only 786,432 pixels over the entire sky with (an average pixel width of around 14 arc minutes). We recommend at least a depth of 12 (average pixel width around 50 arc seconds) for reasonable accuracy.\n",
237+
"For example, if we use `depth=8`, the survey's coverage is approximated by a grid of only 786,432 pixels over the entire sky with (an average pixel width of around 14 arc minutes). We recommend at least a depth of 12 (average pixel width around 50 arc seconds) for reasonable accuracy.\n",
223238
"\n",
224239
"As a concrete example, let's look at what happens if we prebuild the MOC at depth=4 (very coarse). Although the sampler uses a depth of 14, it cannot extract any more resolution from the input than it was given (a depth=4 MOC)."
225240
]
@@ -275,6 +290,44 @@
275290
"moc_sampler.plot_footprint()"
276291
]
277292
},
293+
{
294+
"cell_type": "markdown",
295+
"metadata": {},
296+
"source": [
297+
"## Sampling from a Catalog\n",
298+
"\n",
299+
"We can use the `CatalogRADECSampler` node to sample from a catalog of known objects. This class is a thin connivence wrapper on the `ObsTableRADECSampler` that samples only ra and dec and defaults to a zero radius (exact sampling of observations). It provides a `dedup_threshold` parameter for removing near duplicates."
300+
]
301+
},
302+
{
303+
"cell_type": "code",
304+
"execution_count": null,
305+
"metadata": {},
306+
"outputs": [],
307+
"source": [
308+
"# 4 points with one pair of duplicates\n",
309+
"values = {\n",
310+
" \"ra\": np.array([45.0, 315.0, 315.0, 0.0]),\n",
311+
" \"dec\": np.array([-15.0, 15.0, 15.0, -10.0]),\n",
312+
"}\n",
313+
"data = pd.DataFrame(values)\n",
314+
"\n",
315+
"pointing_sampler = CatalogRADECSampler(data, dedup_threshold=0.1, node_label=\"catalog\")\n",
316+
"print(f\"The sampler will draw from {len(pointing_sampler)} points.\")\n",
317+
"\n",
318+
"(ra, dec) = pointing_sampler.generate(num_samples=100)\n",
319+
"\n",
320+
"plt.scatter(ra, dec, s=1)\n",
321+
"plt.show()"
322+
]
323+
},
324+
{
325+
"cell_type": "markdown",
326+
"metadata": {},
327+
"source": [
328+
"We can use the `CatalogRADECSampler` (as well as the `ObsTableRADECSampler`) includes a `from_hats()` function that allows users to directly load a catalog from the [HATS format](https://www.ivoa.net/documents/Notes/HATS/)."
329+
]
330+
},
278331
{
279332
"cell_type": "markdown",
280333
"metadata": {},

src/lightcurvelynx/math_nodes/ra_dec_sampler.py

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from pathlib import Path
55

66
import numpy as np
7+
import pandas as pd
78
from astropy.coordinates import Angle, SkyCoord
89
from cdshealpix.nested import healpix_to_skycoord
9-
from citation_compass import CiteClass
10+
from citation_compass import CiteClass, cite_inline
1011
from mocpy import MOC
1112

13+
from lightcurvelynx.astro_utils.coordinate_utils import dedup_coords
1214
from lightcurvelynx.math_nodes.given_sampler import TableSampler
1315
from lightcurvelynx.math_nodes.np_random import NumpyRandomFunc
1416
from lightcurvelynx.obstable.obs_table import ObsTable
@@ -96,11 +98,15 @@ class ObsTableRADECSampler(TableSampler):
9698
Use 0.0 to return the exact given points.
9799
If None and data is an ObsTable, uses the value from the ObsTable.
98100
Default: None
101+
dedup_threshold : float, optional
102+
The deduplication threshold in degrees. If two rows have RA and dec values that
103+
are within this threshold, only one of them will be kept for sampling. Use 0.0 to
104+
keep all rows. Default: 0.0
99105
**kwargs : dict, optional
100106
Additional keyword arguments to pass to the parent class constructor.
101107
"""
102108

103-
def __init__(self, data, *, extra_cols=None, radius=None, **kwargs):
109+
def __init__(self, data, *, extra_cols=None, radius=None, dedup_threshold=0.0, **kwargs):
104110
if isinstance(data, ObsTable):
105111
if radius is None:
106112
radius = data.radius
@@ -112,6 +118,9 @@ def __init__(self, data, *, extra_cols=None, radius=None, **kwargs):
112118
raise ValueError(f"Invalid radius: {radius}")
113119
self.radius = radius
114120

121+
if "ra" not in data or "dec" not in data:
122+
raise ValueError("Data must contain 'ra' and 'dec' columns.")
123+
115124
# Start with RA, dec, and (optionally) time.
116125
data_dict = {
117126
"ra": data["ra"],
@@ -126,11 +135,29 @@ def __init__(self, data, *, extra_cols=None, radius=None, **kwargs):
126135
if col not in data_dict:
127136
data_dict[col] = data[col]
128137

138+
# Do a deduplication step to remove rows with very close RA and dec values.
139+
if dedup_threshold > 0.0:
140+
data_dict = pd.DataFrame(data_dict)
141+
_, _, inds = dedup_coords(
142+
data_dict["ra"].values,
143+
data_dict["dec"].values,
144+
threshold=dedup_threshold,
145+
)
146+
data_dict = data_dict.iloc[inds].reset_index(drop=True)
147+
129148
super().__init__(data_dict, in_order=False, **kwargs)
130149

131150
@classmethod
132-
def from_hats(cls, path, *, radius=None, extra_cols=None, **kwargs):
133-
"""Create a GivenRADECSampler from the observations in a HATS Catalog.
151+
def from_hats(
152+
cls,
153+
path,
154+
*,
155+
radius=None,
156+
extra_cols=None,
157+
dedup_threshold=0.0,
158+
**kwargs,
159+
):
160+
"""Create a ObsTableRADECSampler from the observations in a HATS Catalog.
134161
135162
Note
136163
----
@@ -148,13 +175,17 @@ def from_hats(cls, path, *, radius=None, extra_cols=None, **kwargs):
148175
extra_cols : list of str, optional
149176
A list of extra column names to include in the sampling.
150177
Default: None
178+
dedup_threshold : float, optional
179+
The deduplication threshold in degrees. If two rows have RA and dec values that
180+
are within this threshold, only one of them will be kept for sampling. Use 0.0 to
181+
keep all rows. Default: 0.0
151182
**kwargs : dict, optional
152183
Additional keyword arguments to pass to the constructor.
153184
154185
Returns
155186
-------
156-
GivenRADECSampler
157-
The created GivenRADECSampler object.
187+
ObsTableRADECSampler
188+
The created ObsTableRADECSampler object.
158189
"""
159190
# See if the (optional) LSDB package is installed.
160191
try:
@@ -171,8 +202,17 @@ def from_hats(cls, path, *, radius=None, extra_cols=None, **kwargs):
171202
cols_to_load.extend(extra_cols)
172203
columns = list(set(cols_to_load)) # Remove any duplicates.
173204

205+
# Cite the HATS format.
206+
cite_inline("HATS Catalog Format", "https://www.ivoa.net/documents/Notes/HATS/)")
207+
174208
data = read_hats(path, columns=columns).compute()
175-
return cls(data, extra_cols=extra_cols, radius=radius, **kwargs)
209+
return cls(
210+
data,
211+
extra_cols=extra_cols,
212+
radius=radius,
213+
dedup_threshold=dedup_threshold,
214+
**kwargs,
215+
)
176216

177217
def compute(self, graph_state, rng_info=None, **kwargs):
178218
"""Return the given values.
@@ -660,3 +700,29 @@ def compute(self, graph_state, rng_info=None, **kwargs):
660700
graph_state.set(self.node_string, "ra", ra)
661701
graph_state.set(self.node_string, "dec", dec)
662702
return (ra, dec)
703+
704+
705+
class CatalogRADECSampler(ObsTableRADECSampler):
706+
"""A FunctionNode that randomly samples RA and dec from a given catalog of objects.
707+
708+
Note
709+
----
710+
This is a thin convenience wrapper around ObsTableRADECSampler.
711+
712+
Parameters
713+
----------
714+
data : Pandas DataFrame, NestedFrame, or dict
715+
The data to use for sampling. Must contain 'ra' and 'dec' columns.
716+
dedup_threshold : float, optional
717+
The deduplication threshold in degrees. If two rows have RA and dec values that
718+
are within this threshold, only one of them will be kept for sampling. Use 0.0 to
719+
keep all rows. Default: 0.0
720+
**kwargs : dict, optional
721+
Additional keyword arguments to pass to the parent class constructor.
722+
"""
723+
724+
def __init__(self, data, *, dedup_threshold=0.0, **kwargs):
725+
# Always default to a radius of 0.0.
726+
if "radius" not in kwargs or kwargs["radius"] is None:
727+
kwargs["radius"] = 0.0
728+
super().__init__(data, dedup_threshold=dedup_threshold, **kwargs)

0 commit comments

Comments
 (0)