Skip to content

Commit 3bf0cd9

Browse files
author
Ben Mazin
committed
Add HWO residual contrast and exozodi realism
1 parent 6b9ada1 commit 3bf0cd9

7 files changed

Lines changed: 636 additions & 62 deletions

File tree

examples/hwo_space.toml

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
# * Detector cap is set far above the expected rates so the first pass
3030
# is not saturation limited.
3131
# * Space zodiacal backgrounds are included through `background.space_zodi`:
32-
# local zodi defaults to an average V-band surface brightness, and the
33-
# exozodi level comes from [scene.exozodi].
32+
# local zodi defaults to an average V-band surface brightness, and exozodi
33+
# uses a HOSTS-style level draw plus a simple inclined-disk multiplier.
3434
# * Calibration floor and residual systematic are set to negligible
3535
# positive values; HWO contrast is represented by the planet contrast,
3636
# not by an imposed calibration floor.
@@ -111,9 +111,9 @@ separation_mas = 200.0
111111
position_angle_deg = 0.0
112112

113113
[scene.exozodi]
114-
# Solar-System-like exozodi level. `background.space_zodi` treats this as a
115-
# linear multiplier on a 1-zodi visible-light surface brightness near the
116-
# Earth-equivalent insolation distance.
114+
# Fixed fallback exozodi level. The HWO zodi stage below opts into a
115+
# HOSTS-style sampled level for realism; this scene value is used only if
116+
# that stage is returned to exozodi_level_distribution="fixed".
117117
level_zodi = 1.0
118118

119119
[observation]
@@ -167,9 +167,9 @@ pointing_jitter_rms_mas = 0.5
167167
# space-wavefront stage provides a near-unity Strehl spectrum. A real
168168
# multi-sub-aperture injection stage would replace this with independent
169169
# modes; the broadcast keeps raw null depth degenerate but lets
170-
# performance.standard run. Leave apply_strehl_coupling=false because the downstream
171-
# injection.single_mode_fiber stage applies the Ruilier-Cassaing SMF
172-
# coupling exactly once.
170+
# performance.standard run. Leave apply_strehl_coupling=false because the
171+
# downstream injection.single_mode_fiber stage applies the Ruilier-Cassaing
172+
# SMF coupling exactly once.
173173
id = "injection_seed"
174174
type = "injection.ideal"
175175
n_modes = 36
@@ -288,9 +288,10 @@ band = "I"
288288
[[pipeline.stages]]
289289
# Separation-dependent planet throughput + stellar-disk leakage for the
290290
# 36-mode monolithic-pupil surrogate above. With no atmosphere, AO, or
291-
# formation-flying residual stage, the realized-null MC is disabled. A flat
292-
# 1e-10 residual-starlight floor anchors the first-pass HWO design contrast;
293-
# later realism stages can replace it with a wavefront-derived contrast map.
291+
# formation-flying residual stage, the realized-null MC is disabled. The
292+
# residual-starlight floor uses a lightweight wavelength- and separation-
293+
# dependent HWO I-band contrast surface as a stand-in for a future
294+
# wavefront-derived contrast map.
294295
# The grid is HWO-specific: at 0.8 µm on an 8 m primary, λ/D = 20.6 mas.
295296
# The sub-mas samples keep the finite-stellar-disk integral honest for a
296297
# 1.86 mas diameter star; the plotted science range still extends through
@@ -317,23 +318,31 @@ n_azimuths = 1
317318
n_realizations_per_azimuth = 0
318319
n_mc_realizations = 0
319320
mc_seed = 0
320-
residual_starlight_contrast = [1.0e-10]
321+
residual_starlight_table_path = "data/hwo_reference/residual_contrast_i_band.csv"
321322
residual_starlight_combine_mode = "max"
322323

323324
[[pipeline.stages]]
324325
# Space zodiacal backgrounds. The default local-zodi value is an average
325326
# V-band surface brightness converted to I-band with an approximate solar color;
326327
# this HWO example pins a low/ecliptic-pole value because deep direct-imaging
327-
# targets should avoid the high-zodi sky. The 1-zodi exozodi surface brightness
328-
# is scaled from the host star's I-band magnitude and then multiplied by
329-
# [scene.exozodi].level_zodi above. The background is routed through the same
330-
# off-axis dark-port throughput that sensitivity.detection_curve computed for
331-
# the planet separation. The solid angle defaults to 1 (lambda/D)^2.
328+
# targets should avoid the high-zodi sky. Exozodi uses a HOSTS-like lognormal
329+
# dust-level draw and an inclined smooth-disk surface-brightness multiplier,
330+
# then routes through the same off-axis dark-port throughput that
331+
# sensitivity.detection_curve computed for the planet separation. The solid
332+
# angle defaults to 1 (lambda/D)^2.
332333
id = "zodi"
333334
type = "background.space_zodi"
334335
band = "I"
335336
local_zodi_v_mag_arcsec2 = 23.3
336337
derive_exozodi_mag_from_scene_jmag = true
338+
exozodi_level_distribution = "hosts2020_lognormal"
339+
exozodi_hosts2020_median_zodi = 3.0
340+
exozodi_hosts2020_sigma_dex = 0.47712125472
341+
exozodi_level_max_zodi = 100.0
342+
exozodi_morphology = "smooth_inclined_disk"
343+
exozodi_inclination_distribution = "isotropic"
344+
exozodi_max_inclination_deg = 80.0
345+
exozodi_max_surface_brightness_multiplier = 5.0
337346
routing = "scene_planet_throughput"
338347

339348
[[pipeline.stages]]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
separation_mas,wavelength_um,contrast
2+
0.05,0.76,1.0e-6
3+
0.05,0.80,8.0e-7
4+
0.05,0.84,1.0e-6
5+
0.10,0.76,5.0e-7
6+
0.10,0.80,4.0e-7
7+
0.10,0.84,5.0e-7
8+
0.20,0.76,1.5e-7
9+
0.20,0.80,1.2e-7
10+
0.20,0.84,1.5e-7
11+
0.50,0.76,3.0e-8
12+
0.50,0.80,2.5e-8
13+
0.50,0.84,3.0e-8
14+
1.00,0.76,1.0e-8
15+
1.00,0.80,8.0e-9
16+
1.00,0.84,1.0e-8
17+
2.00,0.76,3.0e-9
18+
2.00,0.80,2.5e-9
19+
2.00,0.84,3.0e-9
20+
5.00,0.76,1.0e-9
21+
5.00,0.80,8.0e-10
22+
5.00,0.84,1.0e-9
23+
10.00,0.76,4.0e-10
24+
10.00,0.80,3.0e-10
25+
10.00,0.84,4.0e-10
26+
20.60,0.76,1.6e-10
27+
20.60,0.80,1.2e-10
28+
20.60,0.84,1.6e-10
29+
50.00,0.76,1.2e-10
30+
50.00,0.80,1.0e-10
31+
50.00,0.84,1.2e-10
32+
100.00,0.76,1.1e-10
33+
100.00,0.80,1.0e-10
34+
100.00,0.84,1.1e-10
35+
200.00,0.76,1.0e-10
36+
200.00,0.80,1.0e-10
37+
200.00,0.84,1.0e-10
38+
500.00,0.76,1.4e-10
39+
500.00,0.80,1.2e-10
40+
500.00,0.84,1.4e-10

nullsim/stages/background/space_zodi.py

Lines changed: 155 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88

99
import numpy as np
1010
from numpy.typing import NDArray
11-
from pydantic import BaseModel, ConfigDict, Field as PydanticField
11+
from pydantic import (
12+
BaseModel,
13+
ConfigDict,
14+
Field as PydanticField,
15+
model_validator,
16+
)
1217

1318
from nullsim.physics.photometry import (
1419
photon_rate_per_m2_per_bin,
@@ -77,6 +82,29 @@ class Params(BaseModel):
7782
band: Literal["I", "Y", "J", "H", "K"] | None = None
7883
local_zodi_level: float = PydanticField(default=1.0, ge=0.0)
7984
exozodi_level_zodi: float | None = PydanticField(default=None, ge=0.0)
85+
exozodi_level_distribution: Literal["fixed", "hosts2020_lognormal"] = "fixed"
86+
exozodi_hosts2020_median_zodi: float = PydanticField(default=3.0, ge=0.0)
87+
exozodi_hosts2020_sigma_dex: float = PydanticField(
88+
default=math.log10(3.0),
89+
ge=0.0,
90+
)
91+
exozodi_level_min_zodi: float = PydanticField(default=0.0, ge=0.0)
92+
exozodi_level_max_zodi: float | None = PydanticField(default=None, ge=0.0)
93+
exozodi_morphology: Literal[
94+
"smooth_face_on",
95+
"smooth_inclined_disk",
96+
] = "smooth_face_on"
97+
exozodi_inclination_deg: float = PydanticField(default=0.0, ge=0.0, lt=90.0)
98+
exozodi_inclination_distribution: Literal["fixed", "isotropic"] = "fixed"
99+
exozodi_max_inclination_deg: float = PydanticField(
100+
default=80.0,
101+
gt=0.0,
102+
lt=90.0,
103+
)
104+
exozodi_max_surface_brightness_multiplier: float = PydanticField(
105+
default=5.0,
106+
ge=1.0,
107+
)
80108
local_zodi_v_mag_arcsec2: float = PydanticField(
81109
default=DEFAULT_LOCAL_ZODI_V_MAG_ARCSEC2,
82110
gt=0.0,
@@ -95,6 +123,17 @@ class Params(BaseModel):
95123
n_subapertures: int | None = PydanticField(default=None, ge=1)
96124
area_source: Literal["effective", "collecting_area_per_mode"] = "effective"
97125

126+
@model_validator(mode="after")
127+
def _validate_distribution_bounds(self) -> "SpaceZodiBackground.Params":
128+
if (
129+
self.exozodi_level_max_zodi is not None
130+
and self.exozodi_level_max_zodi < self.exozodi_level_min_zodi
131+
):
132+
raise ValueError(
133+
"exozodi_level_max_zodi must be >= exozodi_level_min_zodi."
134+
)
135+
return self
136+
98137
def apply(self, state: SimulationState) -> SimulationState:
99138
params = self.params_model
100139
wavelength_grid_um = _require_wavelength_grid_um(state)
@@ -142,10 +181,19 @@ def apply(self, state: SimulationState) -> SimulationState:
142181
routing_throughput=1.0,
143182
n_dark_ports=routing.n_dark_ports,
144183
)
145-
exozodi_level = _resolve_exozodi_level(state, params.exozodi_level_zodi)
184+
exozodi_level, exozodi_level_source = _resolve_exozodi_level(
185+
state,
186+
params,
187+
self.context.rng,
188+
)
189+
exozodi_morphology = _resolve_exozodi_morphology(params, self.context.rng)
190+
exozodi_effective_level = (
191+
float(exozodi_level)
192+
* float(exozodi_morphology["surface_brightness_multiplier"])
193+
)
146194
exozodi_base_rate_per_bin_hz = _surface_brightness_rate_per_bin_hz(
147195
mag_arcsec2=exozodi_mag,
148-
level=exozodi_level,
196+
level=exozodi_effective_level,
149197
wavelength_grid_um=wavelength_grid_um,
150198
bandwidth_um=bandwidth_um,
151199
band=band,
@@ -176,6 +224,29 @@ def apply(self, state: SimulationState) -> SimulationState:
176224
"band": band,
177225
"local_zodi_level": float(params.local_zodi_level),
178226
"exozodi_level_zodi": float(exozodi_level),
227+
"exozodi_effective_level_zodi": float(exozodi_effective_level),
228+
"exozodi_level_source": exozodi_level_source,
229+
"exozodi_level_distribution": params.exozodi_level_distribution,
230+
"exozodi_hosts2020_median_zodi": float(
231+
params.exozodi_hosts2020_median_zodi
232+
),
233+
"exozodi_hosts2020_sigma_dex": float(
234+
params.exozodi_hosts2020_sigma_dex
235+
),
236+
"exozodi_level_min_zodi": float(params.exozodi_level_min_zodi),
237+
"exozodi_level_max_zodi": (
238+
None if params.exozodi_level_max_zodi is None
239+
else float(params.exozodi_level_max_zodi)
240+
),
241+
"exozodi_morphology": params.exozodi_morphology,
242+
"exozodi_inclination_deg": float(exozodi_morphology["inclination_deg"]),
243+
"exozodi_inclination_source": exozodi_morphology["inclination_source"],
244+
"exozodi_surface_brightness_multiplier": float(
245+
exozodi_morphology["surface_brightness_multiplier"]
246+
),
247+
"exozodi_raw_surface_brightness_multiplier": float(
248+
exozodi_morphology["raw_surface_brightness_multiplier"]
249+
),
179250
"local_zodi_v_mag_arcsec2": float(params.local_zodi_v_mag_arcsec2),
180251
"exozodi_v_mag_arcsec2": float(params.exozodi_v_mag_arcsec2),
181252
"local_zodi_mag_arcsec2": float(local_mag),
@@ -229,7 +300,9 @@ def apply(self, state: SimulationState) -> SimulationState:
229300
"dark-port routing throughput. Local zodi uses an absolute "
230301
"V-band surface brightness converted with approximate "
231302
"solar colors; exozodi defaults to a 1-zodi surface "
232-
"brightness scaled by the host-star band magnitude."
303+
"brightness scaled by the host-star band magnitude. Optional "
304+
"HOSTS-style zodi-level sampling and inclined smooth-disk "
305+
"surface-brightness scaling are applied before routing."
233306
),
234307
}
235308
payload.update(curve_background_payload)
@@ -501,13 +574,84 @@ def _exozodi_mag_arcsec2(
501574

502575
def _resolve_exozodi_level(
503576
state: SimulationState,
504-
configured: float | None,
505-
) -> float:
506-
if configured is not None:
507-
return float(configured)
508-
if state.scene.exozodi_level_zodi is None:
509-
return 0.0
510-
return float(state.scene.exozodi_level_zodi)
577+
params: SpaceZodiBackground.Params,
578+
rng: np.random.Generator,
579+
) -> tuple[float, str]:
580+
"""Resolve or sample the physical exozodi dust level in zodis."""
581+
582+
if params.exozodi_level_distribution == "fixed":
583+
if params.exozodi_level_zodi is not None:
584+
return float(params.exozodi_level_zodi), "params.exozodi_level_zodi"
585+
if state.scene.exozodi_level_zodi is None:
586+
return 0.0, "scene.exozodi_level_zodi missing"
587+
return float(state.scene.exozodi_level_zodi), "scene.exozodi_level_zodi"
588+
589+
if params.exozodi_level_distribution != "hosts2020_lognormal":
590+
raise ValueError(
591+
"background.space_zodi: unsupported exozodi_level_distribution="
592+
f"{params.exozodi_level_distribution!r}."
593+
)
594+
median = float(params.exozodi_hosts2020_median_zodi)
595+
if median <= 0.0:
596+
sampled = 0.0
597+
elif float(params.exozodi_hosts2020_sigma_dex) == 0.0:
598+
sampled = median
599+
else:
600+
sampled = median * 10.0 ** float(
601+
rng.normal(0.0, float(params.exozodi_hosts2020_sigma_dex))
602+
)
603+
clipped = max(float(params.exozodi_level_min_zodi), float(sampled))
604+
if params.exozodi_level_max_zodi is not None:
605+
clipped = min(float(params.exozodi_level_max_zodi), clipped)
606+
return clipped, "hosts2020_lognormal"
607+
608+
609+
def _resolve_exozodi_morphology(
610+
params: SpaceZodiBackground.Params,
611+
rng: np.random.Generator,
612+
) -> dict[str, float | str]:
613+
"""Resolve simple exozodi morphology diagnostics and rate multiplier."""
614+
615+
if params.exozodi_morphology == "smooth_face_on":
616+
return {
617+
"inclination_deg": 0.0,
618+
"inclination_source": "smooth_face_on",
619+
"raw_surface_brightness_multiplier": 1.0,
620+
"surface_brightness_multiplier": 1.0,
621+
}
622+
if params.exozodi_morphology != "smooth_inclined_disk":
623+
raise ValueError(
624+
"background.space_zodi: unsupported exozodi_morphology="
625+
f"{params.exozodi_morphology!r}."
626+
)
627+
628+
if params.exozodi_inclination_distribution == "isotropic":
629+
max_inclination_rad = math.radians(float(params.exozodi_max_inclination_deg))
630+
cos_i = float(rng.uniform(math.cos(max_inclination_rad), 1.0))
631+
inclination_deg = math.degrees(math.acos(cos_i))
632+
inclination_source = "isotropic"
633+
elif params.exozodi_inclination_distribution == "fixed":
634+
inclination_deg = float(params.exozodi_inclination_deg)
635+
inclination_source = "params.exozodi_inclination_deg"
636+
else:
637+
raise ValueError(
638+
"background.space_zodi: unsupported "
639+
"exozodi_inclination_distribution="
640+
f"{params.exozodi_inclination_distribution!r}."
641+
)
642+
643+
cos_i = max(math.cos(math.radians(inclination_deg)), 1.0e-6)
644+
raw_multiplier = 1.0 / cos_i
645+
multiplier = min(
646+
raw_multiplier,
647+
float(params.exozodi_max_surface_brightness_multiplier),
648+
)
649+
return {
650+
"inclination_deg": inclination_deg,
651+
"inclination_source": inclination_source,
652+
"raw_surface_brightness_multiplier": raw_multiplier,
653+
"surface_brightness_multiplier": multiplier,
654+
}
511655

512656

513657
def _surface_brightness_rate_per_bin_hz(

0 commit comments

Comments
 (0)