Skip to content

Commit b2523bf

Browse files
astronomykclaude
andcommitted
Add Effects documentation section with overview and custom effects tutorial
New docs/source/effects/ section covering: - Overview of all built-in effect types, the simulation pipeline, z-order system, YAML configuration, and runtime interaction - Tutorial on creating custom effects with a non-symmetric vignetting flat field example, plus guidance on sharing/contributing effects Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9a4120b commit b2523bf

4 files changed

Lines changed: 640 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
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)

docs/source/effects/index.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Effects
2+
=======
3+
4+
.. toctree::
5+
:maxdepth: 2
6+
:caption: Contents:
7+
8+
overview
9+
custom_effects

0 commit comments

Comments
 (0)