|
| 1 | +--- |
| 2 | +jupytext: |
| 3 | + text_representation: |
| 4 | + extension: .md |
| 5 | + format_name: myst |
| 6 | + format_version: 0.13 |
| 7 | + jupytext_version: 1.16.1 |
| 8 | +kernelspec: |
| 9 | + display_name: Python 3 (ipykernel) |
| 10 | + language: python |
| 11 | + name: python3 |
| 12 | +--- |
| 13 | + |
| 14 | +# Creating Custom Effects |
| 15 | + |
| 16 | +ScopeSim's built-in effects cover the most common physical phenomena in optical |
| 17 | +systems, but you may need to model instrument-specific behaviour that isn't |
| 18 | +provided out of the box. Creating a custom `Effect` subclass lets you inject |
| 19 | +arbitrary transformations into the simulation pipeline. |
| 20 | + |
| 21 | +For a worked example that creates a `PointSourceJitter` effect and adds it to |
| 22 | +a full MICADO simulation, see the |
| 23 | +[Custom Effects Example Notebook](../examples/3_custom_effects.ipynb). |
| 24 | +This page focuses on a complementary example — a non-symmetric vignetting flat |
| 25 | +field applied at the image plane level. |
| 26 | + |
| 27 | +## Anatomy of an Effect Subclass |
| 28 | + |
| 29 | +Every custom effect needs three things: |
| 30 | + |
| 31 | +1. **`z_order`** — a class variable (tuple of ints) that tells ScopeSim *when* |
| 32 | + in the pipeline to apply the effect. |
| 33 | +2. **`__init__`** — calls `super().__init__()` and sets default parameters in |
| 34 | + `self.meta`. |
| 35 | +3. **`apply_to(self, obj)`** — the method that does the work. It receives an |
| 36 | + object, optionally modifies it, and **must return it**. |
| 37 | + |
| 38 | +The `apply_to` method should use `isinstance` checks to determine whether to |
| 39 | +act on the given object. During a simulation run, ScopeSim passes different |
| 40 | +object types at different stages — your effect will only modify the types it |
| 41 | +knows how to handle, and pass everything else through unchanged. |
| 42 | + |
| 43 | +## Choosing the Right Z-Order |
| 44 | + |
| 45 | +The z_order determines which pipeline stage your effect participates in, and |
| 46 | +therefore what type of object it receives: |
| 47 | + |
| 48 | +| Z-Order Range | Object Type | Use When... | |
| 49 | +|:---:|---|---| |
| 50 | +| 500–599 | `Source` | Modifying the original light distribution (e.g., spectral shifts, flux scaling) | |
| 51 | +| 600–699 | `FieldOfView` | Modifying per-wavelength spatial cutouts (e.g., PSF convolution, dispersion) | |
| 52 | +| 700–799 | `ImagePlane` | Modifying the wavelength-integrated focal plane image (e.g., vignetting, flat fields) | |
| 53 | +| 800–899 | `Detector` | Modifying the detector readout (e.g., noise, dark current, gain variations) | |
| 54 | + |
| 55 | +An effect can have multiple z_order values to participate in both a setup stage |
| 56 | +and an application stage. For a simple custom effect, a single value is |
| 57 | +usually sufficient. |
| 58 | + |
| 59 | +## Example: Non-Symmetric Vignetting Flat Field |
| 60 | + |
| 61 | +This example creates an effect that applies a spatially-varying throughput |
| 62 | +pattern to the image plane, simulating optical vignetting that is not radially |
| 63 | +symmetric — for instance, caused by an off-axis obstruction or asymmetric optics. |
| 64 | + |
| 65 | +The vignetting is modelled as an elliptical Gaussian decay with configurable |
| 66 | +center offset, semi-axes, rotation angle, and throughput range. |
| 67 | + |
| 68 | +### Defining the effect class |
| 69 | + |
| 70 | +```{code-cell} ipython3 |
| 71 | +import numpy as np |
| 72 | +from scopesim.effects import Effect |
| 73 | +from scopesim.optics.image_plane import ImagePlane |
| 74 | +
|
| 75 | +
|
| 76 | +class NonSymmetricVignetting(Effect): |
| 77 | + """Apply a non-symmetric vignetting pattern to the image plane.""" |
| 78 | +
|
| 79 | + z_order = (710,) |
| 80 | +
|
| 81 | + def __init__(self, **kwargs): |
| 82 | + super().__init__(**kwargs) |
| 83 | + params = { |
| 84 | + "x_center_offset": 0.1, # fractional offset from image center |
| 85 | + "y_center_offset": -0.05, |
| 86 | + "sigma_x": 0.8, # fractional semi-axis (1.0 = full frame) |
| 87 | + "sigma_y": 0.6, |
| 88 | + "rotation_deg": 15.0, # rotation angle of the vignetting ellipse |
| 89 | + "max_throughput": 1.0, |
| 90 | + "min_throughput": 0.3, |
| 91 | + } |
| 92 | + for key, val in params.items(): |
| 93 | + self.meta.setdefault(key, val) |
| 94 | + self.meta.update(kwargs) |
| 95 | +
|
| 96 | + def _make_vignetting_map(self, shape): |
| 97 | + """Generate a 2D vignetting map for a given image shape.""" |
| 98 | + ny, nx = shape |
| 99 | + y, x = np.mgrid[:ny, :nx] |
| 100 | +
|
| 101 | + # Normalise pixel coordinates to [-1, 1] and apply center offset |
| 102 | + x_norm = 2.0 * x / nx - 1.0 - self.meta["x_center_offset"] |
| 103 | + y_norm = 2.0 * y / ny - 1.0 - self.meta["y_center_offset"] |
| 104 | +
|
| 105 | + # Rotate coordinate frame |
| 106 | + angle = np.deg2rad(self.meta["rotation_deg"]) |
| 107 | + cos_a, sin_a = np.cos(angle), np.sin(angle) |
| 108 | + x_rot = x_norm * cos_a + y_norm * sin_a |
| 109 | + y_rot = -x_norm * sin_a + y_norm * cos_a |
| 110 | +
|
| 111 | + # Elliptical Gaussian falloff |
| 112 | + r2 = (x_rot / self.meta["sigma_x"]) ** 2 + \ |
| 113 | + (y_rot / self.meta["sigma_y"]) ** 2 |
| 114 | + t_min = self.meta["min_throughput"] |
| 115 | + t_max = self.meta["max_throughput"] |
| 116 | + vmap = t_min + (t_max - t_min) * np.exp(-0.5 * r2) |
| 117 | +
|
| 118 | + return np.clip(vmap, t_min, t_max) |
| 119 | +
|
| 120 | + def apply_to(self, obj, **kwargs): |
| 121 | + if isinstance(obj, ImagePlane): |
| 122 | + vignetting = self._make_vignetting_map(obj.hdu.data.shape) |
| 123 | + obj.hdu.data *= vignetting |
| 124 | + return obj |
| 125 | +``` |
| 126 | + |
| 127 | +### Setting up the simulation |
| 128 | + |
| 129 | +```{code-cell} ipython3 |
| 130 | +import scopesim as sim |
| 131 | +from scopesim.source.source_templates import star_field |
| 132 | +
|
| 133 | +# Load the example optical train and create a star field source |
| 134 | +opt = sim.load_example_optical_train() |
| 135 | +src = star_field(n=50, mmin=15, mmax=20, width=200) |
| 136 | +
|
| 137 | +# Create and add the vignetting effect |
| 138 | +vig = NonSymmetricVignetting(name="asymmetric_vignetting") |
| 139 | +opt.optics_manager.add_effect(vig) |
| 140 | +
|
| 141 | +opt.effects |
| 142 | +``` |
| 143 | + |
| 144 | +### Observing and visualising |
| 145 | + |
| 146 | +```{code-cell} ipython3 |
| 147 | +import matplotlib.pyplot as plt |
| 148 | +
|
| 149 | +opt.observe(src, update=True) |
| 150 | +
|
| 151 | +fig, axes = plt.subplots(1, 2, figsize=(14, 5)) |
| 152 | +
|
| 153 | +# Show the vignetted image |
| 154 | +axes[0].imshow(opt.image_planes[0].data, origin="lower") |
| 155 | +axes[0].set_title("Image plane with vignetting") |
| 156 | +
|
| 157 | +# Show the vignetting map itself |
| 158 | +vmap = vig._make_vignetting_map(opt.image_planes[0].data.shape) |
| 159 | +im = axes[1].imshow(vmap, origin="lower", cmap="RdYlGn", vmin=0, vmax=1) |
| 160 | +axes[1].set_title("Vignetting map (throughput)") |
| 161 | +fig.colorbar(im, ax=axes[1]) |
| 162 | +
|
| 163 | +plt.tight_layout() |
| 164 | +plt.show() |
| 165 | +``` |
| 166 | + |
| 167 | +### Comparing with and without vignetting |
| 168 | + |
| 169 | +```{code-cell} ipython3 |
| 170 | +# Observe without vignetting |
| 171 | +vig.include = False |
| 172 | +opt.observe(src, update=True) |
| 173 | +no_vig_data = opt.image_planes[0].data.copy() |
| 174 | +
|
| 175 | +# Observe with vignetting |
| 176 | +vig.include = True |
| 177 | +opt.observe(src, update=True) |
| 178 | +vig_data = opt.image_planes[0].data.copy() |
| 179 | +
|
| 180 | +fig, axes = plt.subplots(1, 2, figsize=(14, 5)) |
| 181 | +axes[0].imshow(no_vig_data, origin="lower") |
| 182 | +axes[0].set_title("Without vignetting") |
| 183 | +axes[1].imshow(vig_data, origin="lower") |
| 184 | +axes[1].set_title("With vignetting") |
| 185 | +plt.tight_layout() |
| 186 | +plt.show() |
| 187 | +``` |
| 188 | + |
| 189 | +## Modifying Parameters at Runtime |
| 190 | + |
| 191 | +Effect parameters live in the `.meta` dictionary and can be changed between |
| 192 | +observations: |
| 193 | + |
| 194 | +```{code-cell} ipython3 |
| 195 | +# Make the vignetting more extreme |
| 196 | +opt["asymmetric_vignetting"].meta["sigma_x"] = 0.4 |
| 197 | +opt["asymmetric_vignetting"].meta["sigma_y"] = 0.3 |
| 198 | +opt["asymmetric_vignetting"].meta["min_throughput"] = 0.1 |
| 199 | +
|
| 200 | +opt.observe(src, update=True) |
| 201 | +
|
| 202 | +fig, axes = plt.subplots(1, 2, figsize=(14, 5)) |
| 203 | +axes[0].imshow(opt.image_planes[0].data, origin="lower") |
| 204 | +axes[0].set_title("Tighter vignetting") |
| 205 | +
|
| 206 | +vmap = vig._make_vignetting_map(opt.image_planes[0].data.shape) |
| 207 | +im = axes[1].imshow(vmap, origin="lower", cmap="RdYlGn", vmin=0, vmax=1) |
| 208 | +axes[1].set_title("Updated vignetting map") |
| 209 | +fig.colorbar(im, ax=axes[1]) |
| 210 | +plt.tight_layout() |
| 211 | +plt.show() |
| 212 | +``` |
| 213 | + |
| 214 | +## Tips for Writing Robust Effects |
| 215 | + |
| 216 | +- **Always return `obj`** from `apply_to`, even when your `isinstance` check |
| 217 | + doesn't match. ScopeSim passes many object types through the same list of |
| 218 | + effects — returning `None` will break the pipeline. |
| 219 | + |
| 220 | +- **Use `isinstance` guards** to decide whether to act. Your `apply_to` will |
| 221 | + be called with `Source`, `FieldOfView`, `ImagePlane`, and `Detector` objects |
| 222 | + at different stages. |
| 223 | + |
| 224 | +- **Choose the right pipeline stage** carefully: |
| 225 | + - `FieldOfView` (z=600–699): your effect is applied per wavelength bin and |
| 226 | + per spatial chunk — appropriate for wavelength-dependent effects. |
| 227 | + - `ImagePlane` (z=700–799): your effect sees the wavelength-integrated focal |
| 228 | + plane image — appropriate for achromatic spatial effects like vignetting. |
| 229 | + - `Detector` (z=800–899): your effect sees the detector readout after |
| 230 | + extraction — appropriate for electronic effects like noise. |
| 231 | + |
| 232 | +- **Look at built-in effects for patterns.** For example, |
| 233 | + `PixelResponseNonUniformity` in `scopesim/effects/electronic/noise.py` is a |
| 234 | + simple multiplicative detector-level effect. `SeeingPSF` in |
| 235 | + `scopesim/effects/psfs/analytical.py` shows how to build a convolution kernel. |
| 236 | + |
| 237 | +- **Use `from_currsys`** for parameters that should be resolvable as bang |
| 238 | + strings (`!OBS.some_param`): |
| 239 | + ```python |
| 240 | + from scopesim.utils import from_currsys |
| 241 | + value = from_currsys(self.meta["my_param"], self.cmds) |
| 242 | + ``` |
| 243 | + |
| 244 | +## Adding Custom Effects to the Optical Train |
| 245 | + |
| 246 | +Custom effects are added programmatically using `optics_manager.add_effect()`: |
| 247 | + |
| 248 | +```python |
| 249 | +my_effect = MyCustomEffect(name="my_effect", some_param=42) |
| 250 | +opt.optics_manager.add_effect(my_effect) |
| 251 | +``` |
| 252 | + |
| 253 | +After adding an effect, pass `update=True` to `opt.observe()` so the optical |
| 254 | +train rebuilds its internal structures to include the new effect. |
| 255 | + |
| 256 | +Note that YAML-based instrument packages resolve effect class names from the |
| 257 | +`scopesim.effects` namespace. Custom effect classes from third-party packages |
| 258 | +currently need to be added programmatically as shown above. |
| 259 | + |
| 260 | +## Sharing Your Custom Effect |
| 261 | + |
| 262 | +Once you've written and tested a custom effect, there are several ways to make |
| 263 | +it available for use — either for yourself or for the wider community. |
| 264 | + |
| 265 | +### Option 1: Add it directly to the ScopeSim effects module (local) |
| 266 | + |
| 267 | +If you want your effect to be available via YAML instrument packages (i.e., |
| 268 | +referenced by class name in a YAML file), the simplest approach is to place |
| 269 | +your Python file inside the `scopesim/effects/` directory of your local |
| 270 | +ScopeSim installation and import it in `scopesim/effects/__init__.py`. |
| 271 | + |
| 272 | +For example, if you save your effect class in |
| 273 | +`scopesim/effects/my_vignetting.py`: |
| 274 | + |
| 275 | +```python |
| 276 | +# scopesim/effects/my_vignetting.py |
| 277 | +from .effects import Effect |
| 278 | + |
| 279 | +class NonSymmetricVignetting(Effect): |
| 280 | + ... |
| 281 | +``` |
| 282 | + |
| 283 | +Then add the import to `scopesim/effects/__init__.py`: |
| 284 | + |
| 285 | +```python |
| 286 | +from .my_vignetting import * |
| 287 | +``` |
| 288 | + |
| 289 | +After this, the class name `NonSymmetricVignetting` can be used directly in |
| 290 | +YAML configuration files: |
| 291 | + |
| 292 | +```yaml |
| 293 | +effects: |
| 294 | + - name: vignetting |
| 295 | + class: NonSymmetricVignetting |
| 296 | + kwargs: |
| 297 | + sigma_x: 0.8 |
| 298 | + sigma_y: 0.6 |
| 299 | +``` |
| 300 | +
|
| 301 | +Note that this modifies your local ScopeSim installation and will be |
| 302 | +overwritten when you upgrade the package. For a more permanent solution, |
| 303 | +consider contributing it upstream (Option 3). |
| 304 | +
|
| 305 | +### Option 2: Keep it in your own script or package |
| 306 | +
|
| 307 | +For effects that are specific to your analysis, you can keep the effect class |
| 308 | +in your own Python script or package and add it programmatically at runtime as |
| 309 | +shown [above](#adding-custom-effects-to-the-optical-train). This is the |
| 310 | +simplest approach and doesn't require modifying ScopeSim itself. |
| 311 | +
|
| 312 | +### Option 3: Contribute it to ScopeSim |
| 313 | +
|
| 314 | +If your effect is general-purpose and would be useful to other users, we |
| 315 | +welcome contributions! You can: |
| 316 | +
|
| 317 | +- **Open an issue** on the |
| 318 | + [ScopeSim GitHub repository](https://github.com/AstarVienna/ScopeSim/issues) |
| 319 | + describing your effect and sharing the code. The ScopeSim team can help |
| 320 | + integrate it into the package. |
| 321 | +
|
| 322 | +- **Submit a pull request** with your effect class added to the |
| 323 | + `scopesim/effects/` module, including the import in `__init__.py` and |
| 324 | + ideally a test in `scopesim/tests/`. See the |
| 325 | + [existing effects](https://github.com/AstarVienna/ScopeSim/tree/main/scopesim/effects) |
| 326 | + for examples of the expected code style and structure. |
| 327 | + |
| 328 | +## See Also |
| 329 | + |
| 330 | +- [Effects Overview](overview.md) — reference for all built-in effect types |
| 331 | + and the simulation pipeline |
| 332 | +- [Custom Effects Example Notebook](../examples/3_custom_effects.ipynb) — a |
| 333 | + worked example with `PointSourceJitter` and MICADO |
| 334 | +- The auto-generated [API Reference for scopesim.effects.Effect](../_autosummary/scopesim.effects.html) |
0 commit comments