Injecting one or many surveys#
streamobs.observed.StreamInjector injects observational effects (photometric
errors, observed magnitudes, detection flags) into a stream catalog. The same
class handles a single survey or several at once — there is no separate
multi-survey class. When several surveys are injected together, every survey
describes the same physical stars, so you can build catalogs that carry both
Rubin/LSST and Roman photometry for one stream.
Constructing an injector#
Pass one survey, or several:
from streamobs.observed import StreamInjector
# One survey (loaded by name; `release` etc. forwarded to Survey.load).
# Its namespace is "{name}_{release}", here "lsst_dc2".
inj = StreamInjector("lsst", release="dc2")
# Several surveys as a list of specs — each spec is a survey name, a Survey, or a
# {"survey": ..., "release": ...} dict. The namespace is derived from each loaded
# Survey ("lsst_dc2", "roman_dc2"), NOT from any key you supply.
inj = StreamInjector([
{"survey": "lsst", "release": "dc2"},
{"survey": "roman", "release": "dc2"},
])
# A list of plain names loads each release-less, so the namespace is the bare
# name ("lsst", "roman").
inj = StreamInjector(["lsst", "roman"])
The namespace (the column prefix) is always the survey’s own
{name}_{release}(streamobs.surveys.Survey.namespace). A{key: spec}dict is also accepted, but the keys are containers only — the namespace is re-derived from eachSurvey, not taken from the key.primaryselects the survey whose footprint drives the shared sky placement (defaults to the first survey).inj.surveysis the{namespace: Survey}mapping;inj.primary(aliasinj.survey) is the primarySurvey, andinj.primary_namespaceits namespace string.
Injecting#
# Single survey: `bands` is the shorthand (defaults to ['r', 'g']).
out = inj.inject(df, bands=["r", "g"], stream_config=cfg, seed=42)
# Several surveys: give the bands per survey as a {namespace: [bands]} dict,
# keyed by each survey's namespace ({name}_{release}).
out = inj.inject(
df,
bands={"lsst_dc2": ["r", "g"], "roman_dc2": ["F106", "F158"]},
stream_config=cfg,
seed=42,
)
A plain list is rejected for a multi-survey injector (it is ambiguous), and a
bands dict referencing an unknown namespace raises ValueError.
df may already contain ra/dec or phi1/phi2, may be a fully empty frame
of length N, or any subset — anything missing is sampled from stream_config
(see Completing a catalog below). The output carries shared
ra/dec plus, per survey, the namespaced columns described in
Output column convention:
ra, dec,
lsst_dc2_r_true, lsst_dc2_r_obs, lsst_dc2_r_err, lsst_dc2_g_true, ..., lsst_dc2_flag_observed,
roman_dc2_F106_true, roman_dc2_F106_obs, ..., roman_dc2_flag_observed
Useful inject keyword arguments:
kwarg |
meaning |
|---|---|
|
reproducibility (per-survey RNGs are spawned from it, so results are independent of survey order) |
|
distance modulus used directly (scalar or per-row vector) instead of sampling one — see below |
|
non-reference bands to apply the explicit SNR ≥ 5 cut to (see S/N cut ownership below) |
|
also emit a |
|
apply extinction correction to observed magnitudes (default |
|
forwarded to the |
The same physical star across surveys#
For a multi-survey injection the isochrone draws one set of initial masses
(exactly nstars) and interpolates those same masses into every survey’s
bands. So a star’s LSST and Roman magnitudes describe the same object — the
true magnitudes are physically consistent and tightly correlated across surveys
rather than drawn independently.
This requires a multi-survey isochrone in the stream config: a top-level
surveys: mapping sharing one stellar population, e.g.
stream:
# ... density / track / distance_modulus ...
isochrone:
name: Marigo2017 # shared population
age: 12.0
z: 0.0006
surveys:
lsst_dc2: {survey: lsst, band_1: g, band_2: r}
roman_dc2: {survey: roman, band_1: F106, band_2: F158}
Important
Each surveys: key is the column namespace the isochrone produces
(<key>_<band>_true), and it must match the injecting survey’s namespace
{name}_{release} — otherwise the true-magnitude columns the model emits won’t
line up with the columns the injector looks for. Here the inner survey: is the
ugali filter set (no release), while the key carries the release. (In the
single-survey flat form the namespace is derived as {survey}_{release} for you;
in the multi-survey form you spell it out as the key.)
A single-survey isochrone (the flat survey/band_1/band_2 form, optionally
with release:) is just the one-survey case of the same machinery and produces
<namespace>_<band>_true identically.
A complete, runnable example — the surveys, per-survey bands, the multi-survey
isochrone, and the shared stream geometry — is provided as a scene config in
config/scenes/roman_rubin_demo.yaml:
import yaml
from streamobs.observed import StreamInjector
scene = yaml.safe_load(open("config/scenes/roman_rubin_demo.yaml"))
inj = StreamInjector(scene["surveys"]) # {"lsst": "lsst", "roman": "roman"}
cat = inj.inject(
df, bands=scene["survey_bands"], # {"lsst": [...], "roman": [...]}
stream_config=scene["stream"], seed=42,
)
Note
Roman bands are converted Vega→AB automatically. ugali returns Roman
isochrone magnitudes in Vega while the catalogs are AB, so IsochroneModel
applies a fixed per-band offset (streamobs.model.ROMAN_VEGA_TO_AB) to every
Roman band unconditionally. Non-Roman bands pass through unchanged; there is no
config flag.
Note
nstars means exactly N stars. StreamModel.sample(size) / the isochrone
draw return exactly that many stars (a fixed mass set), not a random-length IMF
realization. This is required so the same masses can be shared across surveys.
Completing a catalog#
StreamInjector.complete_data(...) is the public “fill in the rest from the
config” helper — the same completion inject runs internally, exposed so you can
build or inspect a completed catalog without injecting noise. It fills
ra/dec (converting from phi1/phi2 if needed) and the per-survey
<survey>_<band>_true columns, preserving anything already present:
# Partial input -> filled from the config; existing columns are kept.
full = inj.complete_data(df, bands=["r", "g"], stream_config=cfg, seed=1)
Supplying a distance directly#
Apparent magnitudes need a distance modulus. Normally it comes from the config’s
distance_modulus model (which needs phi1), but you can pass dist directly —
a scalar (broadcast to all rows) or a per-row vector — to fill magnitudes without
a distance model or phi1:
out = inj.inject(df, bands=["r", "g"], stream_config=cfg, dist=16.8) # scalar
out = inj.inject(df, bands=["r", "g"], stream_config=cfg, dist=dist_arr) # per-row
When given, dist overrides the configured distance model. Only rows that are
missing a dist value are set.
Existing values are never overwritten#
Completion fills only the missing rows of each column. If you supply one band and request another, the supplied band is left untouched and only the missing one is filled (newly-filled cells still come from one shared mass draw, so they are mutually colour-consistent).
Stellar masses (the mass column)#
When an isochrone is configured, the shared initial masses drawn for the stars
are surfaced as a single un-namespaced mass column (one mass per star, shared by
all surveys — the same physical star). You can also go the other way and supply
your own masses: pass a fully-populated mass column in the input catalog and the
isochrone uses those masses instead of drawing fresh ones, so the sampled
magnitudes reproduce your simulation’s exact stars. At the model level
streamobs.model.IsochroneModel.sample() accepts a masses=
array and returns the masses it used. The mass grid resolution is controlled by
IsochroneModel._MASS_STEPS (default 4000) and a per-call mass_steps= override.
S/N cut ownership#
The reference band (survey.completeness_band, e.g. LSST r) is special: the
survey’s selection-function curves are estimated on true stars detected at
SNR ≥ 5 in that band, so the cut is already baked into both the completeness
and detection-efficiency curves. The injector therefore does not re-apply a
SNR cut to the reference band (doing so would double-count it); the explicit
detection_mag_cut loop applies SNR ≥ 5 only to the other injected bands (its
default is all injected bands except the reference band). Net effect: a star must
have SNR ≥ 5 in every injected band to be flagged observed, with the reference
band’s cut owned entirely by the selection-function curves.
See also#
Output column convention — the
<survey>_<band>_…scheme and the sample-vs-catalog error split.streamobs.observed.StreamInjector— full API.streamobs.model.StreamModel— the stream/isochrone config.