88
99import numpy as np
1010from 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
1318from 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
502575def _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
513657def _surface_brightness_rate_per_bin_hz (
0 commit comments