-
Notifications
You must be signed in to change notification settings - Fork 23
Add Effects documentation section #899
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
astronomyk
wants to merge
3
commits into
main
Choose a base branch
from
docs/effects-documentation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| --- | ||
| jupytext: | ||
| text_representation: | ||
| extension: .md | ||
| format_name: myst | ||
| format_version: 0.13 | ||
| jupytext_version: 1.16.1 | ||
| kernelspec: | ||
| display_name: Python 3 (ipykernel) | ||
| language: python | ||
| name: python3 | ||
| --- | ||
|
|
||
| # Creating Custom Effects | ||
|
|
||
| ScopeSim's built-in effects cover the most common physical phenomena in optical | ||
| systems, but you may need to model instrument-specific behaviour that isn't | ||
| provided out of the box. Creating a custom `Effect` subclass lets you inject | ||
| arbitrary transformations into the simulation pipeline. | ||
|
|
||
| For a worked example that creates a `PointSourceJitter` effect and adds it to | ||
| a full MICADO simulation, see the | ||
| [Custom Effects Example Notebook](../examples/3_custom_effects.ipynb). | ||
| This page focuses on a complementary example – a non-symmetric vignetting flat | ||
| field applied at the image plane level. | ||
|
|
||
| ## Anatomy of an Effect Subclass | ||
|
|
||
| Every custom effect needs three things: | ||
|
|
||
| 1. **`z_order`** – a class variable (tuple of ints) that tells ScopeSim *when* | ||
| in the pipeline to apply the effect. | ||
| 2. **`__init__`** – calls `super().__init__()` and sets default parameters in | ||
| `self.meta`. | ||
| 3. **`apply_to(self, obj)`** – the method that does the work. It receives an | ||
| object, optionally modifies it, and **must return it**. | ||
|
|
||
| The `apply_to` method should use `isinstance` checks to determine whether to | ||
| act on the given object. During a simulation run, ScopeSim passes different | ||
| object types at different stages – your effect will only modify the types it | ||
| knows how to handle, and pass everything else through unchanged. | ||
|
|
||
| ## Choosing the Right Z-Order | ||
|
|
||
| The z_order determines which pipeline stage your effect participates in, and | ||
| therefore what type of object it receives: | ||
|
|
||
| | Z-Order Range | Object Type | Use When... | | ||
| |:---:|---|---| | ||
| | 500–599 | `Source` | Modifying the original light distribution (e.g., spectral shifts, flux scaling) | | ||
| | 600–699 | `FieldOfView` | Modifying per-wavelength spatial cutouts (e.g., PSF convolution, dispersion) | | ||
| | 700–799 | `ImagePlane` | Modifying the wavelength-integrated focal plane image (e.g., vignetting, flat fields) | | ||
| | 800–899 | `Detector` | Modifying the detector readout (e.g., noise, dark current, gain variations) | | ||
|
|
||
| An effect can have multiple z_order values to participate in both a setup stage | ||
| and an application stage. For a simple custom effect, a single value is | ||
| usually sufficient. | ||
|
|
||
| ## Example: Non-Symmetric Vignetting Flat Field | ||
|
|
||
| This example creates an effect that applies a spatially-varying throughput | ||
| pattern to the image plane, simulating optical vignetting that is not radially | ||
| symmetric – for instance, caused by an off-axis obstruction or asymmetric optics. | ||
|
|
||
| The vignetting is modelled as an elliptical Gaussian decay with configurable | ||
| center offset, semi-axes, rotation angle, and throughput range. | ||
|
|
||
| ### Defining the effect class | ||
|
|
||
| ```{code-cell} ipython3 | ||
| import numpy as np | ||
| from scopesim.effects import Effect | ||
| from scopesim.optics.image_plane import ImagePlane | ||
|
|
||
|
|
||
| class NonSymmetricVignetting(Effect): | ||
| """Apply a non-symmetric vignetting pattern to the image plane.""" | ||
|
|
||
| z_order = (710,) | ||
|
|
||
| def __init__(self, **kwargs): | ||
| super().__init__(**kwargs) | ||
| params = { | ||
| "x_center_offset": 0.1, # fractional offset from image center | ||
| "y_center_offset": -0.05, | ||
| "sigma_x": 0.8, # fractional semi-axis (1.0 = full frame) | ||
| "sigma_y": 0.6, | ||
| "rotation_deg": 15.0, # rotation angle of the vignetting ellipse | ||
| "max_throughput": 1.0, | ||
| "min_throughput": 0.3, | ||
| } | ||
| for key, val in params.items(): | ||
| self.meta.setdefault(key, val) | ||
| self.meta.update(kwargs) | ||
|
|
||
| def _make_vignetting_map(self, shape): | ||
| """Generate a 2D vignetting map for a given image shape.""" | ||
| ny, nx = shape | ||
| y, x = np.mgrid[:ny, :nx] | ||
|
|
||
| # Normalise pixel coordinates to [-1, 1] and apply center offset | ||
| x_norm = 2.0 * x / nx - 1.0 - self.meta["x_center_offset"] | ||
| y_norm = 2.0 * y / ny - 1.0 - self.meta["y_center_offset"] | ||
|
|
||
| # Rotate coordinate frame | ||
| angle = np.deg2rad(self.meta["rotation_deg"]) | ||
| cos_a, sin_a = np.cos(angle), np.sin(angle) | ||
| x_rot = x_norm * cos_a + y_norm * sin_a | ||
| y_rot = -x_norm * sin_a + y_norm * cos_a | ||
|
|
||
| # Elliptical Gaussian falloff | ||
| r2 = (x_rot / self.meta["sigma_x"]) ** 2 + \ | ||
| (y_rot / self.meta["sigma_y"]) ** 2 | ||
| t_min = self.meta["min_throughput"] | ||
| t_max = self.meta["max_throughput"] | ||
| vmap = t_min + (t_max - t_min) * np.exp(-0.5 * r2) | ||
|
|
||
| return np.clip(vmap, t_min, t_max) | ||
|
|
||
| def apply_to(self, obj, **kwargs): | ||
| if isinstance(obj, ImagePlane): | ||
| vignetting = self._make_vignetting_map(obj.hdu.data.shape) | ||
| obj.hdu.data *= vignetting | ||
| return obj | ||
| ``` | ||
|
|
||
| ### Setting up the simulation | ||
|
|
||
| ```{code-cell} ipython3 | ||
| import scopesim as sim | ||
| from scopesim.source.source_templates import star_field | ||
|
|
||
| # Load the example optical train and create a star field source | ||
| opt = sim.load_example_optical_train() | ||
| src = star_field(n=50, mmin=15, mmax=20, width=200) | ||
|
|
||
| # Create and add the vignetting effect | ||
| vig = NonSymmetricVignetting(name="asymmetric_vignetting") | ||
| opt.optics_manager.add_effect(vig) | ||
|
|
||
| opt.effects | ||
| ``` | ||
|
|
||
| ### Observing and visualising | ||
|
|
||
| ```{code-cell} ipython3 | ||
| import matplotlib.pyplot as plt | ||
|
|
||
| opt.observe(src, update=True) | ||
|
|
||
| fig, axes = plt.subplots(1, 2, figsize=(14, 5)) | ||
|
|
||
| # Show the vignetted image | ||
| axes[0].imshow(opt.image_planes[0].data, origin="lower") | ||
| axes[0].set_title("Image plane with vignetting") | ||
|
|
||
| # Show the vignetting map itself | ||
| vmap = vig._make_vignetting_map(opt.image_planes[0].data.shape) | ||
| im = axes[1].imshow(vmap, origin="lower", cmap="RdYlGn", vmin=0, vmax=1) | ||
| axes[1].set_title("Vignetting map (throughput)") | ||
| fig.colorbar(im, ax=axes[1]) | ||
|
|
||
| plt.tight_layout() | ||
| plt.show() | ||
| ``` | ||
|
|
||
| ### Comparing with and without vignetting | ||
|
|
||
| ```{code-cell} ipython3 | ||
| # Observe without vignetting | ||
| vig.include = False | ||
| opt.observe(src, update=True) | ||
| no_vig_data = opt.image_planes[0].data.copy() | ||
|
|
||
| # Observe with vignetting | ||
| vig.include = True | ||
| opt.observe(src, update=True) | ||
| vig_data = opt.image_planes[0].data.copy() | ||
|
|
||
| fig, axes = plt.subplots(1, 2, figsize=(14, 5)) | ||
| axes[0].imshow(no_vig_data, origin="lower") | ||
| axes[0].set_title("Without vignetting") | ||
| axes[1].imshow(vig_data, origin="lower") | ||
| axes[1].set_title("With vignetting") | ||
| plt.tight_layout() | ||
| plt.show() | ||
| ``` | ||
|
|
||
| ## Modifying Parameters at Runtime | ||
|
|
||
| Effect parameters live in the `.meta` dictionary and can be changed between | ||
| observations: | ||
|
|
||
| ```{code-cell} ipython3 | ||
| # Make the vignetting more extreme | ||
| opt["asymmetric_vignetting"].meta["sigma_x"] = 0.4 | ||
| opt["asymmetric_vignetting"].meta["sigma_y"] = 0.3 | ||
| opt["asymmetric_vignetting"].meta["min_throughput"] = 0.1 | ||
|
|
||
| opt.observe(src, update=True) | ||
|
|
||
| fig, axes = plt.subplots(1, 2, figsize=(14, 5)) | ||
| axes[0].imshow(opt.image_planes[0].data, origin="lower") | ||
| axes[0].set_title("Tighter vignetting") | ||
|
|
||
| vmap = vig._make_vignetting_map(opt.image_planes[0].data.shape) | ||
| im = axes[1].imshow(vmap, origin="lower", cmap="RdYlGn", vmin=0, vmax=1) | ||
| axes[1].set_title("Updated vignetting map") | ||
| fig.colorbar(im, ax=axes[1]) | ||
| plt.tight_layout() | ||
| plt.show() | ||
| ``` | ||
|
|
||
| ## Tips for Writing Robust Effects | ||
|
|
||
| - **Always return `obj`** from `apply_to`, even when your `isinstance` check | ||
| doesn't match. ScopeSim passes many object types through the same list of | ||
| effects – returning `None` will break the pipeline. | ||
|
|
||
| - **Use `isinstance` guards** to decide whether to act. Your `apply_to` will | ||
| be called with `Source`, `FieldOfView`, `ImagePlane`, and `Detector` objects | ||
| at different stages. | ||
|
|
||
| - **Choose the right pipeline stage** carefully: | ||
| - `FieldOfView` (z=600–699): your effect is applied per wavelength bin and | ||
| per spatial chunk – appropriate for wavelength-dependent effects. | ||
| - `ImagePlane` (z=700–799): your effect sees the wavelength-integrated focal | ||
| plane image – appropriate for achromatic spatial effects like vignetting. | ||
| - `Detector` (z=800–899): your effect sees the detector readout after | ||
| extraction – appropriate for electronic effects like noise. | ||
|
|
||
| - **Look at built-in effects for patterns.** For example, | ||
| `PixelResponseNonUniformity` in `scopesim/effects/electronic/noise.py` is a | ||
| simple multiplicative detector-level effect. `SeeingPSF` in | ||
| `scopesim/effects/psfs/analytical.py` shows how to build a convolution kernel. | ||
|
|
||
| - **Use `from_currsys`** for parameters that should be resolvable as bang | ||
| strings (`!OBS.some_param`): | ||
| ```python | ||
| from scopesim.utils import from_currsys | ||
| value = from_currsys(self.meta["my_param"], self.cmds) | ||
| ``` | ||
|
|
||
| ## Adding Custom Effects to the Optical Train | ||
|
|
||
| Custom effects are added programmatically using `optics_manager.add_effect()`: | ||
|
|
||
| ```python | ||
| my_effect = MyCustomEffect(name="my_effect", some_param=42) | ||
| opt.optics_manager.add_effect(my_effect) | ||
| ``` | ||
|
|
||
| After adding an effect, pass `update=True` to `opt.observe()` so the optical | ||
| train rebuilds its internal structures to include the new effect. | ||
|
|
||
| Note that YAML-based instrument packages resolve effect class names from the | ||
| `scopesim.effects` namespace. Custom effect classes from third-party packages | ||
| currently need to be added programmatically as shown above. | ||
|
|
||
| ## Sharing Your Custom Effect | ||
|
|
||
| Once you've written and tested a custom effect, there are several ways to make | ||
| it available for use – either for yourself or for the wider community. | ||
|
|
||
| ### Option 1: Add it directly to the ScopeSim effects module (local) | ||
|
|
||
| If you want your effect to be available via YAML instrument packages (i.e., | ||
| referenced by class name in a YAML file), the simplest approach is to place | ||
| your Python file inside the `scopesim/effects/` directory of your local | ||
| ScopeSim installation and import it in `scopesim/effects/__init__.py`. | ||
|
|
||
| For example, if you save your effect class in | ||
| `scopesim/effects/my_vignetting.py`: | ||
|
|
||
| ```python | ||
| # scopesim/effects/my_vignetting.py | ||
| from .effects import Effect | ||
|
|
||
| class NonSymmetricVignetting(Effect): | ||
| ... | ||
| ``` | ||
|
|
||
| Then add the import to `scopesim/effects/__init__.py`: | ||
|
|
||
| ```python | ||
| from .my_vignetting import * | ||
| ``` | ||
|
|
||
| After this, the class name `NonSymmetricVignetting` can be used directly in | ||
| YAML configuration files: | ||
|
|
||
| ```yaml | ||
| effects: | ||
| - name: vignetting | ||
| class: NonSymmetricVignetting | ||
| kwargs: | ||
| sigma_x: 0.8 | ||
| sigma_y: 0.6 | ||
| ``` | ||
|
|
||
| Note that this modifies your local ScopeSim installation and will be | ||
| overwritten when you upgrade the package. For a more permanent solution, | ||
| consider contributing it upstream (Option 3). | ||
|
|
||
| ### Option 2: Keep it in your own script or package | ||
|
|
||
| For effects that are specific to your analysis, you can keep the effect class | ||
| in your own Python script or package and add it programmatically at runtime as | ||
| shown [above](#adding-custom-effects-to-the-optical-train). This is the | ||
| simplest approach and doesn't require modifying ScopeSim itself. | ||
|
|
||
| ### Option 3: Contribute it to ScopeSim | ||
|
|
||
| If your effect is general-purpose and would be useful to other users, we | ||
| welcome contributions! You can: | ||
|
|
||
| - **Open an issue** on the | ||
| [ScopeSim GitHub repository](https://github.com/AstarVienna/ScopeSim/issues) | ||
| describing your effect and sharing the code. The ScopeSim team can help | ||
| integrate it into the package. | ||
|
|
||
| - **Submit a pull request** with your effect class added to the | ||
| `scopesim/effects/` module, including the import in `__init__.py` and | ||
| ideally a test in `scopesim/tests/`. See the | ||
| [existing effects](https://github.com/AstarVienna/ScopeSim/tree/main/scopesim/effects) | ||
| for examples of the expected code style and structure. | ||
|
|
||
| ## See Also | ||
|
|
||
| - [Effects Overview](overview.md) – reference for all built-in effect types | ||
| and the simulation pipeline | ||
| - [Custom Effects Example Notebook](../examples/3_custom_effects.ipynb) – a | ||
| worked example with `PointSourceJitter` and MICADO | ||
| - The auto-generated [API Reference for scopesim.effects.Effect](../_autosummary/scopesim.effects.html) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This link does not work, see: https://github.com/AstarVienna/ScopeSim/actions/runs/23962249144/job/69894534246?pr=899 |
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| Effects | ||
| ======= | ||
|
|
||
| .. toctree:: | ||
| :maxdepth: 2 | ||
| :caption: Contents: | ||
|
|
||
| overview | ||
| custom_effects |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't have time for this, but whatever. Let's just hope nobody takes this as an invitation...