diff --git a/.gitignore b/.gitignore
index c7a49bfcc..1d367cd68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,8 @@ MANIFEST
# Documentation etc: Sphinx and ReadTheDocs
reference/
effects_docstrings/
+docs/_build/
+docs/source/_autosummary/
*.ipynb_checkpoints
diff --git a/docs/source/_ext/scopesim_sphinx_ext.py b/docs/source/_ext/scopesim_sphinx_ext.py
index 4ab146d1e..c977ce387 100644
--- a/docs/source/_ext/scopesim_sphinx_ext.py
+++ b/docs/source/_ext/scopesim_sphinx_ext.py
@@ -24,7 +24,7 @@ def setup(app):
eff_type_strs[base_name] += new_str
for eff_type, strs in eff_type_strs.items():
- with open(os.path.join(output_dir, f"{eff_type}.rst"), "w") as f:
+ with open(os.path.join(output_dir, f"{eff_type}.rst"), "w", encoding="utf-8") as f:
f.write(strs)
diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst
new file mode 100644
index 000000000..548fdf249
--- /dev/null
+++ b/docs/source/concepts.rst
@@ -0,0 +1,215 @@
+ScopeSim concepts and architecture
+====================================
+
+This page explains the key objects in ScopeSim and how they fit together.
+Reading it will help you understand what happens during a simulation and how
+to customise it.
+
+---
+
+The simulation pipeline
+------------------------
+
+Every ScopeSim simulation follows the same pipeline:
+
+.. code-block:: text
+
+ Source ──► OpticalTrain ──► Detector ──► FITS output
+ ↑ ↑
+ (target) (telescope + instrument +
+ atmosphere + sky)
+
+1. A **Source** object describes the on-sky target: spatial positions and
+ spectra.
+2. The **OpticalTrain** applies a sequence of **Effects** — PSFs, transmission
+ curves, noise sources, detector geometry — that transform the source signal
+ as photons travel from the sky to the detector.
+3. The **Detector** integrates the focal-plane image, adds readout noise and
+ dark current, and produces the final pixel array.
+4. The result is returned as an ``astropy.fits.HDUList``.
+
+The ``Simulation`` class is a convenience wrapper that bundles steps 2–4
+together and reads the instrument configuration from the IRDB packages.
+
+---
+
+The five core objects
+----------------------
+
+Source
+~~~~~~
+
+A ``Source`` represents an on-sky target. It holds:
+
+- **Spatial information** — positions of emitting elements (point sources or
+ images)
+- **Spectral information** — one or more spectra associated with those elements
+- **Flux scaling** — brightnesses in physical units or magnitudes
+
+Sources are created using ``scopesim_templates`` functions for common
+astronomical objects, or directly from FITS images, tables, or spectra::
+
+ import scopesim_templates as st
+
+ # Point sources
+ src = st.stellar.star(mag=20, filter_name="K")
+ src = st.stellar.cluster(mass=1e4, distance=50000)
+
+ # Extended source from a FITS image
+ from scopesim import Source
+ src = Source(image_hdu=my_fits_image_hdu, spectra=my_spectrum)
+
+ # Combine sources with +
+ combined = star_src + galaxy_src
+
+OpticalTrain
+~~~~~~~~~~~~~
+
+The ``OpticalTrain`` is the heart of ScopeSim. It models everything between
+the sky and the detector: atmosphere, telescope mirrors, AO system, instrument
+optics, filters, PSF, and detectors. It contains an ordered list of
+**Effects** that are applied in sequence when ``observe()`` is called.
+
+You rarely create an ``OpticalTrain`` directly — the ``Simulation`` class does
+it for you. But you can inspect and customise it::
+
+ sim = Simulation("MICADO", mode=["MCAO_4mas", "IMG"])
+ ot = sim.optical_train
+
+ ot.effects # view all effects
+ ot["detector_linearity"].include = False # disable an effect
+ ot.image_planes[0].data # access intermediate focal-plane image
+
+Effect
+~~~~~~~
+
+Effects are the building blocks of the optical model. Each ``Effect`` subclass
+models one physical process:
+
+- **SurfaceList** — mirrors and optical surfaces (transmission, emission)
+- **FieldConstantPSF**, **FieldVaryingPSF** — PSF from a FITS cube
+- **AnisocadoConstPSF** — AnisoCADO-based AO PSF
+- **SeeingPSF**, **GaussianDiffractionPSF** — analytical PSFs
+- **FilterCurve**, **QuantumEfficiencyCurve** — spectral transmission/efficiency
+- **ShotNoise**, **DarkCurrent**, **ReadoutNoise** — detector noise sources
+- **DetectorList** — detector geometry, pixel scale, gaps
+- **AutoExposure**, **SummedExposure** — exposure logic
+
+Effects are defined in the instrument YAML files in the IRDB. You can also
+write custom effects — see the :doc:`examples/3_custom_effects` notebook.
+
+UserCommands / settings
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``UserCommands`` object (accessible as ``sim.settings``) is a nested
+dictionary that holds all simulation parameters. It is constructed from the
+instrument YAML files but any parameter can be overridden at runtime.
+
+Parameters are accessed using **bang-string** (``!``) notation::
+
+ sim.settings["!OBS.dit"] = 60 # integration time [s]
+ sim.settings["!OBS.ndit"] = 10 # number of integrations
+ sim.settings["!OBS.filter_name"] = "K" # filter selection
+ sim.settings["!ATMO.seeing"] = 0.7 # seeing FWHM [arcsec]
+ sim.settings["!SIM.spectral.wave_min"] = 1.9 # wavelength range [µm]
+
+The ``!`` prefix and dot notation resolve paths through nested YAML
+dictionaries. The top-level aliases are:
+
+.. list-table::
+ :widths: 15 85
+ :header-rows: 1
+
+ * - Alias
+ - Covers
+ * - ``!OBS``
+ - Observation parameters (DIT, NDIT, filter, mode)
+ * - ``!SIM``
+ - Simulation parameters (wavelength range, pixel oversampling, file paths)
+ * - ``!ATMO``
+ - Atmospheric parameters (seeing, background, transmission)
+ * - ``!TEL``
+ - Telescope parameters (collecting area, emissivity)
+ * - ``!INST``
+ - Instrument parameters (pixel scale, plate scale)
+ * - ``!DET``
+ - Detector parameters (readout mode, gain, dark current)
+
+Detector
+~~~~~~~~~
+
+The ``Detector`` object manages the focal-plane array. It holds one or more
+``DetectorWindow`` instances (individual chips), integrates signal from the
+focal plane, and applies per-chip noise models and non-linearity corrections.
+You typically interact with the output HDUList rather than the ``Detector``
+directly.
+
+---
+
+Fields of View (FOVs)
+----------------------
+
+When ``OpticalTrain.observe()`` is called, ScopeSim divides the simulation
+volume into small *Fields of View* (FOVs). Each FOV covers a spatial
+sub-region and a wavelength slice, chosen to match the spectral resolution of
+the current setup. Effects are applied independently within each FOV, then
+the results are assembled into the full focal-plane image.
+
+This design enables:
+
+- Efficient memory usage for large detector arrays
+- Wavelength-dependent PSFs (field-varying PSF cubes)
+- Multi-chip detectors with gaps and offsets
+- Spectroscopic and IFU modes with curved/tilted traces
+
+You rarely need to interact with FOVs directly. If you need noiseless
+intermediate images, use ``sim.optical_train.image_planes[0].data``.
+
+---
+
+Instrument packages and YAML files
+------------------------------------
+
+All instrument-specific data lives in the IRDB, downloaded via::
+
+ scopesim.download_packages(["Armazones", "ELT", "MORFEO", "MICADO"])
+
+Each package is a directory of YAML files and associated data (FITS tables,
+transmission curves, PSF cubes). A ``default.yaml`` defines which YAML files
+to load for each observing mode. The YAML files list the ``effects`` that make
+up the optical train, with their parameters.
+
+You can browse the downloaded packages in ``./inst_pkgs/`` to understand what
+parameters are available and what data files are used.
+
+---
+
+Putting it all together
+------------------------
+
+A complete simulation::
+
+ import scopesim
+ import scopesim_templates as st
+ from scopesim import Simulation
+
+ # Download instrument packages (once)
+ scopesim.download_packages(["Armazones", "ELT", "MORFEO", "MICADO"])
+
+ # 1. Create the on-sky target
+ src = st.stellar.cluster(mass=1e4, distance=50000, filter_name="V")
+
+ # 2. Load the optical train for the chosen mode
+ sim = Simulation("MICADO", mode=["MCAO_4mas", "IMG"])
+
+ # 3. Override any parameters you want to change
+ sim.settings["!OBS.dit"] = 120
+ sim.settings["!OBS.ndit"] = 5
+ sim.settings["!OBS.filter_name"] = "Ks"
+
+ # 4. Run the simulation — returns an astropy HDUList
+ hdu = sim(src)
+ hdu.writeto("my_simulation.fits", overwrite=True)
+
+ # Optional: inspect the noiseless focal-plane image
+ noiseless = sim.optical_train.image_planes[0].data
diff --git a/docs/source/faqs/faq_filters.md b/docs/source/faqs/faq_filters.md
new file mode 100644
index 000000000..5ee8cef3a
--- /dev/null
+++ b/docs/source/faqs/faq_filters.md
@@ -0,0 +1,102 @@
+# Filters
+
+## How do I list available filters?
+
+```python
+sim.optical_train["filter_wheel"].filters
+```
+
+This returns a table of filter names and their wavelength coverage. The exact
+name of the filter wheel effect may differ per instrument — use
+`sim.optical_train.effects` to see what effects are loaded and find the
+relevant one.
+
+---
+
+## How do I change the filter?
+
+```python
+sim.settings["!OBS.filter_name"] = "Ks"
+```
+
+Available filter names are shown by `sim.optical_train["filter_wheel"].filters`.
+The parameter path (`!OBS.filter_name`) may differ slightly between instruments
+— check `sim.settings["!OBS"]` if the default path does not work.
+
+---
+
+## How do I plot a filter transmission curve?
+
+```python
+filt = sim.optical_train["filter_wheel"].current_filter
+filt.plot()
+```
+
+Or access the transmission table directly:
+
+```python
+filt.table # astropy Table with "wavelength" and "transmission" columns
+```
+
+---
+
+## How do I use a custom filter?
+
+Create a `FilterCurve` effect from a two-column ASCII file (wavelength [µm],
+transmission [0–1]):
+
+```python
+from scopesim.effects import FilterCurve
+
+my_filter = FilterCurve(
+ filename="my_filter.dat",
+ name="my_custom_filter",
+)
+sim.optical_train.optics_manager.add_effect(my_filter)
+```
+
+Or pass a filter directly as a `synphot.SpectralElement`:
+
+```python
+from synphot import SpectralElement, Empirical1D
+import astropy.units as u
+import numpy as np
+
+wave = np.linspace(2.0, 2.5, 100) * u.um
+trans = np.exp(-0.5 * ((wave.value - 2.2) / 0.1) ** 2)
+sp = SpectralElement(Empirical1D, points=wave, lookup_table=trans)
+```
+
+---
+
+## What filters are available for MICADO?
+
+MICADO's standard filter set includes:
+
+| Name | Wavelength range | Notes |
+|---|---|---|
+| `J` | 1.17–1.33 µm | Broadband J |
+| `H` | 1.49–1.78 µm | Broadband H |
+| `Ks` | 2.00–2.37 µm | Broadband Ks |
+| `Y` | 0.97–1.07 µm | Y-band |
+| `z` | 0.85–0.95 µm | z-band |
+| `Br-gamma` | 2.16 µm | Narrow-band Brγ emission |
+| `H2` | 2.12 µm | Narrow-band H₂ emission |
+| `FeII` | 1.64 µm | Narrow-band [Fe II] emission |
+
+Use `sim.optical_train["filter_wheel"].filters` after loading the MICADO
+package for the authoritative current list.
+
+---
+
+## Why does my flux change when I change wavelength range?
+
+ScopeSim integrates over wavelengths. If `!SIM.spectral.wave_min` or
+`wave_max` is set narrower than the filter bandpass, flux outside the
+simulation range is silently dropped. Always ensure the simulation wavelength
+range covers at least the full filter bandpass:
+
+```python
+sim.settings["!SIM.spectral.wave_min"] = 1.9 # µm
+sim.settings["!SIM.spectral.wave_max"] = 2.5 # µm
+```
diff --git a/docs/source/faqs/faq_sources.md b/docs/source/faqs/faq_sources.md
new file mode 100644
index 000000000..c2a8ada13
--- /dev/null
+++ b/docs/source/faqs/faq_sources.md
@@ -0,0 +1,135 @@
+# Sources
+
+## What packages do I need to create sources?
+
+For common astronomical sources use
+[ScopeSim Templates](https://scopesim-templates.readthedocs.io/en/latest/):
+
+```bash
+pip install scopesim_templates
+```
+
+For spectral libraries use
+[SpeXtra](https://spextra.readthedocs.io/en/latest/) (many catalogues) or
+[Pyckles](https://pyckles.readthedocs.io/en/latest/) (Pickles stellar library).
+
+For hand-crafted sources the `scopesim.Source` class accepts arrays and FITS
+images directly.
+
+---
+
+## How do I create a single star?
+
+```python
+import scopesim_templates as st
+
+src = st.stellar.star(mag=20, filter_name="Ks", spec_type="A0V")
+```
+
+---
+
+## How do I create a star field?
+
+```python
+src = st.stellar.star_field(
+ n=200,
+ mmin=18,
+ mmax=24,
+ width=60, # [arcsec] square field side length
+ filter_name="Ks",
+)
+```
+
+---
+
+## How do I create a stellar cluster?
+
+```python
+src = st.stellar.cluster(
+ mass=1e4, # [M_sun] total stellar mass
+ distance=50000, # [pc]
+ filter_name="V",
+ core_radius=0.3, # [pc]
+)
+```
+
+---
+
+## How do I create a galaxy?
+
+```python
+src = st.extragalactic.elliptical(
+ total_magnitude=18,
+ filter_name="Ks",
+ pixel_scale=0.004, # [arcsec/pix] output pixel scale
+ r_eff=0.5, # [arcsec] effective radius
+ n=4, # Sérsic index
+ ellip=0.3,
+ theta=45,
+)
+```
+
+---
+
+## How do I combine multiple sources?
+
+Use the `+` operator:
+
+```python
+stars = st.stellar.star_field(100, "V", 18, 24, width=30)
+galaxy = st.extragalactic.elliptical(20, "Ks", ...)
+
+combined = stars + galaxy
+sim(combined)
+```
+
+---
+
+## How do I create a source from a FITS image?
+
+```python
+from astropy.io import fits
+from scopesim import Source
+import spextra as sp
+
+# Load your image
+hdu = fits.open("my_image.fits")[0]
+
+# Need a spectrum — use SpeXtra or synphot
+spectrum = sp.Spextrum("pickles/a0v") # example: A0V star spectrum
+
+src = Source(image_hdu=hdu, spectra=[spectrum])
+```
+
+The FITS image header must contain WCS keywords (``CDELT``, ``CRPIX``,
+``CRVAL``) defining the pixel scale in arcsec/pix. Pixel values are treated
+as flux weights multiplied by the given spectrum.
+
+---
+
+## How do I set the position of a source?
+
+Source coordinates are in arcsec relative to the field centre:
+
+```python
+src = st.stellar.star(mag=20, filter_name="Ks")
+src.shift(dx=2.5, dy=-1.0) # offset in arcsec
+```
+
+For point-source arrays, positions are set via the `x` and `y` arguments of
+most template functions.
+
+---
+
+## How do I check what my source looks like?
+
+```python
+src.plot() # spatial footprint
+src.spectra[0].plot() # first spectrum
+```
+
+Or view the source table:
+
+```python
+src.source_table # x, y positions and spectral references
+```
diff --git a/docs/source/faqs/faq_troubleshooting.md b/docs/source/faqs/faq_troubleshooting.md
new file mode 100644
index 000000000..2554da534
--- /dev/null
+++ b/docs/source/faqs/faq_troubleshooting.md
@@ -0,0 +1,133 @@
+# Troubleshooting
+
+Common errors and how to fix them.
+
+---
+
+## `File cannot be found: default.yaml`
+
+**Cause:** ScopeSim cannot find the instrument packages. They have not been
+downloaded, or you are running from the wrong directory.
+
+**Fix:** Download the packages into your current working directory:
+
+```python
+import scopesim
+scopesim.download_packages(["Armazones", "ELT", "MORFEO", "MICADO"])
+```
+
+Or tell ScopeSim where the packages live:
+
+```python
+scopesim.rc.__config__["!SIM.file.local_packages_path"] = "/path/to/inst_pkgs"
+```
+
+---
+
+## `RuntimeError: No package named X found`
+
+**Cause:** The package `X` has not been downloaded or the name is wrong.
+
+**Fix:** Check available packages:
+
+```python
+scopesim.list_packages() # packages available on the server
+scopesim.list_packages(local=True) # packages already installed locally
+```
+
+---
+
+## Simulation returns all zeros / black image
+
+**Cause 1:** The source is outside the field of view.
+
+**Fix:** Check your source coordinates. The field centre is typically at
+`(0, 0)` arcsec unless you have set a WCS offset. Point sources need
+`x` and `y` in arcsec relative to the field centre.
+
+**Cause 2:** The source spectrum has no flux in the simulation wavelength
+range.
+
+**Fix:** Check `sim.settings["!SIM.spectral.wave_min"]` and `wave_max` match
+the filter you are using.
+
+---
+
+## `KeyError: !OBS.some_parameter`
+
+**Cause:** The bang-string path does not exist in the loaded YAML package.
+
+**Fix:** Inspect the settings to find the correct key:
+
+```python
+sim.settings["!OBS"] # view all OBS-level parameters
+sim.settings # view the full settings dict
+```
+
+Bang-strings are case-sensitive. Use `!OBS.dit` not `!OBS.DIT`.
+
+---
+
+## Simulation is very slow
+
+**Cause 1:** The spatial oversampling is high. The default chunk size may
+create many small FOV tiles.
+
+**Fix:** Increase the chunk size:
+
+```python
+sim.settings["!SIM.computing.chunk_size"] = 4096 # default 2048
+```
+
+**Cause 2:** A `FieldVaryingPSF` with many spatial positions is loaded.
+
+**Fix:** Substitute a simpler PSF for exploratory runs:
+
+```python
+sim.optical_train["psf"].include = False
+```
+
+**Cause 3:** Sub-pixel mode is on.
+
+**Fix:**
+
+```python
+sim.settings["!SIM.sub_pixel.flag"] = False
+```
+
+---
+
+## `AttributeError: 'Simulation' object has no attribute 'X'`
+
+**Cause:** You are calling a SimCADO-style attribute on a ScopeSim object.
+
+**Fix:** See the [SimCADO migration guide](../simcado_migration.rst) for the
+ScopeSim equivalent.
+
+---
+
+## Output FITS has unexpected number of extensions
+
+Each chip in the detector array produces one FITS extension. A 3×3 H2RG
+array (like MICADO) produces 9 science extensions plus a primary HDU, giving
+10 extensions total. Access them by index:
+
+```python
+hdu = sim(src)
+len(hdu) # total number of extensions
+hdu[1].data # chip 1 pixel data
+hdu[1].header # chip 1 header with WCS
+```
+
+---
+
+## Getting a bug report
+
+When reporting issues, always include:
+
+```python
+import scopesim
+print(scopesim.bug_report())
+```
+
+File issues at https://github.com/AstarVienna/ScopeSim/issues
diff --git a/docs/source/index.rst b/docs/source/index.rst
index c431552a6..8fbced9a9 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -51,12 +51,25 @@ ScopeSim_ is on pip::
.. toctree::
:maxdepth: 2
- :caption: Contents:
+ :caption: Getting Started:
getting_started
+ concepts
+ simcado_migration
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Reference:
+
+ psfs
+ faqs/index
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Examples:
+
examples/index
5_liners/index
- faqs/index
.. warning:: July 2022: The downloadable content server was retired and the data migrated to a new server.
diff --git a/docs/source/psfs.rst b/docs/source/psfs.rst
new file mode 100644
index 000000000..e3733514f
--- /dev/null
+++ b/docs/source/psfs.rst
@@ -0,0 +1,174 @@
+Point Spread Functions
+=======================
+
+ScopeSim models the PSF as one or more **PSF effects** in the optical train.
+Several PSF classes are available, each suited to different simulation
+scenarios. Instrument packages in the IRDB configure the appropriate PSF
+automatically, but this page explains the options so you can understand what
+is active and how to modify it.
+
+---
+
+Available PSF effect types
+---------------------------
+
+FieldConstantPSF
+~~~~~~~~~~~~~~~~~
+
+A PSF that is the same across the entire field of view, read from a
+FITS file. The FITS file may contain a single kernel or a wavelength-
+dependent PSF cube (one kernel per wavelength slice).
+
+**Best for:** Instruments with a relatively uniform PSF, or when you want a
+single representative PSF for speed.
+
+**YAML example:**
+
+.. code-block:: yaml
+
+ - name: micado_psf
+ class: FieldConstantPSF
+ kwargs:
+ filename: "PSF_MICADO_SCAO_1.5mas.fits"
+
+FieldVaryingPSF
+~~~~~~~~~~~~~~~~
+
+A PSF that varies with position across the field, read from a FITS file
+that contains PSF kernels on a spatial grid (and optionally also as a
+function of wavelength). ScopeSim interpolates between grid points to
+evaluate the PSF at any detector position.
+
+**Best for:** AO PSFs that degrade away from the guide star, such as SCAO
+systems where the PSF varies strongly across the MICADO field.
+
+.. note::
+
+ Field-varying PSF files for MICADO (SCAO modes) are large (~1–5 GB) and
+ must be downloaded separately from the regular instrument packages::
+
+ scopesim.download_psfs(psf_name="MICADO_SCAO_4mas_FV")
+
+AnisocadoConstPSF
+~~~~~~~~~~~~~~~~~~
+
+A field-constant AO PSF generated on-the-fly from the
+`AnisoCADO `_ package.
+AnisoCADO computes SCAO PSFs for the ELT using a semi-analytical model
+parameterised by atmospheric conditions and guide star separation.
+
+**Best for:** Generating realistic MICADO SCAO PSFs without downloading
+large pre-computed files. Useful for exploring how the PSF depends on
+observing conditions.
+
+**Requirements:** ``pip install anisocado``
+
+**Example:**
+
+.. code-block:: python
+
+ from scopesim.effects import AnisocadoConstPSF
+
+ psf = AnisocadoConstPSF(
+ filename=None,
+ strehl_ratio=0.5,
+ wavelength=2.15, # [µm]
+ pixel_scale=0.004, # [arcsec/pix]
+ )
+
+SeeingPSF
+~~~~~~~~~~
+
+An analytical seeing-limited PSF modelled as a 2D Gaussian with FWHM
+read from ``!ATMO.seeing``. Simple and fast — no PSF file needed.
+
+**Best for:** Seeing-limited simulations, or as a quick substitute while
+developing a simulation before loading the real AO PSF.
+
+**Example (override the seeing FWHM):**
+
+.. code-block:: python
+
+ sim.settings["!ATMO.seeing"] = 0.8 # [arcsec]
+
+GaussianDiffractionPSF
+~~~~~~~~~~~~~~~~~~~~~~~
+
+An analytical PSF combining a Gaussian seeing component with a diffraction-
+limited Airy disk. Parameterised by the telescope diameter and atmospheric
+seeing.
+
+**Best for:** Quick exploratory simulations or instruments without a dedicated
+PSF file.
+
+---
+
+Disabling or replacing the PSF
+--------------------------------
+
+To check what PSF effect is loaded::
+
+ sim.optical_train.effects # look for effects of type *PSF
+
+To disable the PSF (simulate a delta-function PSF)::
+
+ sim.optical_train["psf_effect_name"].include = False
+
+Replace with a simpler PSF for exploratory runs::
+
+ from scopesim.effects import SeeingPSF
+ sim.optical_train.optics_manager.add_effect(
+ SeeingPSF(fwhm=0.8, name="quick_psf")
+ )
+ sim.optical_train["original_psf"].include = False
+
+---
+
+Field-varying PSFs for MICADO
+-------------------------------
+
+MICADO SCAO observations use a field-varying PSF — the PSF is excellent
+near the guide star and degrades toward the field edge. Pre-computed
+field-varying PSF cubes (generated with AnisoCADO from ESO SCAO simulations)
+are available for download separately from the IRDB package.
+
+.. list-table::
+ :widths: 30 30 40
+ :header-rows: 1
+
+ * - PSF file
+ - Pixel scale
+ - Description
+ * - ``MICADO_SCAO_4mas_FV``
+ - 4 mas/pix
+ - 7×7 spatial grid, H+K bands
+ * - ``MICADO_SCAO_1.5mas_FV``
+ - 1.5 mas/pix
+ - 7×7 spatial grid, H+K bands
+
+These files are several GB each. Use ``FieldConstantPSF`` (the package
+default) for most simulations; switch to ``FieldVaryingPSF`` when the
+spatial variation of the PSF matters for your science case (e.g. astrometric
+calibration, PSF-subtracted imaging).
+
+---
+
+PSF file format
+----------------
+
+ScopeSim reads PSF FITS files in two formats:
+
+**Single kernel** — a 2D FITS image with:
+
+- ``PIXSCALE`` keyword in the header giving the kernel pixel scale [arcsec]
+- Pixel values representing the PSF intensity (will be normalised to sum=1)
+
+**Wavelength cube** — a 3D FITS cube with:
+
+- First axis: wavelength
+- Second and third axes: spatial kernel
+- ``WAVE0``, ``DWAVE``, ``WAVEUNIT`` header keywords defining the wavelength axis
+
+For field-varying PSFs, the FITS file contains a grid of kernels arranged by
+spatial position. See the IRDB documentation for the specific format used by
+MICADO field-varying PSF files.
diff --git a/docs/source/simcado_migration.rst b/docs/source/simcado_migration.rst
new file mode 100644
index 000000000..a5c86fee6
--- /dev/null
+++ b/docs/source/simcado_migration.rst
@@ -0,0 +1,212 @@
+Migrating from SimCADO
+======================
+
+ScopeSim is the successor to SimCADO. While the underlying simulation
+philosophy is the same, the architecture has been redesigned to be instrument-
+agnostic. This page maps the old SimCADO API onto ScopeSim equivalents.
+
+.. note::
+
+ SimCADO only simulated MICADO. ScopeSim is a general framework — to
+ simulate MICADO you need the ScopeSim engine plus the MICADO instrument
+ package from the IRDB.
+
+---
+
+The minimum working example
+----------------------------
+
+**SimCADO** ::
+
+ import simcado
+ src = simcado.source.star_field(100, 15, 20, width=10)
+ simcado.run(src, filename="output.fits")
+
+**ScopeSim** ::
+
+ import scopesim
+ import scopesim_templates as st
+ from scopesim import Simulation
+
+ scopesim.download_packages(["Armazones", "ELT", "MORFEO", "MICADO"])
+
+ src = st.stellar.star_field(100, "V", 15, 20, width=10)
+ sim = Simulation("MICADO", mode=["MCAO_4mas", "IMG"])
+ sim(src, dit=60, ndit=10)
+
+
+---
+
+API mapping
+-----------
+
+Installation / setup
+~~~~~~~~~~~~~~~~~~~~
+
+.. list-table::
+ :widths: 40 60
+ :header-rows: 1
+
+ * - SimCADO
+ - ScopeSim equivalent
+ * - ``pip install simcado``
+ - ``pip install scopesim scopesim_templates``
+ * - ``simcado.get_extras()``
+ - ``scopesim.download_packages(["Armazones", "ELT", "MORFEO", "MICADO"])``
+ * - Data files bundled with package
+ - Instrument packages live in ``./inst_pkgs/`` after download
+
+Source creation
+~~~~~~~~~~~~~~~
+
+.. list-table::
+ :widths: 40 60
+ :header-rows: 1
+
+ * - SimCADO
+ - ScopeSim equivalent
+ * - ``simcado.source.star(mag, filter_name)``
+ - ``scopesim_templates.stellar.star(mag, filter_name, ...)``
+ * - ``simcado.source.stars(mags, x, y, filter_name)``
+ - ``scopesim_templates.stellar.stars(filter_name, mags, ...)``
+ * - ``simcado.source.star_grid(n, mag_min, mag_max)``
+ - ``scopesim_templates.stellar.star_grid(n, mag_min, mag_max, ...)``
+ * - ``simcado.source.cluster(mass, distance)``
+ - ``scopesim_templates.stellar.cluster(mass, distance, ...)``
+ * - ``simcado.source.source_from_image(imgs, lam, spectra)``
+ - ``scopesim.Source(image_hdu=fits_image, spectra=spectra, ...)``
+ * - ``src1 + src2``
+ - ``src1 + src2`` (unchanged)
+
+Configuration / commands
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. list-table::
+ :widths: 40 60
+ :header-rows: 1
+
+ * - SimCADO
+ - ScopeSim equivalent
+ * - ``cmds = simcado.UserCommands()``
+ - ``sim.settings`` (accessed from the ``Simulation`` object)
+ * - ``cmds["OBS_EXPTIME"] = 3600``
+ - ``sim.settings["!OBS.dit"] = 360; sim.settings["!OBS.ndit"] = 10``
+ * - ``cmds["OBS_DIT"] = 60``
+ - ``sim.settings["!OBS.dit"] = 60``
+ * - ``cmds["OBS_NDIT"] = 10``
+ - ``sim.settings["!OBS.ndit"] = 10``
+ * - ``cmds["INST_FILTER_TC"] = "K"``
+ - ``sim.settings["!OBS.filter_name"] = "K"``
+ * - ``cmds["SIM_PIXEL_SCALE"] = 0.004``
+ - Determined by the chosen observing mode (e.g. ``"MCAO_4mas"``)
+ * - ``cmds["ATMO_SEEING"] = 0.8``
+ - ``sim.settings["!ATMO.seeing"] = 0.8``
+ * - Keyword names like ``OBS_EXPTIME``
+ - Bang-string notation: ``!OBS.dit``, ``!SIM.spectral.wave_min``, etc.
+
+Running simulations
+~~~~~~~~~~~~~~~~~~~
+
+.. list-table::
+ :widths: 40 60
+ :header-rows: 1
+
+ * - SimCADO
+ - ScopeSim equivalent
+ * - ``simcado.run(src, cmds=cmds, filename="out.fits")``
+ - ``sim(src)`` — returns an ``astropy.fits.HDUList``
+ * - ``ot = simcado.OpticalTrain(cmds); ot.observe(src)``
+ - ``sim.optical_train.observe(src); sim.optical_train.readout()``
+ * - ``det = simcado.Detector(cmds); det.read_out()``
+ - Handled internally by ``sim(src)``
+ * - ``det.array`` (raw pixel data)
+ - ``sim(src)[1].data`` (FITS extension 1)
+
+Observing modes
+~~~~~~~~~~~~~~~
+
+SimCADO was MICADO-specific and configured via flat keywords. ScopeSim uses
+named observing modes defined in the YAML package files.
+
+.. list-table::
+ :widths: 40 60
+ :header-rows: 1
+
+ * - SimCADO configuration
+ - ScopeSim equivalent
+ * - ``cmds["SIM_PIXEL_SCALE"] = 0.004`` (4 mas)
+ - ``Simulation("MICADO", mode=["MCAO_4mas", "IMG"])``
+ * - ``cmds["SIM_PIXEL_SCALE"] = 0.0015`` (1.5 mas)
+ - ``Simulation("MICADO", mode=["SCAO_1.5mas", "IMG"])``
+ * - ``cmds["SCOPE_USE_MIRROR_BG"] = True``
+ - Controlled by an ``SurfaceList`` effect in the YAML package
+
+Listing available options::
+
+ sim.settings.modes # available observing modes
+ sim.optical_train["filter_wheel"].filters # available filters
+ sim.optical_train.effects # all active effects
+
+Optical train inspection
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. list-table::
+ :widths: 40 60
+ :header-rows: 1
+
+ * - SimCADO
+ - ScopeSim equivalent
+ * - ``ot.apply_optical_train(src, det)``
+ - ``sim.optical_train.observe(src)``
+ * - Accessing intermediate images: not straightforward
+ - ``sim.optical_train.image_planes[0].data``
+ * - Turn effect off: not directly supported
+ - ``sim.optical_train["effect_name"].include = False``
+ * - List effects: not supported
+ - ``sim.optical_train.effects``
+
+---
+
+Key conceptual changes
+-----------------------
+
+Modular instrument packages
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+SimCADO was self-contained — all MICADO data was shipped with the Python
+package. ScopeSim separates the simulation engine from the instrument data:
+
+- **ScopeSim** — the simulation engine (this package)
+- **IRDB** — instrument packages downloaded on first use
+- **ScopeSim Templates** — helper functions for creating on-sky sources
+
+This means you must call ``scopesim.download_packages(...)`` before running any
+real instrument simulation. The packages are cached in ``./inst_pkgs/`` and
+only need to be downloaded once.
+
+Bang-string parameter access
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+SimCADO used flat uppercase keyword names (``OBS_EXPTIME``, ``INST_FILTER_TC``).
+ScopeSim organises parameters in a nested YAML hierarchy accessed with
+bang-string notation::
+
+ sim.settings["!OBS.dit"] # observation DIT
+ sim.settings["!OBS.filter_name"] # filter selection
+ sim.settings["!ATMO.seeing"] # atmospheric seeing
+ sim.settings["!SIM.spectral.wave_min"] # simulation wavelength range
+
+The ``!`` prefix signals a YAML-path lookup. Keys are structured as
+``!..``.
+
+MICADO now needs MORFEO
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+In SimCADO, MICADO's adaptive optics (MAORY at the time) was implicit. In
+ScopeSim, MICADO works with MORFEO as a separate instrument package that must
+be downloaded explicitly::
+
+ scopesim.download_packages(["Armazones", "ELT", "MORFEO", "MICADO"])
+
+For purely diffraction-limited or seeing-limited simulations (no AO), use the
+standalone MICADO modes.