@@ -134,6 +134,85 @@ def plot_hist(self, det, **kwargs):
134134 ax .hist (dtcr .data .flatten ())
135135
136136
137+ class PixelResponseNonUniformity (Effect ):
138+ """Pixel Response Non-Uniformity (PRNU).
139+
140+ Models the fixed pattern of per-pixel gain variations across the detector
141+ arising from manufacturing differences in quantum efficiency. Each pixel is
142+ multiplied by a gain factor drawn from N(1, ``prnu_std``) keyed by detector ID.
143+ The gain map is generated once per detector on first use and reused identically
144+ across all subsequent exposures.
145+
146+ Parameters
147+ ----------
148+ prnu_std : float or dict
149+ Standard deviation of the per-pixel gain distribution.
150+
151+ prnu_seed : int, fixed
152+
153+ include: "!DET.include_prnu"
154+
155+ Example
156+ --------
157+
158+ - name: prnu
159+ description: Pixel response non-uniformity
160+ class: PixelResponseNonUniformity
161+ kwargs:
162+ prnu_std: 0.001
163+ prnu_seed: 42
164+ include: "!DET.include_prnu"
165+
166+ """
167+
168+ required_keys : ClassVar [set ] = set ()
169+ z_order : ClassVar [tuple [int , ...]] = (805 ,)
170+
171+ def __init__ (self , ** kwargs ):
172+ super ().__init__ (** kwargs )
173+ self .meta .update (kwargs )
174+ self ._gain_maps = {} # keyed by dtcr_id
175+
176+ def apply_to (self , obj , ** kwargs ):
177+ if not isinstance (obj , Detector ):
178+ return obj
179+
180+ random_seed = from_currsys (self .meta .get ("prnu_seed" ), self .cmds )
181+ id_key = real_colname ("id" , obj .meta )
182+ dtcr_id = obj .meta [id_key ] if id_key is not None else None
183+
184+ prnu_std_meta = from_currsys (self .meta ["prnu_std" ], self .cmds )
185+ if isinstance (prnu_std_meta , dict ):
186+ prnu_std = float (from_currsys (prnu_std_meta [dtcr_id ], self .cmds ))
187+ elif isinstance (prnu_std_meta , (int , float )):
188+ prnu_std = float (prnu_std_meta )
189+ else :
190+ raise TypeError (
191+ "<PixelResponseNonUniformity>.meta['prnu_std'] must be a float "
192+ f"or a dict keyed by detector ID, got { type (prnu_std_meta )} "
193+ )
194+
195+ shape = obj ._hdu .data .shape
196+ if dtcr_id not in self ._gain_maps or self ._gain_maps [dtcr_id ].shape != shape :
197+ self ._gain_maps [dtcr_id ] = np .random .default_rng (random_seed ).normal (
198+ loc = 1.0 , scale = prnu_std , size = shape )
199+
200+ obj ._hdu .data = obj ._hdu .data * self ._gain_maps [dtcr_id ]
201+ return obj
202+
203+ def plot (self , det_id = None ):
204+ if not self ._gain_maps :
205+ raise RuntimeError ("No gain map yet — run a simulation first." )
206+ key = det_id if det_id in self ._gain_maps else next (iter (self ._gain_maps ))
207+ gain_map = self ._gain_maps [key ]
208+ dev = np .max (np .abs (gain_map - 1.0 ))
209+ fig , ax = figure_factory ()
210+ im = ax .imshow (gain_map , origin = "lower" , aspect = "auto" ,
211+ vmin = 1 - dev , vmax = 1 + dev )
212+ fig .colorbar (im , ax = ax , label = "per-pixel gain" )
213+ return fig
214+
215+
137216class ShotNoise (Effect ):
138217 z_order : ClassVar [tuple [int , ...]] = (820 ,)
139218
0 commit comments