Skip to content

API reference

Camera

getframes.camera.Camera

A camera that generates realistic synthetic frames.

A :class:Camera wraps a :class:~getframes.config.CameraConfig and exposes high-level frame-generation methods. Construct one directly from a config, or load a built-in preset:

import getframes as gf cam = gf.Camera.from_preset("andor_ikon_m934") frame = cam.dark_frame(exposure=30.0, temperature=-60.0, seed=0) frame.shape (1024, 1024)

Parameters:

Name Type Description Default
config CameraConfig

The detector configuration.

required
default_temperature_c float | None

Temperature (deg C) used when a frame method is called without an explicit temperature. Defaults to the config's dark-current reference temperature.

None
seed int | None

Optional seed for this camera's internal random generator, giving reproducible output across calls when no per-call seed is supplied.

None
precision str

Working floating-point precision of the signal chain: "float64" (the exact default) or "float32" for the memory-light fast path — half the per-pixel memory, useful for large detectors and bulk dataset generation. The digitised ADU stay integer either way; only the floating-point arrays (including each frame's ground truth) change.

'float64'
device str

Execution device for detector arrays and random sampling: "cpu" (NumPy, default) or "gpu" (optional CuPy). GPU frames keep their ADU and truth arrays on device; CPU and GPU seeds are reproducible within a backend but intentionally do not produce identical random samples.

'cpu'
Source code in src/getframes/camera.py
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
class Camera:
    """A camera that generates realistic synthetic frames.

    A :class:`Camera` wraps a :class:`~getframes.config.CameraConfig` and exposes
    high-level frame-generation methods. Construct one directly from a config, or
    load a built-in preset:

    >>> import getframes as gf
    >>> cam = gf.Camera.from_preset("andor_ikon_m934")
    >>> frame = cam.dark_frame(exposure=30.0, temperature=-60.0, seed=0)
    >>> frame.shape
    (1024, 1024)

    Parameters
    ----------
    config:
        The detector configuration.
    default_temperature_c:
        Temperature (deg C) used when a frame method is called without an explicit
        temperature. Defaults to the config's dark-current reference temperature.
    seed:
        Optional seed for this camera's internal random generator, giving
        reproducible output across calls when no per-call seed is supplied.
    precision:
        Working floating-point precision of the signal chain: ``"float64"`` (the
        exact default) or ``"float32"`` for the memory-light fast path — half the
        per-pixel memory, useful for large detectors and bulk dataset generation.
        The digitised ADU stay integer either way; only the floating-point arrays
        (including each frame's ground truth) change.
    device:
        Execution device for detector arrays and random sampling: ``"cpu"``
        (NumPy, default) or ``"gpu"`` (optional CuPy). GPU frames keep their ADU
        and truth arrays on device; CPU and GPU seeds are reproducible within a
        backend but intentionally do not produce identical random samples.
    """

    _PRECISIONS: ClassVar[dict[str, type[np.floating[Any]]]] = {
        "float32": np.float32,
        "float64": np.float64,
    }

    def __init__(
        self,
        config: CameraConfig,
        *,
        default_temperature_c: float | None = None,
        seed: int | None = None,
        precision: str = "float64",
        device: str = "cpu",
    ) -> None:
        if not isinstance(config, CameraConfig):
            raise TypeError("config must be a CameraConfig instance.")
        if precision not in self._PRECISIONS:
            raise ValueError(
                f"precision must be one of {sorted(self._PRECISIONS)}, got {precision!r}."
            )
        self.config = config
        self.default_temperature_c = (
            default_temperature_c
            if default_temperature_c is not None
            else config.dark_current_ref_temp_c
        )
        self.precision = precision
        self._float_dtype = self._PRECISIONS[precision]
        self._backend: ArrayBackend = get_backend(device)
        self._rng = self._backend.default_rng(seed, float_dtype=self._float_dtype)
        self._seeded_rng = (
            None
            if self._backend.is_cpu
            else self._backend.default_rng(0, float_dtype=self._float_dtype)
        )
        self._fixed_patterns = noise.fixed_pattern_maps(
            config, backend=self._backend, float_dtype=self._float_dtype
        )
        self._dark_signal_cache_key: tuple[float, float] | None = None
        self._dark_signal_cache: Any | None = None

    # ------------------------------------------------------------------
    # Constructors
    # ------------------------------------------------------------------
    @classmethod
    def from_preset(cls, name: str, **kwargs: Any) -> Camera:
        """Create a camera from a built-in preset (see :func:`getframes.available_presets`)."""
        return cls(load_preset(name), **kwargs)

    @classmethod
    def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Camera:
        """Create a camera from a plain configuration dictionary."""
        return cls(CameraConfig.from_dict(data), **kwargs)

    # ------------------------------------------------------------------
    # Convenience accessors
    # ------------------------------------------------------------------
    @property
    def name(self) -> str:
        return self.config.name

    @property
    def resolution(self) -> tuple[int, int]:
        """Unbinned output shape, accounting for the configured detector ROI."""
        return self.config.output_resolution

    @property
    def sensor_resolution(self) -> tuple[int, int]:
        """Full physical detector shape before applying an ROI."""
        return self.config.resolution

    @property
    def roi(self) -> tuple[int, int, int, int] | None:
        """Configured ``(left, top, width, height)`` detector ROI, if any."""
        return self.config.roi

    @property
    def sensor_type(self) -> str:
        return self.config.sensor_type.value

    @property
    def device(self) -> str:
        """Execution device (``"cpu"`` or ``"gpu"``)."""
        return self._backend.device

    def with_config(self, **changes: Any) -> Camera:
        """Return a new camera with configuration fields overridden."""
        return Camera(
            self.config.replace(**changes),
            default_temperature_c=self.default_temperature_c,
            precision=self.precision,
            device=self.device,
        )

    # ------------------------------------------------------------------
    # Frame generation
    # ------------------------------------------------------------------
    def _resolve_rng(self, seed: int | None) -> Any:
        if seed is None:
            return self._rng
        if self._seeded_rng is not None:
            self._seeded_rng.seed(seed)
            return self._seeded_rng
        return self._backend.default_rng(seed)

    @staticmethod
    def _series_seeds(seed: int | None, n_frames: int) -> list[int | None]:
        """Derive ``n_frames`` independent-but-reproducible per-frame seeds.

        When ``seed`` is given, each frame gets a distinct seed spawned from a
        :class:`numpy.random.SeedSequence`, so the frames are statistically
        independent yet the whole series repeats exactly. When ``seed`` is ``None``,
        every frame draws from the camera's internal generator instead.
        """
        if n_frames < 1:
            raise ValueError("n_frames must be >= 1.")
        if seed is None:
            return [None] * n_frames
        ss = np.random.SeedSequence(seed)
        return [int(s.generate_state(1)[0]) for s in ss.spawn(n_frames)]

    def _full_detector_input(
        self,
        value: PhotonRate,
        name: str,
        *,
        workspace: noise.DetectorWorkspace | None = None,
    ) -> Any:
        """Embed a scalar or ROI-shaped input in the full physical detector grid."""
        if self.roi is None:
            return value
        array = self._backend.asarray(value, dtype=self._float_dtype)
        if array.ndim == 0 and isinstance(value, (int, float, np.number)) and float(value) == 0.0:
            return value
        if workspace is None:
            full = self._backend.xp.zeros(self.sensor_resolution, dtype=self._float_dtype)
        else:
            full = workspace._buffer(
                f"roi_{name}", self._backend, self.sensor_resolution, self._float_dtype
            )
            full.fill(0)
        if array.ndim == 0:
            full[self.config.roi_slices] = array
            return full
        if tuple(array.shape) != self.resolution:
            raise ValueError(
                f"{name} shape {tuple(array.shape)} does not match camera ROI "
                f"resolution {self.resolution}."
            )
        full[self.config.roi_slices] = array
        return full

    def _dark_signal(self, exposure: float, temperature: float) -> Any:
        """Return a cached full-detector dark expectation for this camera state."""
        key = (float(exposure), float(temperature))
        if self._dark_signal_cache_key != key or self._dark_signal_cache is None:
            self._dark_signal_cache = noise.dark_signal_map(
                self.config,
                exposure,
                temperature,
                backend=self._backend,
                float_dtype=self._float_dtype,
                fixed_patterns=self._fixed_patterns,
            )
            self._dark_signal_cache_key = key
        return self._dark_signal_cache

    def _binned_roi_slices(self, binning: int) -> tuple[slice, slice]:
        """Return ROI slices on a full-detector grid after native-pixel binning."""
        if binning < 1:
            raise ValueError("binning must be a positive integer.")
        if self.roi is None:
            return (
                slice(0, self.sensor_resolution[0] // binning),
                slice(0, self.sensor_resolution[1] // binning),
            )
        left, top, width, height = self.roi
        if any(value % binning for value in (left, top, width, height)):
            raise ValueError("binning must divide the ROI left, top, width, and height exactly.")
        return (
            slice(top // binning, (top + height) // binning),
            slice(left // binning, (left + width) // binning),
        )

    def _crop_to_roi(self, value: Any, binning: int = 1) -> Any:
        """Crop a full-detector array to the configured ROI."""
        if self.roi is None:
            return value
        return value[self._binned_roi_slices(binning)]

    def dark_frame(
        self,
        exposure: float,
        temperature: float | None = None,
        *,
        seed: int | None = None,
    ) -> Frame:
        """Generate a single dark frame.

        Parameters
        ----------
        exposure:
            Integration time in seconds.
        temperature:
            Sensor temperature in degrees Celsius. Defaults to the camera's
            :attr:`default_temperature_c`.
        seed:
            If given, use a fresh generator seeded with this value, producing a
            fully reproducible frame independent of prior calls. If omitted, the
            camera's internal generator advances.

        Returns
        -------
        Frame
            The simulated frame (ADU) with descriptive metadata.
        """
        temp = self.default_temperature_c if temperature is None else temperature
        rng = self._resolve_rng(seed)
        data = noise.generate_dark_frame(
            self.config,
            exposure,
            temp,
            rng=rng,
            backend=self._backend,
            fixed_patterns=self._fixed_patterns,
            _dark_signal=self._dark_signal(exposure, temp),
        )
        data = self._crop_to_roi(data)
        return Frame(data=data, metadata=self._metadata("dark", exposure, temp, seed))

    def dark_series(
        self,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
    ) -> Iterator[Frame]:
        """Yield ``n_frames`` independent dark frames (e.g. for building a master dark).

        When ``seed`` is given the series is reproducible; each frame uses a distinct
        derived seed so the frames are independent but the whole series is repeatable.
        """
        for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
            frame = self.dark_frame(exposure, temperature, seed=frame_seed)
            frame.metadata["frame_index"] = i
            yield frame

    def nondestructive_series(
        self,
        photon_rate: PhotonRate,
        read_interval: float,
        n_frames: int,
        reads_per_reset: int,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        quantum_efficiency: float | None = None,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Iterator[Frame]:
        """Yield correlated nondestructive reads separated by global resets.

        Newly collected photo- and dark electrons are sampled once per
        ``read_interval`` and accumulated in the pixel well. For an EMCCD/eAPD,
        each new increment passes through the stochastic gain stage once when it
        is collected; already accumulated charge is not re-multiplied on later
        reads. Reset noise is drawn once at the start of a ramp and is therefore
        common to every read in that ramp, while amplifier read noise is fresh on
        every read. This gives correlated double sampling and up-the-ramp fitting
        the correct temporal covariance.

        A reset occurs immediately before reads ``0, reads_per_reset, ...``. The
        first returned frame therefore contains one read interval of accumulated
        charge. Detector transport terms that are linear in charge (IPC) act on
        each collected increment; CCD-only transfer/blooming models are outside
        this hybrid-array readout path.

        Parameters
        ----------
        photon_rate:
            Incident photons/s/pixel, scalar or a map matching the active camera
            resolution.
        read_interval:
            Time between consecutive reads in seconds.
        n_frames:
            Total number of raw reads to return, across all ramps.
        reads_per_reset:
            Number of nondestructive reads between global resets.
        temperature:
            Detector temperature in degrees Celsius.
        background:
            Additive incident background in photons/s/pixel.
        quantum_efficiency:
            Optional scalar QE override.
        seed:
            Seed for the complete correlated sequence.
        include_truth:
            Attach the cumulative noise-free input-electron expectation.

        Yields
        ------
        Frame
            Raw digitised reads with ramp/read metadata.
        """
        if read_interval <= 0:
            raise ValueError("read_interval must be positive.")
        if n_frames < 1:
            raise ValueError("n_frames must be >= 1.")
        if reads_per_reset < 1:
            raise ValueError("reads_per_reset must be >= 1.")
        yield from self._ramp_reads(
            photon_rate,
            (read_interval,) * reads_per_reset,
            n_frames,
            temperature,
            background=background,
            quantum_efficiency=quantum_efficiency,
            seed=seed,
            include_truth=include_truth,
            caller="nondestructive_series",
        )

    def _ramp_reads(
        self,
        photon_rate: PhotonRate,
        read_intervals: Sequence[float],
        n_frames: int,
        temperature: float | None,
        *,
        background: PhotonRate,
        quantum_efficiency: float | None,
        seed: int | None,
        include_truth: bool,
        caller: str,
    ) -> Iterator[Frame]:
        """Yield global-reset ramp reads on an arbitrary per-read interval pattern.

        This is the single reset-correlated readout core.
        :meth:`nondestructive_series` drives it with a uniform cadence, and
        :meth:`correlated_double_sample` drives it with a short pedestal read
        followed by the integration read. ``read_intervals[i]`` is the time
        between read ``i - 1`` (or the reset, for ``i = 0``) and read ``i``, so
        ``len(read_intervals)`` is the ramp length in reads.

        Interval-dependent terms — the collected charge, the read-rate bias
        pedestal, the eAPD input-referred noise, and the gain-driven part of the
        common-mode sigma — are evaluated per read at that read's own interval,
        and cached per distinct interval so a long uniform ramp still holds only
        one set of detector-sized expectation maps.
        """
        if any(interval < 0 for interval in read_intervals):
            raise ValueError("read intervals must be non-negative.")
        if self.config.blooming or self.config.cti > 0:
            raise ValueError(f"{caller} does not support CCD blooming or CTI.")
        if self.config.nonlinearity > 0 or self.config.nonlinearity_coeffs is not None:
            raise ValueError(f"{caller} does not yet support detector nonlinearity.")

        reads_per_reset = len(read_intervals)
        temp = self.default_temperature_c if temperature is None else temperature
        rng = self._resolve_rng(seed)
        resolved = self._backend
        xp = resolved.xp
        dtype = self._float_dtype
        full_rate = self._full_detector_input(photon_rate, "photon_rate")
        full_background = self._full_detector_input(background, "background")

        # Cache the detector-sized expectation maps on the interval, not the read
        # index: a uniform ramp of any length keeps exactly one entry.
        mean_maps: dict[float, tuple[Any, Any]] = {}
        for interval in read_intervals:
            if interval in mean_maps:
                continue
            mean_photo = noise.photo_signal_map(
                self.config,
                full_rate,
                interval,
                full_background,
                quantum_efficiency,
                dtype,
                backend=resolved,
                fixed_patterns=self._fixed_patterns,
            )
            mean_maps[interval] = (mean_photo, mean_photo + self._dark_signal(interval, temp))

        excess_gain = max(self.config.em_gain - 1.0, 0.0)
        common_rho = self.config.readout_common_mode_correlation
        common_sigmas = [
            self.config.readout_common_mode_noise_adu
            + self.config.ndr_common_mode_gain_noise_adu_per_s * excess_gain * interval
            for interval in read_intervals
        ]
        common_innovations = [sigma * np.sqrt(1.0 - common_rho**2) for sigma in common_sigmas]
        interval_biases = [
            interval
            * (
                self.config.ndr_bias_offset_adu_per_s
                + self.config.ndr_bias_gain_coefficient_adu_per_s * excess_gain
            )
            for interval in read_intervals
        ]
        avalanche_input_noises = [
            self.config.avalanche_input_noise_e
            * (interval / self.config.ndr_avalanche_input_noise_reference_interval_s)
            ** self.config.ndr_avalanche_input_noise_interval_exponent
            for interval in read_intervals
        ]
        settles = [
            (
                self.config.ndr_reset_settling_input_e
                * self.config.em_gain
                / self.config.gain_e_per_adu
                * (interval / self.config.ndr_reset_settling_reference_interval_s)
                ** self.config.ndr_reset_settling_interval_exponent
                * np.exp(-read_index / self.config.ndr_reset_settling_scale_reads)
                if self.config.ndr_reset_settling_input_e > 0
                and self.config.ndr_reset_settling_scale_reads > 0
                else 0.0
            )
            for read_index, interval in enumerate(read_intervals)
        ]
        elapsed_times = list(np.cumsum(np.asarray(read_intervals, dtype=np.float64)))

        accumulated_input = xp.zeros(self.sensor_resolution, dtype=dtype)
        accumulated_output = xp.zeros(self.sensor_resolution, dtype=dtype)
        cumulative_mean: Any = None
        cumulative_photo: Any = None
        if include_truth:
            cumulative_mean = xp.zeros(self.sensor_resolution, dtype=dtype)
            cumulative_photo = xp.zeros(self.sensor_resolution, dtype=dtype)
        reset_noise: Any = 0.0
        correlated_read_noise: Any = None
        common_mode = 0.0
        correlated_fraction = self.config.read_noise_correlated_fraction
        read_noise_sigma = (
            self._fixed_patterns.read_noise_sigma if self.config.read_noise_e > 0 else None
        )

        for frame_index in range(n_frames):
            read_index = frame_index % reads_per_reset
            ramp_index = frame_index // reads_per_reset
            read_interval = read_intervals[read_index]
            mean_photo_increment, mean_increment = mean_maps[read_interval]
            if read_index == 0:
                accumulated_input.fill(0)
                accumulated_output.fill(0)
                if include_truth:
                    cumulative_mean.fill(0)
                    cumulative_photo.fill(0)
                if self.config.reset_noise_e > 0:
                    reset_noise = rng.normal(
                        0.0,
                        self.config.reset_noise_e,
                        size=self.sensor_resolution,
                    ).astype(dtype, copy=False)
                else:
                    reset_noise = 0.0
                # One draw per ramp, so every read of the ramp carries the same
                # correlated read-noise pattern and a difference of two reads
                # cancels it. This is the part of read noise that CDS removes.
                if correlated_fraction > 0.0 and read_noise_sigma is not None:
                    correlated_read_noise = (
                        rng.normal(0.0, 1.0, size=self.sensor_resolution).astype(dtype, copy=False)
                        * read_noise_sigma
                        * np.sqrt(correlated_fraction)
                    )

            increment = rng.poisson(mean_increment).astype(dtype, copy=False)
            if self.config.cosmic_ray_rate_per_cm2_s > 0:
                increment = noise.add_cosmic_rays(
                    increment,
                    self.config,
                    read_interval,
                    rng,
                    backend=resolved,
                )
            room = xp.maximum(self.config.full_well_e - accumulated_input, 0.0)
            xp.minimum(increment, room, out=increment)
            accumulated_input += increment
            if self.config.ipc_coupling > 0:
                increment = noise.apply_ipc(increment, self.config.ipc_coupling, backend=resolved)
            if self.config.has_gain_stage:
                increment = noise.apply_gain_stage(
                    increment,
                    self.config.em_gain,
                    self.config.gain_excess_noise_factor,
                    rng,
                    backend=resolved,
                )
                increment *= self._fixed_patterns.avalanche_gain_multiplier
            accumulated_output += increment

            if common_sigmas[read_index] > 0:
                if frame_index == 0:
                    common_mode = float(resolved.scalar(rng.normal(0.0, common_sigmas[read_index])))
                else:
                    innovation = float(
                        resolved.scalar(rng.normal(0.0, common_innovations[read_index]))
                    )
                    common_mode = common_rho * common_mode + innovation

            raw = noise.digitize(
                xp.array(accumulated_output, copy=True),
                self.config,
                rng,
                backend=resolved,
                fixed_patterns=self._fixed_patterns,
                reset_noise_e=reset_noise,
                correlated_read_noise_e=correlated_read_noise,
                common_mode_adu=common_mode + interval_biases[read_index] - settles[read_index],
                avalanche_input_noise_e=avalanche_input_noises[read_index],
            )
            data = self._crop_to_roi(raw)
            elapsed = float(elapsed_times[read_index])
            truth = None
            if include_truth:
                cumulative_mean += mean_increment
                cumulative_photo += mean_photo_increment
                truth = FrameTruth(
                    mean_electrons=self._crop_to_roi(xp.array(cumulative_mean, copy=True)),
                    mean_photoelectrons=self._crop_to_roi(xp.array(cumulative_photo, copy=True)),
                    photon_rate=photon_rate,
                )
            metadata = self._metadata("nondestructive", elapsed, temp, seed)
            metadata.update(
                {
                    "frame_index": frame_index,
                    "ramp_index": ramp_index,
                    "read_index": read_index,
                    "reads_per_reset": reads_per_reset,
                    "read_interval_s": read_interval,
                    "time_since_reset_s": elapsed,
                    "readout_mode": "global_reset_nondestructive",
                }
            )
            yield Frame(data=data, metadata=metadata, truth=truth)

    def dark_nondestructive_series(
        self,
        read_interval: float,
        n_frames: int,
        reads_per_reset: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Iterator[Frame]:
        """Yield global-reset nondestructive dark reads.

        This is :meth:`nondestructive_series` with zero incident photon rate.
        """
        return self.nondestructive_series(
            0.0,
            read_interval,
            n_frames,
            reads_per_reset,
            temperature,
            seed=seed,
            include_truth=include_truth,
        )

    def correlated_double_sample(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        quantum_efficiency: float | None = None,
        pedestal_interval_s: float = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """Read the sensor in correlated double sampling and return the difference.

        CDS is the standard low-noise operating mode of a nondestructive-readout
        hybrid array such as the SAPHIRA in a C-RED One. The pixel is globally
        reset, read once to record the reset pedestal, integrated for
        ``exposure``, and read again; the reported value is the difference of
        the two reads. This is one ramp of :meth:`nondestructive_series` with
        two reads, differenced, and it uses that same reset-correlated core.

        What the differencing does and does not remove follows from which terms
        are common to the two reads:

        - **Removed.** kTC/reset noise (``reset_noise_e``), drawn once per ramp,
          and the fixed bias structure — pedestal, per-channel and per-pixel
          offsets, and edge structure — which is a property of the silicon and
          identical in both reads.
        - **Amplified.** Amplifier read noise is redrawn per read, so the
          difference carries ``sqrt(2) * read_noise_e``. The eAPD
          input-referred noise likewise adds in quadrature across the two reads.
        - **Partly removed.** Readout common mode is an AR(1) sequence with
          correlation ``readout_common_mode_correlation``, so the difference
          retains the ``sqrt(2 * (1 - rho))`` fraction of it rather than all or
          none. Reset settling is read-index dependent and therefore leaves the
          residual between its value at the pedestal and signal reads, which is
          the physical CDS pedestal artifact rather than a modelling shortcut.
        - **Not removed.** The interval-proportional bias rate
          (``ndr_bias_offset_adu_per_s`` and
          ``ndr_bias_gain_coefficient_adu_per_s``) scales with collected
          integration time, not with the read operation, so it survives
          differencing in full. A CDS frame therefore still sits on a small
          exposure-dependent pedestal — for the C-RED One preset at 1750 Hz,
          about ``+50 ADU`` of bias rate against ``-8 ADU`` of settling
          residual. Subtract it with a dark CDS frame at the same exposure and
          gain, exactly as on the real camera.

        Charge is collected only between the two reads, so the well holds one
        ``exposure`` worth of signal and full-well clipping happens at the
        intended level.

        Parameters
        ----------
        photon_rate:
            Incident photons/s/pixel, scalar or a map matching the active camera
            resolution.
        exposure:
            Integration time *between* the pedestal and signal reads, in
            seconds. This is the charge the difference measures, so it is
            independent of ``pedestal_interval_s``.
        temperature:
            Detector temperature in degrees Celsius. Defaults to
            :attr:`default_temperature_c`.
        background:
            Additive incident background in photons/s/pixel.
        quantum_efficiency:
            Optional scalar QE override.
        pedestal_interval_s:
            Reset-to-pedestal-read time in seconds. ``0.0`` (the default) models
            a pedestal read taken immediately after the reset, collecting no
            charge. Set it to the camera's real reset-to-read delay when that
            delay is a significant fraction of ``exposure``. It does not change
            the measured signal; it sets the interval the pedestal read's own
            terms are evaluated at — the bias rate, the eAPD input-referred
            noise, and the gain-driven common-mode sigma — and it adds that much
            charge to the well before the signal read.
        seed:
            Seed for the complete two-read sequence.
        include_truth:
            Attach the noise-free electron expectation *of the difference*, i.e.
            the charge collected during ``exposure``.

        Returns
        -------
        Frame
            Signed difference frame in ADU (``int32``). Unlike a single raw
            read it is bias-subtracted by construction and may go negative on a
            dark pixel.
        """
        if exposure <= 0:
            raise ValueError("exposure must be positive.")
        if pedestal_interval_s < 0:
            raise ValueError("pedestal_interval_s must be non-negative.")
        if pedestal_interval_s == exposure:
            raise ValueError(
                "pedestal_interval_s must differ from exposure so the two reads "
                "are distinguishable; use nondestructive_series for a uniform ramp."
            )
        pedestal, signal = self._ramp_reads(
            photon_rate,
            (pedestal_interval_s, exposure),
            2,
            temperature,
            background=background,
            quantum_efficiency=quantum_efficiency,
            seed=seed,
            include_truth=include_truth,
            caller="correlated_double_sample",
        )
        xp = self._backend.xp
        data = signal.data.astype(xp.int32) - pedestal.data.astype(xp.int32)
        truth = None
        if include_truth and signal.truth is not None and pedestal.truth is not None:
            truth = FrameTruth(
                mean_electrons=signal.truth.mean_electrons - pedestal.truth.mean_electrons,
                mean_photoelectrons=(
                    signal.truth.mean_photoelectrons - pedestal.truth.mean_photoelectrons
                ),
                photon_rate=photon_rate,
            )
        temp = self.default_temperature_c if temperature is None else temperature
        metadata = self._metadata("correlated_double_sample", exposure, temp, seed)
        metadata.update(
            {
                "readout_mode": "global_reset_cds",
                "exposure_s": exposure,
                "pedestal_interval_s": pedestal_interval_s,
                "reads_per_reset": 2,
            }
        )
        return Frame(data=data, metadata=metadata, truth=truth)

    def expose(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        quantum_efficiency: float | None = None,
        extra_electrons: PhotonRate = 0.0,
        binning: int = 1,
        binning_mode: str = "digital",
        seed: int | None = None,
        include_truth: bool = True,
        workspace: DetectorWorkspace | None = None,
        out: Any | None = None,
    ) -> Frame:
        """Expose the sensor to an incident photon rate and return a frame.

        This is the general signal path; :meth:`dark_frame`, :meth:`flat_frame`,
        and :meth:`bias_frame` are convenience wrappers around it.

        Parameters
        ----------
        photon_rate:
            Incident photon rate in photons/s/pixel, as a scalar (uniform
            illumination) or a 2-D array matching :attr:`resolution`.
        exposure:
            Integration time in seconds.
        temperature:
            Sensor temperature in degrees Celsius. Defaults to
            :attr:`default_temperature_c`.
        background:
            Additive background (sky/thermal) photon rate in photons/s/pixel.
        quantum_efficiency:
            Overrides the config's scalar QE for this exposure. Spectral mode uses
            this with an already-photoelectron map and ``1.0``; most callers leave
            it ``None``.
        extra_electrons:
            Additive noise-free signal in electrons (scalar or array) injected
            before shot noise. Used by :meth:`observe_series` to carry latent charge
            from image persistence; most callers leave it ``0.0``.
        binning:
            Combine ``binning x binning`` native pixels into each output pixel
            (``1`` = no binning). :attr:`resolution` is the native grid and must be
            divisible by ``binning``; the returned frame is ``resolution // binning``.
        binning_mode:
            ``"digital"`` (post-read software binning, the default) reads each native
            pixel with its own read noise then sums the digitised values, so binned
            read noise grows as ``binning``. ``"on_chip"`` (pre-read charge-domain /
            hardware binning) sums the charge before the amplifier, so one read noise
            is applied per super-pixel. See :func:`getframes.noise.simulate_frame`.
        seed:
            If given, use a fresh generator seeded with this value for a fully
            reproducible frame.
        include_truth:
            If ``True`` (default), attach the noise-free ground truth to the
            returned :class:`~getframes.frame.Frame` for pipeline validation.
        workspace:
            Optional reusable :class:`~getframes.noise.DetectorWorkspace` for
            private detector and full-grid ROI scratch. It is sequential-use only;
            returned arrays do not alias it.
        out:
            Optional C-contiguous, writable backend-native ``uint32`` array with
            the returned frame shape. The frame uses this exact caller-owned
            storage; do not overwrite it while a consumer still needs the frame.
        """
        if workspace is not None:
            with workspace._using(self._backend, self.sensor_resolution, self._float_dtype):
                return self._expose_into(
                    photon_rate,
                    exposure,
                    temperature,
                    background=background,
                    quantum_efficiency=quantum_efficiency,
                    extra_electrons=extra_electrons,
                    binning=binning,
                    binning_mode=binning_mode,
                    seed=seed,
                    include_truth=include_truth,
                    workspace=workspace,
                    out=out,
                )
        return self._expose_into(
            photon_rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=quantum_efficiency,
            extra_electrons=extra_electrons,
            binning=binning,
            binning_mode=binning_mode,
            seed=seed,
            include_truth=include_truth,
            workspace=None,
            out=out,
        )

    def _expose_into(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        temperature: float | None,
        *,
        background: PhotonRate,
        quantum_efficiency: float | None,
        extra_electrons: PhotonRate,
        binning: int,
        binning_mode: str,
        seed: int | None,
        include_truth: bool,
        workspace: noise.DetectorWorkspace | None,
        out: Any | None,
    ) -> Frame:
        """Execute one exposure with an already-claimed optional workspace."""
        temp = self.default_temperature_c if temperature is None else temperature
        rng = self._resolve_rng(seed)
        self._binned_roi_slices(binning)
        output_shape = (self.resolution[0] // binning, self.resolution[1] // binning)
        if out is not None:
            noise._validate_output_buffer(out, self._backend, output_shape)

        detector_out = out if self.roi is None else None
        output_slices: tuple[slice, slice] | None = None
        direct_roi_output = self.roi is not None and workspace is not None and binning == 1
        if direct_roi_output:
            detector_out = (
                out if out is not None else self._backend.xp.empty(output_shape, dtype=np.uint32)
            )
            output_slices = self._binned_roi_slices(binning)
        elif self.roi is not None and workspace is not None:
            detector_shape = (
                self.sensor_resolution[0] // binning,
                self.sensor_resolution[1] // binning,
            )
            detector_out = workspace._buffer("full_adu", self._backend, detector_shape, np.uint32)
        result = noise.simulate_frame(
            self.config,
            self._full_detector_input(photon_rate, "photon_rate", workspace=workspace),
            exposure,
            temperature_c=temp,
            background_photon_rate=self._full_detector_input(
                background, "background", workspace=workspace
            ),
            quantum_efficiency=quantum_efficiency,
            extra_electrons=self._full_detector_input(
                extra_electrons, "extra_electrons", workspace=workspace
            ),
            binning=binning,
            binning_mode=binning_mode,
            rng=rng,
            float_dtype=self._float_dtype,
            backend=self._backend,
            fixed_patterns=self._fixed_patterns,
            _dark_signal=self._dark_signal(exposure, temp),
            workspace=workspace,
            out=detector_out,
            _workspace_claimed=workspace is not None,
            _preserve_truth=include_truth,
            _output_slices=output_slices,
            _out_validated=out is not None or direct_roi_output,
        )
        cropped_adu = result.adu if direct_roi_output else self._crop_to_roi(result.adu, binning)
        if self.roi is not None and out is not None:
            out[...] = cropped_adu
            cropped_adu = out
        elif self.roi is not None and workspace is not None:
            # The full-frame destination is workspace-owned; copy only the ROI so
            # a returned Frame remains stable across the next workspace use.
            cropped_adu = self._backend.xp.array(cropped_adu, copy=True)
        result = noise.SimulationResult(
            cropped_adu,
            self._crop_to_roi(result.mean_photoelectrons, binning),
            self._crop_to_roi(result.mean_dark_electrons, binning),
            photon_rate,
        )
        truth = (
            FrameTruth(
                mean_electrons=result.mean_photoelectrons
                + result.mean_dark_electrons
                + self._binned_extra(extra_electrons, binning),
                mean_photoelectrons=result.mean_photoelectrons,
                photon_rate=result.photon_rate,
            )
            if include_truth
            else None
        )
        metadata = self._metadata("light", exposure, temp, seed)
        if binning > 1:
            metadata["binning"] = binning
            metadata["binning_mode"] = binning_mode
        return Frame(data=result.adu, metadata=metadata, truth=truth)

    def expose_spectral(
        self,
        photon_rate_cube: NDArray[np.floating[Any]],
        wavelengths_nm: NDArray[np.floating[Any]],
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        seed: int | None = None,
        binning: int = 1,
        binning_mode: str = "digital",
        include_truth: bool = True,
        workspace: DetectorWorkspace | None = None,
        out: Any | None = None,
    ) -> Frame:
        """Expose a wavelength-resolved photon-rate cube.

        ``photon_rate_cube`` is incident photons/s/native pixel with shape
        ``(n_wavelength, height, width)``. The configured :class:`~getframes.spectral.QE`
        is evaluated at each node and applied before the ordinary detector signal
        chain. The detector stochastic model is therefore executed exactly once;
        ``FrameTruth.mean_photoelectrons`` contains the QE-weighted result while
        ``FrameTruth.photon_rate`` retains the integrated incident photon map.

        This method is separate from :meth:`expose` so scalar callers remain
        unchanged and callers cannot accidentally apply QE twice. A configured
        ``qe_curve`` is required. ``workspace`` and ``out`` have the same
        reusable-scratch and caller-owned-lifetime contracts as :meth:`expose`.
        """
        cube, wavelengths_host, electron_rate, integrated_rate = self._fold_spectral_cube(
            photon_rate_cube, wavelengths_nm, "expose_spectral"
        )
        frame = self.expose(
            electron_rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=1.0,
            binning=binning,
            binning_mode=binning_mode,
            seed=seed,
            include_truth=include_truth,
            workspace=workspace,
            out=out,
        )
        frame.metadata["spectral"] = True
        frame.metadata["spectral_wavelengths_nm"] = [float(value) for value in wavelengths_host]
        if frame.truth is not None:
            frame = replace(
                frame,
                truth=replace(
                    frame.truth,
                    photon_rate=integrated_rate,
                    spectral_photon_rate=cube,
                    wavelengths_nm=self._backend.asarray(wavelengths_host, dtype=np.float64),
                ),
            )
        return frame

    def _fold_spectral_cube(
        self,
        photon_rate_cube: NDArray[np.floating[Any]],
        wavelengths_nm: NDArray[np.floating[Any]],
        caller: str,
    ) -> tuple[Any, NDArray[np.float64], Any, Any]:
        """Validate a spectral cube and fold the configured QE through it once.

        Returns the backend cube, the host wavelength nodes, the QE-weighted
        photoelectron rate map, and the wavelength-integrated incident photon
        rate. Both spectral entry points share this so a cube can never have QE
        applied twice, or applied differently between readout modes.
        """
        if self.config.qe_curve is None:
            raise ValueError(f"{caller} requires CameraConfig.qe_curve")
        xp = self._backend.xp
        cube = self._backend.asarray(photon_rate_cube, dtype=self._float_dtype)
        wavelengths_host = np.asarray(self._backend.to_numpy(wavelengths_nm), dtype=np.float64)
        if cube.ndim != 3:
            raise ValueError("photon_rate_cube must have shape (wavelength, height, width)")
        if cube.shape[1:] != self.resolution:
            raise ValueError(
                f"photon_rate_cube spatial shape {cube.shape[1:]} does not match camera "
                f"resolution {self.resolution}"
            )
        if wavelengths_host.ndim != 1 or wavelengths_host.size != cube.shape[0]:
            raise ValueError("wavelengths_nm must be a 1-D array matching cube axis 0")
        if wavelengths_host.size == 0 or not np.all(np.isfinite(wavelengths_host)):
            raise ValueError("wavelengths_nm must contain finite values")
        if not bool(self._backend.scalar(xp.all(xp.isfinite(cube)))) or bool(
            self._backend.scalar(xp.any(cube < 0))
        ):
            raise ValueError("photon_rate_cube must be finite and non-negative")
        qe = self._backend.asarray(self.config.qe_curve(wavelengths_host), dtype=self._float_dtype)
        return cube, wavelengths_host, xp.tensordot(qe, cube, axes=(0, 0)), xp.sum(cube, axis=0)

    def correlated_double_sample_spectral(
        self,
        photon_rate_cube: NDArray[np.floating[Any]],
        wavelengths_nm: NDArray[np.floating[Any]],
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        pedestal_interval_s: float = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """Read a wavelength-resolved photon-rate cube in correlated double sampling.

        This is :meth:`correlated_double_sample` for the spectral path, and
        stands in the same relation to it as :meth:`expose_spectral` does to
        :meth:`expose`: the configured :class:`~getframes.spectral.QE` is
        evaluated at each wavelength node and folded in before the detector
        signal chain runs once, so callers cannot apply QE twice.

        ``photon_rate_cube`` is incident photons/s/native pixel with shape
        ``(n_wavelength, height, width)``. A configured ``qe_curve`` is required.
        The return is the signed ``int32`` ADU difference described in
        :meth:`correlated_double_sample`.
        """
        cube, wavelengths_host, electron_rate, integrated_rate = self._fold_spectral_cube(
            photon_rate_cube, wavelengths_nm, "correlated_double_sample_spectral"
        )
        frame = self.correlated_double_sample(
            electron_rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=1.0,
            pedestal_interval_s=pedestal_interval_s,
            seed=seed,
            include_truth=include_truth,
        )
        frame.metadata["spectral"] = True
        frame.metadata["spectral_wavelengths_nm"] = [float(value) for value in wavelengths_host]
        if frame.truth is not None:
            frame = replace(
                frame,
                truth=replace(
                    frame.truth,
                    photon_rate=integrated_rate,
                    spectral_photon_rate=cube,
                    wavelengths_nm=self._backend.asarray(wavelengths_host, dtype=np.float64),
                ),
            )
        return frame

    def _binned_extra(self, extra_electrons: PhotonRate, binning: int) -> Any:
        """The ``extra_electrons`` truth term summed to match a binned frame's grid."""
        extra = self._backend.asarray(extra_electrons, dtype=self._float_dtype)
        if binning == 1:
            return extra
        if extra.ndim == 0:
            return extra * float(binning * binning)
        return noise.block_sum(extra, binning)

    def flat_frame(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """A uniformly (or per-pixel) illuminated flat-field frame.

        Equivalent to :meth:`expose`; provided as a named entry point for
        flat-field/photon-transfer workflows. Pass a scalar ``photon_rate`` for a
        uniform flat.
        """
        frame = self.expose(
            photon_rate,
            exposure,
            temperature,
            background=background,
            seed=seed,
            include_truth=include_truth,
        )
        frame.metadata["frame_type"] = "flat"
        return frame

    def bias_frame(
        self,
        temperature: float | None = None,
        *,
        seed: int | None = None,
    ) -> Frame:
        """A zero-exposure bias frame (bias pedestal + read noise only)."""
        frame = self.expose(0.0, 0.0, temperature, seed=seed, include_truth=False)
        frame.metadata["frame_type"] = "bias"
        return frame

    def observe(
        self,
        scene: Scene,
        exposure: float,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Frame:
        """Observe a :class:`~getframes.scene.Scene` and return a science frame.

        Renders the scene to an incident photon-rate map, then exposes the sensor
        to it (adding the scene's sky as a uniform background). The scene's
        ``shape`` must match this camera's :attr:`resolution`.

        **Spectral mode** activates automatically when this camera's config has a
        :attr:`~getframes.config.CameraConfig.qe_curve` *and* the scene's band
        carries a spectral response: each source then gets a colour-dependent
        effective QE from its SED, instead of the scalar ``quantum_efficiency``.
        """
        if tuple(scene.shape) != self.resolution:
            raise ValueError(
                f"scene.shape {tuple(scene.shape)} does not match camera "
                f"resolution {self.resolution}."
            )
        rate, background, qe, spectral = self._scene_inputs(scene)
        frame = self.expose(
            rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=qe,
            seed=seed,
            include_truth=include_truth,
        )
        self._tag_science(frame, scene, spectral)
        return frame

    def _scene_inputs(
        self,
        scene: Scene,
        time_s: float | None = None,
        offset_xy: tuple[float, float] = (0.0, 0.0),
    ) -> tuple[PhotonRate, PhotonRate, float | None, bool]:
        """Render a scene to the ``(rate, background, qe, spectral)`` :meth:`expose` inputs.

        Selects spectral mode when the config carries a QE curve and the scene's
        band has a spectral response. ``time_s`` and ``offset_xy`` thread the
        per-frame time and pointing offset through to the scene renderer.
        """
        spectral = self.config.qe_curve is not None and scene.is_spectral_capable
        dtype = self._float_dtype
        if spectral:
            assert self.config.qe_curve is not None  # narrowed by `spectral`
            rate = scene.photoelectron_rate_map(self.config.qe_curve, time_s, offset_xy, dtype)
            return rate, scene.background_electron_rate(self.config.qe_curve), 1.0, True
        rate = scene.photon_rate_map(time_s, offset_xy, dtype)
        return rate, scene.background_photon_rate(), None, False

    @staticmethod
    def _tag_science(frame: Frame, scene: Scene, spectral: bool) -> None:
        """Stamp the shared science-frame metadata (frame type, spectral flag, WCS)."""
        frame.metadata["frame_type"] = "science"
        frame.metadata["spectral"] = spectral
        if scene.wcs is not None:
            frame.metadata.update(scene.wcs.header_cards())

    def expose_series(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        quantum_efficiency: float | None = None,
        binning: int = 1,
        binning_mode: str = "digital",
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Iterator[Frame]:
        """Yield ``n_frames`` independent illuminated frames (the :meth:`expose` series).

        The light-frame analogue of :meth:`dark_series`. When ``seed`` is given the
        series is reproducible; each frame uses a distinct derived seed so the
        frames are independent but the whole series repeats. ``quantum_efficiency``
        has the same meaning as in :meth:`expose`; pass ``1.0`` when
        ``photon_rate`` and ``background`` are already expressed as electron rates.
        ``binning`` and ``binning_mode`` are passed through to :meth:`expose`, so a
        calibration series bins exactly as its science frames do.
        """
        for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
            frame = self.expose(
                photon_rate,
                exposure,
                temperature,
                background=background,
                quantum_efficiency=quantum_efficiency,
                binning=binning,
                binning_mode=binning_mode,
                seed=frame_seed,
                include_truth=include_truth,
            )
            frame.metadata["frame_index"] = i
            yield frame

    def observe_series(
        self,
        scene: Scene,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        cadence: float | None = None,
        pointing: Pointing | None = None,
        jitter_arcsec: float = 0.0,
        seed: int | None = None,
        include_truth: bool = True,
    ) -> Observation:
        """Observe ``scene`` over time, returning a reproducible :class:`Observation`.

        Produces a time-ordered stack of science frames. Frame ``i`` is exposed at
        timestamp ``t_i = i * cadence`` (the start of its exposure); sources
        carrying a :class:`~getframes.scene.sources.LightCurve` vary accordingly,
        and a :class:`~getframes.observation.Pointing` model shifts the field per
        frame. If the detector has a non-zero
        :attr:`~getframes.config.CameraConfig.persistence_fraction`, latent charge
        is carried across frames.

        The returned :class:`Observation` is iterable over its frames (so
        ``for f in cam.observe_series(...)`` still works) and carries the per-frame
        timestamps, realised pointing offsets, and the ground-truth light curve.

        Parameters
        ----------
        scene, exposure, temperature:
            As in :meth:`observe`.
        n_frames:
            Number of frames in the series.
        cadence:
            Seconds between successive frame start times. Defaults to ``exposure``
            (back-to-back frames with no dead time).
        pointing:
            A :class:`~getframes.observation.Pointing` model for per-frame field
            offsets. If ``None`` and ``jitter_arcsec`` is given, a jitter-only model
            is built from it.
        jitter_arcsec:
            Convenience for the common case: the RMS of a per-frame Gaussian
            pointing jitter (ignored if ``pointing`` is given explicitly).
        seed:
            When given, the series is reproducible; each frame draws a distinct
            derived seed (independent frames) and the pointing jitter uses its own
            derived stream, so the whole observation repeats exactly.
        include_truth:
            Whether to attach per-frame :class:`~getframes.frame.FrameTruth` and
            build the observation's light-curve truth.
        """
        cadence = exposure if cadence is None else cadence
        if pointing is None and jitter_arcsec > 0:
            pointing = Pointing(jitter_arcsec=jitter_arcsec)
        plate_scale = scene.optics.plate_scale_arcsec_per_pixel

        frame_seeds = self._series_seeds(seed, n_frames)
        # A pointing stream independent of the per-frame shot/read-noise seeds, so
        # jitter is reproducible without coupling to (or perturbing) frame noise.
        point_seq = None if seed is None else np.random.SeedSequence([int(seed), _POINTING_STREAM])
        point_rng = np.random.default_rng(point_seq)

        names = self._source_names(scene.sources)
        light_curve: dict[str, list[float]] = {name: [] for name in names}
        frames: list[Frame] = []
        times: list[float] = []
        offsets: list[tuple[float, float]] = []
        latent: PhotonRate = 0.0  # trapped charge (electrons) carried across frames

        for i, frame_seed in enumerate(frame_seeds):
            t = i * cadence
            offset = (0.0, 0.0)
            if pointing is not None and not pointing.is_static:
                offset = pointing.offset_pixels(i, t, plate_scale, point_rng)

            rate, background, qe, spectral = self._scene_inputs(scene, t, offset)
            extra = self.config.persistence_decay * latent if self._has_persistence else 0.0
            frame = self.expose(
                rate,
                exposure,
                temperature,
                background=background,
                quantum_efficiency=qe,
                extra_electrons=extra,
                seed=frame_seed,
                include_truth=include_truth,
            )
            self._tag_science(frame, scene, spectral)
            frame.metadata["frame_index"] = i
            frame.metadata["time_s"] = t
            frame.metadata["pointing_offset_px"] = offset

            if self._has_persistence:
                latent = self._update_latent(latent, extra, rate, exposure, background, qe)
            if include_truth:
                for name, source in zip(names, scene.sources):
                    light_curve[name].append(scene._source_photon_rate(source, t) * exposure)

            frames.append(frame)
            times.append(t)
            offsets.append(offset)

        truth = (
            ObservationTruth(
                times_s=np.asarray(times, dtype=np.float64),
                light_curve={k: np.asarray(v, dtype=np.float64) for k, v in light_curve.items()},
            )
            if include_truth
            else None
        )
        return Observation(
            frames=frames,
            times_s=np.asarray(times, dtype=np.float64),
            offsets_pixels=np.asarray(offsets, dtype=np.float64).reshape(n_frames, 2),
            truth=truth,
        )

    @property
    def _has_persistence(self) -> bool:
        return self.config.persistence_fraction > 0.0

    def _update_latent(
        self,
        latent: PhotonRate,
        released: PhotonRate,
        rate: PhotonRate,
        exposure: float,
        background: PhotonRate,
        qe: float | None,
    ) -> Any:
        """Advance the latent-charge state after a frame.

        Trapped charge releases ``persistence_decay`` of itself into the frame just
        exposed (``released``) and captures ``persistence_fraction`` of that frame's
        noise-free photo-signal. The remainder stays trapped for the next frame.
        """
        signal = noise.photo_signal_map(
            self.config,
            self._full_detector_input(rate, "photon_rate"),
            exposure,
            self._full_detector_input(background, "background"),
            qe,
            backend=self._backend,
            fixed_patterns=self._fixed_patterns,
        )
        signal = self._crop_to_roi(signal)
        captured = self.config.persistence_fraction * signal
        latent_arr = self._backend.asarray(latent, dtype=np.float64)
        released_arr = self._backend.asarray(released, dtype=np.float64)
        updated = latent_arr - released_arr + captured
        return updated

    @staticmethod
    def _source_names(sources: Sequence[Source]) -> list[str]:
        """A stable, unique name per source (falling back to ``source_{i}``)."""
        names: list[str] = []
        seen: set[str] = set()
        for i, source in enumerate(sources):
            name = source.name if source.name is not None else f"source_{i}"
            if name in seen:
                name = f"{name}_{i}"
            seen.add(name)
            names.append(name)
        return names

    # ------------------------------------------------------------------
    # Calibration masters
    # ------------------------------------------------------------------
    def master_bias(
        self,
        n_frames: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        method: str = "median",
    ) -> Frame:
        """Combine ``n_frames`` bias frames into a master bias (see :func:`getframes.combine`)."""
        from .calibrate import combine

        frames = (self.bias_frame(temperature, seed=s) for s in self._series_seeds(seed, n_frames))
        return combine(frames, method=method)

    def master_dark(
        self,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        seed: int | None = None,
        method: str = "median",
    ) -> Frame:
        """Combine ``n_frames`` dark frames into a master dark.

        The result still contains the bias pedestal, so it is subtracted directly
        from an exposure-matched science frame (``calibrate(sci, dark=master)``).
        """
        from .calibrate import combine

        return combine(self.dark_series(exposure, n_frames, temperature, seed=seed), method=method)

    def master_flat(
        self,
        photon_rate: PhotonRate,
        exposure: float,
        n_frames: int,
        temperature: float | None = None,
        *,
        background: PhotonRate = 0.0,
        bias: Frame | NDArray[np.floating[Any]] | None = None,
        seed: int | None = None,
        method: str = "median",
    ) -> Frame:
        """Combine ``n_frames`` flat frames into a master flat.

        If ``bias`` is given it is subtracted, yielding a pedestal-free flat whose
        pixel-to-pixel structure is the detector's response — the form
        :func:`getframes.calibrate` expects to normalise and divide by.
        """
        from .calibrate import combine

        frames = self.expose_series(
            photon_rate,
            exposure,
            n_frames,
            temperature,
            background=background,
            seed=seed,
            include_truth=False,
        )
        master = combine(frames, method=method)
        if bias is None:
            return master
        data = np.asarray(master.data, dtype=np.float64) - np.asarray(bias, dtype=np.float64)
        metadata = {**master.metadata, "bias_subtracted": True}
        return Frame(data=data, metadata=metadata)

    def _metadata(
        self, frame_type: str, exposure: float, temperature: float, seed: int | None
    ) -> dict[str, Any]:
        metadata: dict[str, Any] = {
            "camera": self.config.name,
            "sensor": self.config.sensor_type.value,
            "frame_type": frame_type,
            "exposure_s": exposure,
            "temperature_c": temperature,
            "dark_e_per_s": self.config.dark_current_at(temperature),
            "read_noise_e": self.config.read_noise_e,
            "gain_e_per_adu": self.config.gain_e_per_adu,
            "em_gain": self.config.em_gain,
            "device": self.device,
            "seed": seed,
        }
        if self.config.charge_diffusion_fwhm_px > 0:
            # This entry point receives an already pixel-integrated photon rate,
            # so a sub-pixel diffusion width cannot be represented here. Record
            # that the configured sensor property was left to the caller's
            # oversampled optical model rather than dropping it silently.
            metadata["charge_diffusion_fwhm_px"] = self.config.charge_diffusion_fwhm_px
            metadata["charge_diffusion_applied"] = False
        if self.roi is not None:
            metadata["detector_roi"] = self.roi
            metadata["sensor_resolution"] = self.sensor_resolution
        return metadata

    def __repr__(self) -> str:
        h, w = self.resolution
        return (
            f"Camera(name={self.config.name!r}, sensor={self.config.sensor_type.value!r}, "
            f"resolution={h}x{w}, device={self.device!r})"
        )

resolution property

Unbinned output shape, accounting for the configured detector ROI.

sensor_resolution property

Full physical detector shape before applying an ROI.

roi property

Configured (left, top, width, height) detector ROI, if any.

device property

Execution device ("cpu" or "gpu").

from_preset(name, **kwargs) classmethod

Create a camera from a built-in preset (see :func:getframes.available_presets).

Source code in src/getframes/camera.py
111
112
113
114
@classmethod
def from_preset(cls, name: str, **kwargs: Any) -> Camera:
    """Create a camera from a built-in preset (see :func:`getframes.available_presets`)."""
    return cls(load_preset(name), **kwargs)

from_dict(data, **kwargs) classmethod

Create a camera from a plain configuration dictionary.

Source code in src/getframes/camera.py
116
117
118
119
@classmethod
def from_dict(cls, data: dict[str, Any], **kwargs: Any) -> Camera:
    """Create a camera from a plain configuration dictionary."""
    return cls(CameraConfig.from_dict(data), **kwargs)

with_config(**changes)

Return a new camera with configuration fields overridden.

Source code in src/getframes/camera.py
152
153
154
155
156
157
158
159
def with_config(self, **changes: Any) -> Camera:
    """Return a new camera with configuration fields overridden."""
    return Camera(
        self.config.replace(**changes),
        default_temperature_c=self.default_temperature_c,
        precision=self.precision,
        device=self.device,
    )

dark_frame(exposure, temperature=None, *, seed=None)

Generate a single dark frame.

Parameters:

Name Type Description Default
exposure float

Integration time in seconds.

required
temperature float | None

Sensor temperature in degrees Celsius. Defaults to the camera's :attr:default_temperature_c.

None
seed int | None

If given, use a fresh generator seeded with this value, producing a fully reproducible frame independent of prior calls. If omitted, the camera's internal generator advances.

None

Returns:

Type Description
Frame

The simulated frame (ADU) with descriptive metadata.

Source code in src/getframes/camera.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def dark_frame(
    self,
    exposure: float,
    temperature: float | None = None,
    *,
    seed: int | None = None,
) -> Frame:
    """Generate a single dark frame.

    Parameters
    ----------
    exposure:
        Integration time in seconds.
    temperature:
        Sensor temperature in degrees Celsius. Defaults to the camera's
        :attr:`default_temperature_c`.
    seed:
        If given, use a fresh generator seeded with this value, producing a
        fully reproducible frame independent of prior calls. If omitted, the
        camera's internal generator advances.

    Returns
    -------
    Frame
        The simulated frame (ADU) with descriptive metadata.
    """
    temp = self.default_temperature_c if temperature is None else temperature
    rng = self._resolve_rng(seed)
    data = noise.generate_dark_frame(
        self.config,
        exposure,
        temp,
        rng=rng,
        backend=self._backend,
        fixed_patterns=self._fixed_patterns,
        _dark_signal=self._dark_signal(exposure, temp),
    )
    data = self._crop_to_roi(data)
    return Frame(data=data, metadata=self._metadata("dark", exposure, temp, seed))

dark_series(exposure, n_frames, temperature=None, *, seed=None)

Yield n_frames independent dark frames (e.g. for building a master dark).

When seed is given the series is reproducible; each frame uses a distinct derived seed so the frames are independent but the whole series is repeatable.

Source code in src/getframes/camera.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
def dark_series(
    self,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
) -> Iterator[Frame]:
    """Yield ``n_frames`` independent dark frames (e.g. for building a master dark).

    When ``seed`` is given the series is reproducible; each frame uses a distinct
    derived seed so the frames are independent but the whole series is repeatable.
    """
    for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
        frame = self.dark_frame(exposure, temperature, seed=frame_seed)
        frame.metadata["frame_index"] = i
        yield frame

nondestructive_series(photon_rate, read_interval, n_frames, reads_per_reset, temperature=None, *, background=0.0, quantum_efficiency=None, seed=None, include_truth=True)

Yield correlated nondestructive reads separated by global resets.

Newly collected photo- and dark electrons are sampled once per read_interval and accumulated in the pixel well. For an EMCCD/eAPD, each new increment passes through the stochastic gain stage once when it is collected; already accumulated charge is not re-multiplied on later reads. Reset noise is drawn once at the start of a ramp and is therefore common to every read in that ramp, while amplifier read noise is fresh on every read. This gives correlated double sampling and up-the-ramp fitting the correct temporal covariance.

A reset occurs immediately before reads 0, reads_per_reset, .... The first returned frame therefore contains one read interval of accumulated charge. Detector transport terms that are linear in charge (IPC) act on each collected increment; CCD-only transfer/blooming models are outside this hybrid-array readout path.

Parameters:

Name Type Description Default
photon_rate PhotonRate

Incident photons/s/pixel, scalar or a map matching the active camera resolution.

required
read_interval float

Time between consecutive reads in seconds.

required
n_frames int

Total number of raw reads to return, across all ramps.

required
reads_per_reset int

Number of nondestructive reads between global resets.

required
temperature float | None

Detector temperature in degrees Celsius.

None
background PhotonRate

Additive incident background in photons/s/pixel.

0.0
quantum_efficiency float | None

Optional scalar QE override.

None
seed int | None

Seed for the complete correlated sequence.

None
include_truth bool

Attach the cumulative noise-free input-electron expectation.

True

Yields:

Type Description
Frame

Raw digitised reads with ramp/read metadata.

Source code in src/getframes/camera.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def nondestructive_series(
    self,
    photon_rate: PhotonRate,
    read_interval: float,
    n_frames: int,
    reads_per_reset: int,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    seed: int | None = None,
    include_truth: bool = True,
) -> Iterator[Frame]:
    """Yield correlated nondestructive reads separated by global resets.

    Newly collected photo- and dark electrons are sampled once per
    ``read_interval`` and accumulated in the pixel well. For an EMCCD/eAPD,
    each new increment passes through the stochastic gain stage once when it
    is collected; already accumulated charge is not re-multiplied on later
    reads. Reset noise is drawn once at the start of a ramp and is therefore
    common to every read in that ramp, while amplifier read noise is fresh on
    every read. This gives correlated double sampling and up-the-ramp fitting
    the correct temporal covariance.

    A reset occurs immediately before reads ``0, reads_per_reset, ...``. The
    first returned frame therefore contains one read interval of accumulated
    charge. Detector transport terms that are linear in charge (IPC) act on
    each collected increment; CCD-only transfer/blooming models are outside
    this hybrid-array readout path.

    Parameters
    ----------
    photon_rate:
        Incident photons/s/pixel, scalar or a map matching the active camera
        resolution.
    read_interval:
        Time between consecutive reads in seconds.
    n_frames:
        Total number of raw reads to return, across all ramps.
    reads_per_reset:
        Number of nondestructive reads between global resets.
    temperature:
        Detector temperature in degrees Celsius.
    background:
        Additive incident background in photons/s/pixel.
    quantum_efficiency:
        Optional scalar QE override.
    seed:
        Seed for the complete correlated sequence.
    include_truth:
        Attach the cumulative noise-free input-electron expectation.

    Yields
    ------
    Frame
        Raw digitised reads with ramp/read metadata.
    """
    if read_interval <= 0:
        raise ValueError("read_interval must be positive.")
    if n_frames < 1:
        raise ValueError("n_frames must be >= 1.")
    if reads_per_reset < 1:
        raise ValueError("reads_per_reset must be >= 1.")
    yield from self._ramp_reads(
        photon_rate,
        (read_interval,) * reads_per_reset,
        n_frames,
        temperature,
        background=background,
        quantum_efficiency=quantum_efficiency,
        seed=seed,
        include_truth=include_truth,
        caller="nondestructive_series",
    )

dark_nondestructive_series(read_interval, n_frames, reads_per_reset, temperature=None, *, seed=None, include_truth=True)

Yield global-reset nondestructive dark reads.

This is :meth:nondestructive_series with zero incident photon rate.

Source code in src/getframes/camera.py
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def dark_nondestructive_series(
    self,
    read_interval: float,
    n_frames: int,
    reads_per_reset: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    include_truth: bool = True,
) -> Iterator[Frame]:
    """Yield global-reset nondestructive dark reads.

    This is :meth:`nondestructive_series` with zero incident photon rate.
    """
    return self.nondestructive_series(
        0.0,
        read_interval,
        n_frames,
        reads_per_reset,
        temperature,
        seed=seed,
        include_truth=include_truth,
    )

correlated_double_sample(photon_rate, exposure, temperature=None, *, background=0.0, quantum_efficiency=None, pedestal_interval_s=0.0, seed=None, include_truth=True)

Read the sensor in correlated double sampling and return the difference.

CDS is the standard low-noise operating mode of a nondestructive-readout hybrid array such as the SAPHIRA in a C-RED One. The pixel is globally reset, read once to record the reset pedestal, integrated for exposure, and read again; the reported value is the difference of the two reads. This is one ramp of :meth:nondestructive_series with two reads, differenced, and it uses that same reset-correlated core.

What the differencing does and does not remove follows from which terms are common to the two reads:

  • Removed. kTC/reset noise (reset_noise_e), drawn once per ramp, and the fixed bias structure — pedestal, per-channel and per-pixel offsets, and edge structure — which is a property of the silicon and identical in both reads.
  • Amplified. Amplifier read noise is redrawn per read, so the difference carries sqrt(2) * read_noise_e. The eAPD input-referred noise likewise adds in quadrature across the two reads.
  • Partly removed. Readout common mode is an AR(1) sequence with correlation readout_common_mode_correlation, so the difference retains the sqrt(2 * (1 - rho)) fraction of it rather than all or none. Reset settling is read-index dependent and therefore leaves the residual between its value at the pedestal and signal reads, which is the physical CDS pedestal artifact rather than a modelling shortcut.
  • Not removed. The interval-proportional bias rate (ndr_bias_offset_adu_per_s and ndr_bias_gain_coefficient_adu_per_s) scales with collected integration time, not with the read operation, so it survives differencing in full. A CDS frame therefore still sits on a small exposure-dependent pedestal — for the C-RED One preset at 1750 Hz, about +50 ADU of bias rate against -8 ADU of settling residual. Subtract it with a dark CDS frame at the same exposure and gain, exactly as on the real camera.

Charge is collected only between the two reads, so the well holds one exposure worth of signal and full-well clipping happens at the intended level.

Parameters:

Name Type Description Default
photon_rate PhotonRate

Incident photons/s/pixel, scalar or a map matching the active camera resolution.

required
exposure float

Integration time between the pedestal and signal reads, in seconds. This is the charge the difference measures, so it is independent of pedestal_interval_s.

required
temperature float | None

Detector temperature in degrees Celsius. Defaults to :attr:default_temperature_c.

None
background PhotonRate

Additive incident background in photons/s/pixel.

0.0
quantum_efficiency float | None

Optional scalar QE override.

None
pedestal_interval_s float

Reset-to-pedestal-read time in seconds. 0.0 (the default) models a pedestal read taken immediately after the reset, collecting no charge. Set it to the camera's real reset-to-read delay when that delay is a significant fraction of exposure. It does not change the measured signal; it sets the interval the pedestal read's own terms are evaluated at — the bias rate, the eAPD input-referred noise, and the gain-driven common-mode sigma — and it adds that much charge to the well before the signal read.

0.0
seed int | None

Seed for the complete two-read sequence.

None
include_truth bool

Attach the noise-free electron expectation of the difference, i.e. the charge collected during exposure.

True

Returns:

Type Description
Frame

Signed difference frame in ADU (int32). Unlike a single raw read it is bias-subtracted by construction and may go negative on a dark pixel.

Source code in src/getframes/camera.py
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
def correlated_double_sample(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    pedestal_interval_s: float = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """Read the sensor in correlated double sampling and return the difference.

    CDS is the standard low-noise operating mode of a nondestructive-readout
    hybrid array such as the SAPHIRA in a C-RED One. The pixel is globally
    reset, read once to record the reset pedestal, integrated for
    ``exposure``, and read again; the reported value is the difference of
    the two reads. This is one ramp of :meth:`nondestructive_series` with
    two reads, differenced, and it uses that same reset-correlated core.

    What the differencing does and does not remove follows from which terms
    are common to the two reads:

    - **Removed.** kTC/reset noise (``reset_noise_e``), drawn once per ramp,
      and the fixed bias structure — pedestal, per-channel and per-pixel
      offsets, and edge structure — which is a property of the silicon and
      identical in both reads.
    - **Amplified.** Amplifier read noise is redrawn per read, so the
      difference carries ``sqrt(2) * read_noise_e``. The eAPD
      input-referred noise likewise adds in quadrature across the two reads.
    - **Partly removed.** Readout common mode is an AR(1) sequence with
      correlation ``readout_common_mode_correlation``, so the difference
      retains the ``sqrt(2 * (1 - rho))`` fraction of it rather than all or
      none. Reset settling is read-index dependent and therefore leaves the
      residual between its value at the pedestal and signal reads, which is
      the physical CDS pedestal artifact rather than a modelling shortcut.
    - **Not removed.** The interval-proportional bias rate
      (``ndr_bias_offset_adu_per_s`` and
      ``ndr_bias_gain_coefficient_adu_per_s``) scales with collected
      integration time, not with the read operation, so it survives
      differencing in full. A CDS frame therefore still sits on a small
      exposure-dependent pedestal — for the C-RED One preset at 1750 Hz,
      about ``+50 ADU`` of bias rate against ``-8 ADU`` of settling
      residual. Subtract it with a dark CDS frame at the same exposure and
      gain, exactly as on the real camera.

    Charge is collected only between the two reads, so the well holds one
    ``exposure`` worth of signal and full-well clipping happens at the
    intended level.

    Parameters
    ----------
    photon_rate:
        Incident photons/s/pixel, scalar or a map matching the active camera
        resolution.
    exposure:
        Integration time *between* the pedestal and signal reads, in
        seconds. This is the charge the difference measures, so it is
        independent of ``pedestal_interval_s``.
    temperature:
        Detector temperature in degrees Celsius. Defaults to
        :attr:`default_temperature_c`.
    background:
        Additive incident background in photons/s/pixel.
    quantum_efficiency:
        Optional scalar QE override.
    pedestal_interval_s:
        Reset-to-pedestal-read time in seconds. ``0.0`` (the default) models
        a pedestal read taken immediately after the reset, collecting no
        charge. Set it to the camera's real reset-to-read delay when that
        delay is a significant fraction of ``exposure``. It does not change
        the measured signal; it sets the interval the pedestal read's own
        terms are evaluated at — the bias rate, the eAPD input-referred
        noise, and the gain-driven common-mode sigma — and it adds that much
        charge to the well before the signal read.
    seed:
        Seed for the complete two-read sequence.
    include_truth:
        Attach the noise-free electron expectation *of the difference*, i.e.
        the charge collected during ``exposure``.

    Returns
    -------
    Frame
        Signed difference frame in ADU (``int32``). Unlike a single raw
        read it is bias-subtracted by construction and may go negative on a
        dark pixel.
    """
    if exposure <= 0:
        raise ValueError("exposure must be positive.")
    if pedestal_interval_s < 0:
        raise ValueError("pedestal_interval_s must be non-negative.")
    if pedestal_interval_s == exposure:
        raise ValueError(
            "pedestal_interval_s must differ from exposure so the two reads "
            "are distinguishable; use nondestructive_series for a uniform ramp."
        )
    pedestal, signal = self._ramp_reads(
        photon_rate,
        (pedestal_interval_s, exposure),
        2,
        temperature,
        background=background,
        quantum_efficiency=quantum_efficiency,
        seed=seed,
        include_truth=include_truth,
        caller="correlated_double_sample",
    )
    xp = self._backend.xp
    data = signal.data.astype(xp.int32) - pedestal.data.astype(xp.int32)
    truth = None
    if include_truth and signal.truth is not None and pedestal.truth is not None:
        truth = FrameTruth(
            mean_electrons=signal.truth.mean_electrons - pedestal.truth.mean_electrons,
            mean_photoelectrons=(
                signal.truth.mean_photoelectrons - pedestal.truth.mean_photoelectrons
            ),
            photon_rate=photon_rate,
        )
    temp = self.default_temperature_c if temperature is None else temperature
    metadata = self._metadata("correlated_double_sample", exposure, temp, seed)
    metadata.update(
        {
            "readout_mode": "global_reset_cds",
            "exposure_s": exposure,
            "pedestal_interval_s": pedestal_interval_s,
            "reads_per_reset": 2,
        }
    )
    return Frame(data=data, metadata=metadata, truth=truth)

expose(photon_rate, exposure, temperature=None, *, background=0.0, quantum_efficiency=None, extra_electrons=0.0, binning=1, binning_mode='digital', seed=None, include_truth=True, workspace=None, out=None)

Expose the sensor to an incident photon rate and return a frame.

This is the general signal path; :meth:dark_frame, :meth:flat_frame, and :meth:bias_frame are convenience wrappers around it.

Parameters:

Name Type Description Default
photon_rate PhotonRate

Incident photon rate in photons/s/pixel, as a scalar (uniform illumination) or a 2-D array matching :attr:resolution.

required
exposure float

Integration time in seconds.

required
temperature float | None

Sensor temperature in degrees Celsius. Defaults to :attr:default_temperature_c.

None
background PhotonRate

Additive background (sky/thermal) photon rate in photons/s/pixel.

0.0
quantum_efficiency float | None

Overrides the config's scalar QE for this exposure. Spectral mode uses this with an already-photoelectron map and 1.0; most callers leave it None.

None
extra_electrons PhotonRate

Additive noise-free signal in electrons (scalar or array) injected before shot noise. Used by :meth:observe_series to carry latent charge from image persistence; most callers leave it 0.0.

0.0
binning int

Combine binning x binning native pixels into each output pixel (1 = no binning). :attr:resolution is the native grid and must be divisible by binning; the returned frame is resolution // binning.

1
binning_mode str

"digital" (post-read software binning, the default) reads each native pixel with its own read noise then sums the digitised values, so binned read noise grows as binning. "on_chip" (pre-read charge-domain / hardware binning) sums the charge before the amplifier, so one read noise is applied per super-pixel. See :func:getframes.noise.simulate_frame.

'digital'
seed int | None

If given, use a fresh generator seeded with this value for a fully reproducible frame.

None
include_truth bool

If True (default), attach the noise-free ground truth to the returned :class:~getframes.frame.Frame for pipeline validation.

True
workspace DetectorWorkspace | None

Optional reusable :class:~getframes.noise.DetectorWorkspace for private detector and full-grid ROI scratch. It is sequential-use only; returned arrays do not alias it.

None
out Any | None

Optional C-contiguous, writable backend-native uint32 array with the returned frame shape. The frame uses this exact caller-owned storage; do not overwrite it while a consumer still needs the frame.

None
Source code in src/getframes/camera.py
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
def expose(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    extra_electrons: PhotonRate = 0.0,
    binning: int = 1,
    binning_mode: str = "digital",
    seed: int | None = None,
    include_truth: bool = True,
    workspace: DetectorWorkspace | None = None,
    out: Any | None = None,
) -> Frame:
    """Expose the sensor to an incident photon rate and return a frame.

    This is the general signal path; :meth:`dark_frame`, :meth:`flat_frame`,
    and :meth:`bias_frame` are convenience wrappers around it.

    Parameters
    ----------
    photon_rate:
        Incident photon rate in photons/s/pixel, as a scalar (uniform
        illumination) or a 2-D array matching :attr:`resolution`.
    exposure:
        Integration time in seconds.
    temperature:
        Sensor temperature in degrees Celsius. Defaults to
        :attr:`default_temperature_c`.
    background:
        Additive background (sky/thermal) photon rate in photons/s/pixel.
    quantum_efficiency:
        Overrides the config's scalar QE for this exposure. Spectral mode uses
        this with an already-photoelectron map and ``1.0``; most callers leave
        it ``None``.
    extra_electrons:
        Additive noise-free signal in electrons (scalar or array) injected
        before shot noise. Used by :meth:`observe_series` to carry latent charge
        from image persistence; most callers leave it ``0.0``.
    binning:
        Combine ``binning x binning`` native pixels into each output pixel
        (``1`` = no binning). :attr:`resolution` is the native grid and must be
        divisible by ``binning``; the returned frame is ``resolution // binning``.
    binning_mode:
        ``"digital"`` (post-read software binning, the default) reads each native
        pixel with its own read noise then sums the digitised values, so binned
        read noise grows as ``binning``. ``"on_chip"`` (pre-read charge-domain /
        hardware binning) sums the charge before the amplifier, so one read noise
        is applied per super-pixel. See :func:`getframes.noise.simulate_frame`.
    seed:
        If given, use a fresh generator seeded with this value for a fully
        reproducible frame.
    include_truth:
        If ``True`` (default), attach the noise-free ground truth to the
        returned :class:`~getframes.frame.Frame` for pipeline validation.
    workspace:
        Optional reusable :class:`~getframes.noise.DetectorWorkspace` for
        private detector and full-grid ROI scratch. It is sequential-use only;
        returned arrays do not alias it.
    out:
        Optional C-contiguous, writable backend-native ``uint32`` array with
        the returned frame shape. The frame uses this exact caller-owned
        storage; do not overwrite it while a consumer still needs the frame.
    """
    if workspace is not None:
        with workspace._using(self._backend, self.sensor_resolution, self._float_dtype):
            return self._expose_into(
                photon_rate,
                exposure,
                temperature,
                background=background,
                quantum_efficiency=quantum_efficiency,
                extra_electrons=extra_electrons,
                binning=binning,
                binning_mode=binning_mode,
                seed=seed,
                include_truth=include_truth,
                workspace=workspace,
                out=out,
            )
    return self._expose_into(
        photon_rate,
        exposure,
        temperature,
        background=background,
        quantum_efficiency=quantum_efficiency,
        extra_electrons=extra_electrons,
        binning=binning,
        binning_mode=binning_mode,
        seed=seed,
        include_truth=include_truth,
        workspace=None,
        out=out,
    )

expose_spectral(photon_rate_cube, wavelengths_nm, exposure, temperature=None, *, background=0.0, seed=None, binning=1, binning_mode='digital', include_truth=True, workspace=None, out=None)

Expose a wavelength-resolved photon-rate cube.

photon_rate_cube is incident photons/s/native pixel with shape (n_wavelength, height, width). The configured :class:~getframes.spectral.QE is evaluated at each node and applied before the ordinary detector signal chain. The detector stochastic model is therefore executed exactly once; FrameTruth.mean_photoelectrons contains the QE-weighted result while FrameTruth.photon_rate retains the integrated incident photon map.

This method is separate from :meth:expose so scalar callers remain unchanged and callers cannot accidentally apply QE twice. A configured qe_curve is required. workspace and out have the same reusable-scratch and caller-owned-lifetime contracts as :meth:expose.

Source code in src/getframes/camera.py
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
def expose_spectral(
    self,
    photon_rate_cube: NDArray[np.floating[Any]],
    wavelengths_nm: NDArray[np.floating[Any]],
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    seed: int | None = None,
    binning: int = 1,
    binning_mode: str = "digital",
    include_truth: bool = True,
    workspace: DetectorWorkspace | None = None,
    out: Any | None = None,
) -> Frame:
    """Expose a wavelength-resolved photon-rate cube.

    ``photon_rate_cube`` is incident photons/s/native pixel with shape
    ``(n_wavelength, height, width)``. The configured :class:`~getframes.spectral.QE`
    is evaluated at each node and applied before the ordinary detector signal
    chain. The detector stochastic model is therefore executed exactly once;
    ``FrameTruth.mean_photoelectrons`` contains the QE-weighted result while
    ``FrameTruth.photon_rate`` retains the integrated incident photon map.

    This method is separate from :meth:`expose` so scalar callers remain
    unchanged and callers cannot accidentally apply QE twice. A configured
    ``qe_curve`` is required. ``workspace`` and ``out`` have the same
    reusable-scratch and caller-owned-lifetime contracts as :meth:`expose`.
    """
    cube, wavelengths_host, electron_rate, integrated_rate = self._fold_spectral_cube(
        photon_rate_cube, wavelengths_nm, "expose_spectral"
    )
    frame = self.expose(
        electron_rate,
        exposure,
        temperature,
        background=background,
        quantum_efficiency=1.0,
        binning=binning,
        binning_mode=binning_mode,
        seed=seed,
        include_truth=include_truth,
        workspace=workspace,
        out=out,
    )
    frame.metadata["spectral"] = True
    frame.metadata["spectral_wavelengths_nm"] = [float(value) for value in wavelengths_host]
    if frame.truth is not None:
        frame = replace(
            frame,
            truth=replace(
                frame.truth,
                photon_rate=integrated_rate,
                spectral_photon_rate=cube,
                wavelengths_nm=self._backend.asarray(wavelengths_host, dtype=np.float64),
            ),
        )
    return frame

correlated_double_sample_spectral(photon_rate_cube, wavelengths_nm, exposure, temperature=None, *, background=0.0, pedestal_interval_s=0.0, seed=None, include_truth=True)

Read a wavelength-resolved photon-rate cube in correlated double sampling.

This is :meth:correlated_double_sample for the spectral path, and stands in the same relation to it as :meth:expose_spectral does to :meth:expose: the configured :class:~getframes.spectral.QE is evaluated at each wavelength node and folded in before the detector signal chain runs once, so callers cannot apply QE twice.

photon_rate_cube is incident photons/s/native pixel with shape (n_wavelength, height, width). A configured qe_curve is required. The return is the signed int32 ADU difference described in :meth:correlated_double_sample.

Source code in src/getframes/camera.py
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
def correlated_double_sample_spectral(
    self,
    photon_rate_cube: NDArray[np.floating[Any]],
    wavelengths_nm: NDArray[np.floating[Any]],
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    pedestal_interval_s: float = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """Read a wavelength-resolved photon-rate cube in correlated double sampling.

    This is :meth:`correlated_double_sample` for the spectral path, and
    stands in the same relation to it as :meth:`expose_spectral` does to
    :meth:`expose`: the configured :class:`~getframes.spectral.QE` is
    evaluated at each wavelength node and folded in before the detector
    signal chain runs once, so callers cannot apply QE twice.

    ``photon_rate_cube`` is incident photons/s/native pixel with shape
    ``(n_wavelength, height, width)``. A configured ``qe_curve`` is required.
    The return is the signed ``int32`` ADU difference described in
    :meth:`correlated_double_sample`.
    """
    cube, wavelengths_host, electron_rate, integrated_rate = self._fold_spectral_cube(
        photon_rate_cube, wavelengths_nm, "correlated_double_sample_spectral"
    )
    frame = self.correlated_double_sample(
        electron_rate,
        exposure,
        temperature,
        background=background,
        quantum_efficiency=1.0,
        pedestal_interval_s=pedestal_interval_s,
        seed=seed,
        include_truth=include_truth,
    )
    frame.metadata["spectral"] = True
    frame.metadata["spectral_wavelengths_nm"] = [float(value) for value in wavelengths_host]
    if frame.truth is not None:
        frame = replace(
            frame,
            truth=replace(
                frame.truth,
                photon_rate=integrated_rate,
                spectral_photon_rate=cube,
                wavelengths_nm=self._backend.asarray(wavelengths_host, dtype=np.float64),
            ),
        )
    return frame

flat_frame(photon_rate, exposure, temperature=None, *, background=0.0, seed=None, include_truth=True)

A uniformly (or per-pixel) illuminated flat-field frame.

Equivalent to :meth:expose; provided as a named entry point for flat-field/photon-transfer workflows. Pass a scalar photon_rate for a uniform flat.

Source code in src/getframes/camera.py
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
def flat_frame(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """A uniformly (or per-pixel) illuminated flat-field frame.

    Equivalent to :meth:`expose`; provided as a named entry point for
    flat-field/photon-transfer workflows. Pass a scalar ``photon_rate`` for a
    uniform flat.
    """
    frame = self.expose(
        photon_rate,
        exposure,
        temperature,
        background=background,
        seed=seed,
        include_truth=include_truth,
    )
    frame.metadata["frame_type"] = "flat"
    return frame

bias_frame(temperature=None, *, seed=None)

A zero-exposure bias frame (bias pedestal + read noise only).

Source code in src/getframes/camera.py
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
def bias_frame(
    self,
    temperature: float | None = None,
    *,
    seed: int | None = None,
) -> Frame:
    """A zero-exposure bias frame (bias pedestal + read noise only)."""
    frame = self.expose(0.0, 0.0, temperature, seed=seed, include_truth=False)
    frame.metadata["frame_type"] = "bias"
    return frame

observe(scene, exposure, temperature=None, *, seed=None, include_truth=True)

Observe a :class:~getframes.scene.Scene and return a science frame.

Renders the scene to an incident photon-rate map, then exposes the sensor to it (adding the scene's sky as a uniform background). The scene's shape must match this camera's :attr:resolution.

Spectral mode activates automatically when this camera's config has a :attr:~getframes.config.CameraConfig.qe_curve and the scene's band carries a spectral response: each source then gets a colour-dependent effective QE from its SED, instead of the scalar quantum_efficiency.

Source code in src/getframes/camera.py
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
def observe(
    self,
    scene: Scene,
    exposure: float,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    include_truth: bool = True,
) -> Frame:
    """Observe a :class:`~getframes.scene.Scene` and return a science frame.

    Renders the scene to an incident photon-rate map, then exposes the sensor
    to it (adding the scene's sky as a uniform background). The scene's
    ``shape`` must match this camera's :attr:`resolution`.

    **Spectral mode** activates automatically when this camera's config has a
    :attr:`~getframes.config.CameraConfig.qe_curve` *and* the scene's band
    carries a spectral response: each source then gets a colour-dependent
    effective QE from its SED, instead of the scalar ``quantum_efficiency``.
    """
    if tuple(scene.shape) != self.resolution:
        raise ValueError(
            f"scene.shape {tuple(scene.shape)} does not match camera "
            f"resolution {self.resolution}."
        )
    rate, background, qe, spectral = self._scene_inputs(scene)
    frame = self.expose(
        rate,
        exposure,
        temperature,
        background=background,
        quantum_efficiency=qe,
        seed=seed,
        include_truth=include_truth,
    )
    self._tag_science(frame, scene, spectral)
    return frame

expose_series(photon_rate, exposure, n_frames, temperature=None, *, background=0.0, quantum_efficiency=None, binning=1, binning_mode='digital', seed=None, include_truth=True)

Yield n_frames independent illuminated frames (the :meth:expose series).

The light-frame analogue of :meth:dark_series. When seed is given the series is reproducible; each frame uses a distinct derived seed so the frames are independent but the whole series repeats. quantum_efficiency has the same meaning as in :meth:expose; pass 1.0 when photon_rate and background are already expressed as electron rates. binning and binning_mode are passed through to :meth:expose, so a calibration series bins exactly as its science frames do.

Source code in src/getframes/camera.py
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
def expose_series(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    binning: int = 1,
    binning_mode: str = "digital",
    seed: int | None = None,
    include_truth: bool = True,
) -> Iterator[Frame]:
    """Yield ``n_frames`` independent illuminated frames (the :meth:`expose` series).

    The light-frame analogue of :meth:`dark_series`. When ``seed`` is given the
    series is reproducible; each frame uses a distinct derived seed so the
    frames are independent but the whole series repeats. ``quantum_efficiency``
    has the same meaning as in :meth:`expose`; pass ``1.0`` when
    ``photon_rate`` and ``background`` are already expressed as electron rates.
    ``binning`` and ``binning_mode`` are passed through to :meth:`expose`, so a
    calibration series bins exactly as its science frames do.
    """
    for i, frame_seed in enumerate(self._series_seeds(seed, n_frames)):
        frame = self.expose(
            photon_rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=quantum_efficiency,
            binning=binning,
            binning_mode=binning_mode,
            seed=frame_seed,
            include_truth=include_truth,
        )
        frame.metadata["frame_index"] = i
        yield frame

observe_series(scene, exposure, n_frames, temperature=None, *, cadence=None, pointing=None, jitter_arcsec=0.0, seed=None, include_truth=True)

Observe scene over time, returning a reproducible :class:Observation.

Produces a time-ordered stack of science frames. Frame i is exposed at timestamp t_i = i * cadence (the start of its exposure); sources carrying a :class:~getframes.scene.sources.LightCurve vary accordingly, and a :class:~getframes.observation.Pointing model shifts the field per frame. If the detector has a non-zero :attr:~getframes.config.CameraConfig.persistence_fraction, latent charge is carried across frames.

The returned :class:Observation is iterable over its frames (so for f in cam.observe_series(...) still works) and carries the per-frame timestamps, realised pointing offsets, and the ground-truth light curve.

Parameters:

Name Type Description Default
scene Scene

As in :meth:observe.

required
exposure Scene

As in :meth:observe.

required
temperature Scene

As in :meth:observe.

required
n_frames int

Number of frames in the series.

required
cadence float | None

Seconds between successive frame start times. Defaults to exposure (back-to-back frames with no dead time).

None
pointing Pointing | None

A :class:~getframes.observation.Pointing model for per-frame field offsets. If None and jitter_arcsec is given, a jitter-only model is built from it.

None
jitter_arcsec float

Convenience for the common case: the RMS of a per-frame Gaussian pointing jitter (ignored if pointing is given explicitly).

0.0
seed int | None

When given, the series is reproducible; each frame draws a distinct derived seed (independent frames) and the pointing jitter uses its own derived stream, so the whole observation repeats exactly.

None
include_truth bool

Whether to attach per-frame :class:~getframes.frame.FrameTruth and build the observation's light-curve truth.

True
Source code in src/getframes/camera.py
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
def observe_series(
    self,
    scene: Scene,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    cadence: float | None = None,
    pointing: Pointing | None = None,
    jitter_arcsec: float = 0.0,
    seed: int | None = None,
    include_truth: bool = True,
) -> Observation:
    """Observe ``scene`` over time, returning a reproducible :class:`Observation`.

    Produces a time-ordered stack of science frames. Frame ``i`` is exposed at
    timestamp ``t_i = i * cadence`` (the start of its exposure); sources
    carrying a :class:`~getframes.scene.sources.LightCurve` vary accordingly,
    and a :class:`~getframes.observation.Pointing` model shifts the field per
    frame. If the detector has a non-zero
    :attr:`~getframes.config.CameraConfig.persistence_fraction`, latent charge
    is carried across frames.

    The returned :class:`Observation` is iterable over its frames (so
    ``for f in cam.observe_series(...)`` still works) and carries the per-frame
    timestamps, realised pointing offsets, and the ground-truth light curve.

    Parameters
    ----------
    scene, exposure, temperature:
        As in :meth:`observe`.
    n_frames:
        Number of frames in the series.
    cadence:
        Seconds between successive frame start times. Defaults to ``exposure``
        (back-to-back frames with no dead time).
    pointing:
        A :class:`~getframes.observation.Pointing` model for per-frame field
        offsets. If ``None`` and ``jitter_arcsec`` is given, a jitter-only model
        is built from it.
    jitter_arcsec:
        Convenience for the common case: the RMS of a per-frame Gaussian
        pointing jitter (ignored if ``pointing`` is given explicitly).
    seed:
        When given, the series is reproducible; each frame draws a distinct
        derived seed (independent frames) and the pointing jitter uses its own
        derived stream, so the whole observation repeats exactly.
    include_truth:
        Whether to attach per-frame :class:`~getframes.frame.FrameTruth` and
        build the observation's light-curve truth.
    """
    cadence = exposure if cadence is None else cadence
    if pointing is None and jitter_arcsec > 0:
        pointing = Pointing(jitter_arcsec=jitter_arcsec)
    plate_scale = scene.optics.plate_scale_arcsec_per_pixel

    frame_seeds = self._series_seeds(seed, n_frames)
    # A pointing stream independent of the per-frame shot/read-noise seeds, so
    # jitter is reproducible without coupling to (or perturbing) frame noise.
    point_seq = None if seed is None else np.random.SeedSequence([int(seed), _POINTING_STREAM])
    point_rng = np.random.default_rng(point_seq)

    names = self._source_names(scene.sources)
    light_curve: dict[str, list[float]] = {name: [] for name in names}
    frames: list[Frame] = []
    times: list[float] = []
    offsets: list[tuple[float, float]] = []
    latent: PhotonRate = 0.0  # trapped charge (electrons) carried across frames

    for i, frame_seed in enumerate(frame_seeds):
        t = i * cadence
        offset = (0.0, 0.0)
        if pointing is not None and not pointing.is_static:
            offset = pointing.offset_pixels(i, t, plate_scale, point_rng)

        rate, background, qe, spectral = self._scene_inputs(scene, t, offset)
        extra = self.config.persistence_decay * latent if self._has_persistence else 0.0
        frame = self.expose(
            rate,
            exposure,
            temperature,
            background=background,
            quantum_efficiency=qe,
            extra_electrons=extra,
            seed=frame_seed,
            include_truth=include_truth,
        )
        self._tag_science(frame, scene, spectral)
        frame.metadata["frame_index"] = i
        frame.metadata["time_s"] = t
        frame.metadata["pointing_offset_px"] = offset

        if self._has_persistence:
            latent = self._update_latent(latent, extra, rate, exposure, background, qe)
        if include_truth:
            for name, source in zip(names, scene.sources):
                light_curve[name].append(scene._source_photon_rate(source, t) * exposure)

        frames.append(frame)
        times.append(t)
        offsets.append(offset)

    truth = (
        ObservationTruth(
            times_s=np.asarray(times, dtype=np.float64),
            light_curve={k: np.asarray(v, dtype=np.float64) for k, v in light_curve.items()},
        )
        if include_truth
        else None
    )
    return Observation(
        frames=frames,
        times_s=np.asarray(times, dtype=np.float64),
        offsets_pixels=np.asarray(offsets, dtype=np.float64).reshape(n_frames, 2),
        truth=truth,
    )

master_bias(n_frames, temperature=None, *, seed=None, method='median')

Combine n_frames bias frames into a master bias (see :func:getframes.combine).

Source code in src/getframes/camera.py
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
def master_bias(
    self,
    n_frames: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    method: str = "median",
) -> Frame:
    """Combine ``n_frames`` bias frames into a master bias (see :func:`getframes.combine`)."""
    from .calibrate import combine

    frames = (self.bias_frame(temperature, seed=s) for s in self._series_seeds(seed, n_frames))
    return combine(frames, method=method)

master_dark(exposure, n_frames, temperature=None, *, seed=None, method='median')

Combine n_frames dark frames into a master dark.

The result still contains the bias pedestal, so it is subtracted directly from an exposure-matched science frame (calibrate(sci, dark=master)).

Source code in src/getframes/camera.py
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
def master_dark(
    self,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    seed: int | None = None,
    method: str = "median",
) -> Frame:
    """Combine ``n_frames`` dark frames into a master dark.

    The result still contains the bias pedestal, so it is subtracted directly
    from an exposure-matched science frame (``calibrate(sci, dark=master)``).
    """
    from .calibrate import combine

    return combine(self.dark_series(exposure, n_frames, temperature, seed=seed), method=method)

master_flat(photon_rate, exposure, n_frames, temperature=None, *, background=0.0, bias=None, seed=None, method='median')

Combine n_frames flat frames into a master flat.

If bias is given it is subtracted, yielding a pedestal-free flat whose pixel-to-pixel structure is the detector's response — the form :func:getframes.calibrate expects to normalise and divide by.

Source code in src/getframes/camera.py
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
def master_flat(
    self,
    photon_rate: PhotonRate,
    exposure: float,
    n_frames: int,
    temperature: float | None = None,
    *,
    background: PhotonRate = 0.0,
    bias: Frame | NDArray[np.floating[Any]] | None = None,
    seed: int | None = None,
    method: str = "median",
) -> Frame:
    """Combine ``n_frames`` flat frames into a master flat.

    If ``bias`` is given it is subtracted, yielding a pedestal-free flat whose
    pixel-to-pixel structure is the detector's response — the form
    :func:`getframes.calibrate` expects to normalise and divide by.
    """
    from .calibrate import combine

    frames = self.expose_series(
        photon_rate,
        exposure,
        n_frames,
        temperature,
        background=background,
        seed=seed,
        include_truth=False,
    )
    master = combine(frames, method=method)
    if bias is None:
        return master
    data = np.asarray(master.data, dtype=np.float64) - np.asarray(bias, dtype=np.float64)
    metadata = {**master.metadata, "bias_subtracted": True}
    return Frame(data=data, metadata=metadata)

CameraConfig

getframes.config.CameraConfig dataclass

Physical and electronic parameters of a camera/detector.

All electron quantities are in electrons (e-); all digital quantities are in analog-to-digital units (ADU, sometimes called counts or DN).

Parameters:

Name Type Description Default
name str

Human-readable identifier (e.g. "Andor iKon-M 934").

required
sensor_type SensorType

One of :class:SensorType (CCD, CMOS, EMCCD, EAPD). Selects the noise model; EMCCD and EAPD additionally use the stochastic gain stage.

required
resolution tuple[int, int]

Sensor size as (height, width) in pixels, matching NumPy's row-major array convention.

required
roi tuple[int, int, int, int] | None

Optional detector region of interest as (left, top, width, height) in unbinned full-detector pixels. :class:~getframes.camera.Camera accepts and returns arrays shaped (height, width) while detector effects are still simulated on the full resolution grid before cropping.

None
pixel_size_um float

Physical pixel pitch in microns. Informational; not used for dark frames.

required
quantum_efficiency float

Band-averaged quantum efficiency in [0, 1]. Used by the signal path to convert photons to photoelectrons. Ignored for dark frames.

required
qe_curve QE | None

Optional wavelength-resolved quantum efficiency (:class:~getframes.spectral.QE). When set, :meth:Camera.observe switches to spectral mode and computes a colour-dependent effective QE from each source's SED and the band's spectral response, instead of the scalar quantum_efficiency. None keeps the band-averaged model.

None
supported_binnings tuple[int, ...]

Integer pixel-binning factors this sensor supports (must include 1). Passed to :meth:Camera.expose's binning argument. Advisory metadata: the model bins whatever factor you ask for.

(1,)
binning_method str

How this sensor combines binned pixels: "digital" (post-read software binning, read noise grows as the binning factor) or "on_chip" (pre-read charge-domain/hardware binning, one read noise per super-pixel). Consumed by :meth:Camera.expose's binning_mode argument.

'digital'
full_well_e float

Image-area (input) full-well capacity in electrons. Collected charge saturates here before any EM/avalanche multiplication stage.

required
output_full_well_e float | None

Optional post-multiplication output-register capacity in electrons. This limits amplified charge before conversion to ADU. None preserves the legacy behavior and uses full_well_e as the digitizer ceiling.

None
bit_depth int

ADC resolution in bits. The output saturates at 2**bit_depth - 1.

required
gain_e_per_adu float

Camera conversion gain in electrons per ADU. Electrons reaching the ADC are divided by this to produce counts.

required
bias_offset_adu float

Electronic offset (pedestal) added to every pixel, in ADU.

required
read_noise_e float

RMS read noise in electrons. When read_noise_nonuniformity is zero this is every pixel's read noise. Otherwise it is the scale of the per-pixel distribution, which is log-normal with unit mean --- so the mean per-pixel RMS is read_noise_e and the median is read_noise_e * exp(-read_noise_nonuniformity**2 / 2), a few percent lower. See read_noise_nonuniformity and read_noise_rts_fraction.

required
avalanche_input_noise_e float

RMS per-read noise in input-referred electrons that scales with the mean avalanche gain. This empirical term captures gain-dependent tunnelling or multiplication-region noise that is not part of the output-amplifier read_noise_e. It is added as an output-equivalent Gaussian with RMS avalanche_input_noise_e * em_gain. Relevant only to gain-stage sensors; 0 disables it.

0.0
avalanche_input_noise_gain_exponent float

Optional sublinear gain scaling of the avalanche-noise output RMS. The ordinary input-referred result is multiplied by (em_gain / reference_gain)**(gain_exponent - 1). An exponent of 1 preserves linear input-referred scaling.

1.0
avalanche_input_noise_reference_gain float

Optional sublinear gain scaling of the avalanche-noise output RMS. The ordinary input-referred result is multiplied by (em_gain / reference_gain)**(gain_exponent - 1). An exponent of 1 preserves linear input-referred scaling.

1.0
read_noise_nonuniformity float

Fractional pixel-to-pixel spread of the read-noise RMS (e.g. 0.3 for a 30% log-normal spread). Models the per-pixel read-noise distribution of sCMOS sensors. 0 gives a single uniform read noise.

The resulting per-pixel RMS is a fixed property of the sensor (drawn from fixed_pattern_seed, like PRNU and DSNU), not re-drawn each frame, so a pixel's temporal noise is repeatable across a stack --- which is what is measured in practice.

0.0
read_noise_rts_fraction float

Fraction of pixels belonging to a second, noisier read-noise population, in [0, 1]. These are the random-telegraph-signal (RTS) pixels of a real sCMOS array, whose trapped-charge switching gives the read-noise histogram a tail much heavier than the single log-normal of read_noise_nonuniformity. 0 disables the second population. Measured values for back-illuminated sCMOS are around 0.005-0.03.

0.0
read_noise_rts_factor float

Multiplier applied to the read-noise RMS of the RTS population selected by read_noise_rts_fraction. Ignored when that fraction is 0.

2.5
readout_channel_count int

Number of interleaved video-output channels. Channel c reads detector coordinates whose index along readout_channel_axis is congruent to c modulo this count. 1 disables channel structure. SAPHIRA uses 32 parallel outputs interleaved across the row.

1
readout_channel_axis int

Detector axis carrying the interleaved channel assignment: 0 for rows or 1 for columns.

1
read_noise_channel_nonuniformity float

Log-normal fractional spread of read-noise RMS between interleaved output channels. The factors have unit mean and are fixed by fixed_pattern_seed. 0 gives equal channel noise.

0.0
read_noise_edge_factor float

Multiplicative rise in read-noise RMS at the detector boundary and its exponential falloff scale in pixels. A factor of 1 or a scale of 0 disables the edge term.

1.0
read_noise_edge_scale_px float

Multiplicative rise in read-noise RMS at the detector boundary and its exponential falloff scale in pixels. A factor of 1 or a scale of 0 disables the edge term.

1.0
readout_common_mode_noise_adu float

Frame-wide electronic offset noise RMS in ADU. Unlike the fixed bias map, this scalar is redrawn for each ordinary frame and therefore survives a master bias. 0 disables it.

0.0
readout_common_mode_correlation float

Lag-one correlation coefficient of common-mode noise in :meth:Camera.nondestructive_series, in (-1, 1). Ordinary independent frame methods still draw independent common-mode offsets.

0.0
ndr_bias_offset_adu_per_s float

Read-interval-dependent pedestal coefficients for nondestructive sequences. The added pedestal is read_interval * (offset + gain_coefficient * (em_gain - 1)) ADU. These empirical terms describe read-rate and avalanche-dependent ROIC settling; both default to zero.

0.0
ndr_bias_gain_coefficient_adu_per_s float

Read-interval-dependent pedestal coefficients for nondestructive sequences. The added pedestal is read_interval * (offset + gain_coefficient * (em_gain - 1)) ADU. These empirical terms describe read-rate and avalanche-dependent ROIC settling; both default to zero.

0.0
ndr_common_mode_gain_noise_adu_per_s float

Additional frame-wide common-mode RMS in an NDR sequence, equal to this coefficient times read_interval * (em_gain - 1). Defaults to zero.

0.0
ndr_avalanche_input_noise_reference_interval_s float
1.0
ndr_avalanche_input_noise_interval_exponent float

Optional read-rate scaling of avalanche_input_noise_e in NDR series. Its effective input-referred RMS is multiplied by (read_interval / reference_interval)**exponent. An exponent of 0 preserves the ordinary rate-independent behavior.

0.0
ndr_reset_settling_input_e float
0.0
ndr_reset_settling_scale_reads float
0.0
ndr_reset_settling_reference_interval_s float

Input-referred amplitude and exponential read-index scale of the negative pedestal transient immediately following a global reset. The first read is lowered by ndr_reset_settling_input_e * em_gain / gain_e_per_adu ADU at the reference read interval, then the term decays as exp(-read_index / scale_reads). A nonzero interval exponent scales the amplitude by (read_interval / reference_interval)**exponent. Either amplitude or scale at 0 disables it.

1.0
ndr_reset_settling_interval_exponent float

Input-referred amplitude and exponential read-index scale of the negative pedestal transient immediately following a global reset. The first read is lowered by ndr_reset_settling_input_e * em_gain / gain_e_per_adu ADU at the reference read interval, then the term decays as exp(-read_index / scale_reads). A nonzero interval exponent scales the amplitude by (read_interval / reference_interval)**exponent. Either amplitude or scale at 0 disables it.

1.0
detector_glow_edge_scale_px float

Exponential falloff scale, in pixels, of the detector_glow_e_per_s term away from the detector edges. Amplifier/array glow originates at the readout electronics on the array periphery, so real glow is edge-concentrated rather than uniform. 0 (the default) keeps the glow uniform. When positive, the map is renormalised so the mean glow over the array is still detector_glow_e_per_s, which means the edges run hotter and the centre cooler than that figure. The pattern is fixed and exposure-scaling, so an exposure-matched master dark still removes it.

0.0
nonlinearity float

Fractional signal compression at full well, in [0, 0.5). The collected charge is bent as q -> q * (1 - nonlinearity * q / full_well_e), so a pixel at full well reads nonlinearity fraction low. 0 is perfectly linear. Superseded by nonlinearity_coeffs when that is given.

0.0
nonlinearity_coeffs tuple[float, ...] | None

Optional polynomial generalisation of nonlinearity. A sequence (c1, c2, ...) defines the response multiplier q -> q * (1 + c1 * u + c2 * u**2 + ...) with u = q / full_well_e, so an arbitrary measured nonlinearity curve (or look-up) can be reproduced. When set it replaces the single-parameter nonlinearity model. None keeps the scalar model.

None
cti float

Charge-transfer inefficiency (CTI) of a CCD, the fraction of charge left behind per pixel-to-pixel transfer during readout, in [0, 1). A bright pixel r rows from the readout register undergoes r transfers and smears a deferred-charge tail away from the register. 0 is a perfect CCD. (Trap-driven deferral; the readout register is taken to be row 0.)

0.0
blooming bool

When True, charge collected above full_well_e spills (blooms) into the vertically adjacent pixels of the same column until it is below full well or runs off the array, charge-conserving — the bright bleed columns of a saturated CCD. False simply clips at full well.

False
ipc_coupling float

Inter-pixel capacitance (IPC): the fraction of each pixel's signal that couples capacitively into each of its four nearest neighbours at readout, in [0, 0.25). Applied as a charge-conserving 3x3 convolution (CMOS/IR hybrid arrays). 0 disables it.

0.0
charge_diffusion_fwhm_px float

Lateral charge-diffusion FWHM in native pixels. Photo-electrons random walk in the silicon before reaching a potential well, so the collected charge is the incident irradiance convolved with this Gaussian and only then integrated over each pixel's area. It is applied only by :func:~getframes.apply_charge_diffusion (or its :func:~getframes.charge_diffusion_kernel), which requires an oversampled irradiance map. :class:Camera receives an already integrated photon-rate map, so it does not apply diffusion a second time and records that fact in frame metadata. 0 disables it. Distinct from ipc_coupling, which couples charge after collection.

0.0
reset_noise_e float

kTC / reset noise RMS in electrons. Ordinary exposures draw an independent per-pixel Gaussian; nondestructive reads share one draw per reset ramp. 0 disables it (or assumes correlated double sampling removes it).

0.0
read_noise_correlated_fraction float

Fraction of the read-noise variance that is common to every read of a nondestructive ramp, and therefore cancels when two reads are differenced. 0 (the default) makes every read an independent draw.

Read noise measured from a single read is not all white. Reference-level drift, bias settling, and 1/f components persist across the microseconds between two reads of a correlated-double-sampling pair, and differencing removes them --- which is the entire reason CDS is used. A detector whose single-read noise is R and whose correlated fraction is f therefore shows R * sqrt(2 * (1 - f)) in CDS, which is below R whenever f > 0.5.

Fitting this from single-read data alone is impossible: only a differenced measurement separates the correlated part. Take it from a CDS measurement, or leave it at 0 and accept that the model will overstate CDS noise by up to sqrt(2 / (2 * (1 - f))).

0.0
amplifier_layout tuple[int, int]

Multi-amplifier readout as (n_rows, n_cols) of amplifiers tiling the sensor (e.g. (2, 2) for a four-quadrant CCD). Each amplifier block gets its own small gain and offset error (see amp_gain_nonuniformity / amp_offset_spread_adu), producing the characteristic seams. (1, 1) is a single amplifier.

(1, 1)
amplifier_boundaries_y_px tuple[int, ...]

Optional exact internal amplifier split coordinates on the full detector. Empty tuples divide resolution equally according to amplifier_layout. :attr:active_amplifier_boundaries_y_px and :attr:active_amplifier_boundaries_x_px translate them into ROI coordinates.

()
amplifier_boundaries_x_px tuple[int, ...]

Optional exact internal amplifier split coordinates on the full detector. Empty tuples divide resolution equally according to amplifier_layout. :attr:active_amplifier_boundaries_y_px and :attr:active_amplifier_boundaries_x_px translate them into ROI coordinates.

()
amplifier_gain_factors tuple[float, ...] | None

Optional exact row-major multiplicative conversion-gain factors, one per amplifier. These override stochastic amp_gain_nonuniformity draws.

None
amplifier_offsets_adu tuple[float, ...] | None

Optional exact row-major additive bias offsets in ADU, one per amplifier. These override stochastic amp_offset_spread_adu draws.

None
amp_gain_nonuniformity float

Fractional RMS spread of per-amplifier gain about gain_e_per_adu (a fixed pattern keyed on fixed_pattern_seed). Ignored for a single amplifier.

0.0
amp_offset_spread_adu float

RMS spread of per-amplifier bias offset in ADU, about bias_offset_adu (fixed pattern). Ignored for a single amplifier.

0.0
cosmic_ray_track_length_px float

Mean length in pixels of cosmic-ray tracks. 0 keeps the single-pixel hit model; a positive value draws an exponential track length and a random direction per hit, depositing the charge along the track (glancing muons).

0.0
bad_column_fraction float

Fraction of columns that are defective (dead): a fixed, deterministic set of whole columns forced to zero signal in every frame — the bad columns a flat cannot rescue. 0 disables.

0.0
dead_pixel_fraction float

Fraction of individual pixels that are dead (zero response), a fixed map. 0 disables.

0.0
bias_structure_amplitude_adu float

Peak amplitude in ADU of a fixed, structured bias pattern (a smooth gradient plus per-column offsets) added on top of the flat bias_offset_adu pedestal. 0 keeps the bias a flat pedestal.

0.0
bias_channel_spread_adu float

RMS fixed offset in ADU between the interleaved readout channels. Requires readout_channel_count > 1.

0.0
bias_pixel_spread_adu float

RMS fixed pixel-scale bias texture in ADU, drawn once from fixed_pattern_seed. This is additive readout structure, not PRNU or dark-signal non-uniformity. 0 disables it.

0.0
bias_edge_amplitude_adu float

Additive fixed pedestal at the detector boundary and its exponential falloff scale in pixels. bias_edge_axis optionally restricts the rise to row edges (0) or column edges (1); None uses the full perimeter. Either numeric value at 0 disables the edge term.

0.0
bias_edge_scale_px float

Additive fixed pedestal at the detector boundary and its exponential falloff scale in pixels. bias_edge_axis optionally restricts the rise to row edges (0) or column edges (1); None uses the full perimeter. Either numeric value at 0 disables the edge term.

0.0
bias_edge_axis float

Additive fixed pedestal at the detector boundary and its exponential falloff scale in pixels. bias_edge_axis optionally restricts the rise to row edges (0) or column edges (1); None uses the full perimeter. Either numeric value at 0 disables the edge term.

0.0
bias_edge_secondary_amplitude_adu float
0.0
bias_edge_secondary_scale_px float
0.0
bias_edge_secondary_axis int | None

Optional second exponential edge pedestal. This represents detectors with a broad halo on one axis and a weaker, narrower halo on the other.

None
cosmic_ray_rate_per_cm2_s float

Cosmic-ray hit rate in events per cm^2 per second (sea level is ~5). The number of hits scales with sensor area and exposure; each deposits a burst of charge in a random pixel.

0.0
prnu float

Photo-response non-uniformity: fractional pixel-to-pixel variation in sensitivity (e.g. 0.01 for 1% RMS). Imprints a fixed multiplicative pattern on the photo signal (not the dark signal). Ignored for dark frames, where there is no light.

0.0
dark_current_e_per_s float

Dark current in electrons per pixel per second, specified at dark_current_ref_temp_c.

required
detector_glow_e_per_s float

Detector self-emission ("glow") in electrons per pixel per second, added to the dark signal (it scales with exposure and so is removed by an exposure-matched master dark). A uniform model of amplifier/array glow, relevant for IR arrays alongside the thermal background. 0 disables it.

0.0
dark_current_ref_temp_c float

Temperature (deg C) at which dark_current_e_per_s is quoted.

20.0
dark_current_doubling_temp_c float

Temperature increase (deg C) that doubles the dark current. Typical CCD/CMOS silicon values are 5-8 C.

6.3
em_gain float

Mean gain of the stochastic multiplication stage: the EM register of an EMCCD or the avalanche gain of an eAPD. 1.0 disables it (CCD/CMOS).

1.0
avalanche_gain_nonuniformity float

Fixed pixel-to-pixel avalanche-gain variation per natural logarithm of physical gain. The resulting log-normal multiplier has fractional width avalanche_gain_nonuniformity * log(em_gain). This is distinct from illumination PRNU and is 0 at unity gain.

0.0
excess_noise_factor float | None

Excess noise factor F of the gain stage, quantifying the extra noise from stochastic multiplication. F = 1 is noiseless multiplication; EMCCDs approach F = sqrt(2) ~ 1.41 at high gain; eAPDs are much quieter (F ~ 1.2-1.4). If None (default), an appropriate value is used for the sensor type (sqrt(2) for EMCCD, 1.0 otherwise) --- see :attr:gain_excess_noise_factor.

None
clock_induced_charge_e float

Clock-induced charge (spurious charge) in electrons per pixel per frame. Relevant mainly for EMCCD.

0.0
persistence_fraction float

Fraction of a frame's collected charge captured into traps as a latent image (image persistence), in [0, 1]. Relevant for IR arrays (eAPD). The trapped charge is released into subsequent frames of an :class:~getframes.observation.Observation (it needs the cross-frame state that :meth:Camera.observe_series provides). 0 disables persistence.

0.0
persistence_decay float

Fraction of the trapped charge released each subsequent frame, in [0, 1]. 1 dumps all latent charge into the very next frame; smaller values give a slowly fading ghost over several frames.

0.5
dark_current_nonuniformity float

Fractional pixel-to-pixel dark-signal non-uniformity (DSNU), e.g. 0.05 for 5% RMS. Models fixed-pattern structure in the dark signal.

0.0
hot_pixel_fraction float

Fraction of pixels that are "hot" (anomalously high dark current).

0.0
hot_pixel_factor float

Multiplicative dark-current factor applied to hot pixels.

100.0
fixed_pattern_seed int

Seed for the sensor's fixed-pattern noise (PRNU, DSNU, hot-pixel, read-noise scale, channel-offset, and bias-structure maps). These patterns are a property of the physical sensor, so they are the same in every frame this camera produces --- which is exactly what lets a master flat or dark capture and remove them. Two configs with the same seed and shape share a pattern; change it to mint a different sensor. Independent of the per-frame seed that drives shot/read noise.

0
manufacturer str | None

Optional provenance metadata.

None
model str | None

Optional provenance metadata.

None
notes str | None

Optional provenance metadata.

None
Source code in src/getframes/config.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
@dataclass(frozen=True, slots=True)
class CameraConfig:
    """Physical and electronic parameters of a camera/detector.

    All electron quantities are in electrons (``e-``); all digital quantities are
    in analog-to-digital units (ADU, sometimes called counts or DN).

    Parameters
    ----------
    name:
        Human-readable identifier (e.g. ``"Andor iKon-M 934"``).
    sensor_type:
        One of :class:`SensorType` (CCD, CMOS, EMCCD, EAPD). Selects the noise
        model; EMCCD and EAPD additionally use the stochastic gain stage.
    resolution:
        Sensor size as ``(height, width)`` in pixels, matching NumPy's row-major
        array convention.
    roi:
        Optional detector region of interest as ``(left, top, width, height)`` in
        unbinned full-detector pixels. :class:`~getframes.camera.Camera` accepts
        and returns arrays shaped ``(height, width)`` while detector effects are
        still simulated on the full ``resolution`` grid before cropping.
    pixel_size_um:
        Physical pixel pitch in microns. Informational; not used for dark frames.
    quantum_efficiency:
        Band-averaged quantum efficiency in ``[0, 1]``. Used by the signal path to
        convert photons to photoelectrons. Ignored for dark frames.
    qe_curve:
        Optional wavelength-resolved quantum efficiency
        (:class:`~getframes.spectral.QE`). When set, :meth:`Camera.observe`
        switches to spectral mode and computes a colour-dependent effective QE from
        each source's SED and the band's spectral response, instead of the scalar
        ``quantum_efficiency``. ``None`` keeps the band-averaged model.
    supported_binnings:
        Integer pixel-binning factors this sensor supports (must include ``1``).
        Passed to :meth:`Camera.expose`'s ``binning`` argument. Advisory metadata:
        the model bins whatever factor you ask for.
    binning_method:
        How this sensor combines binned pixels: ``"digital"`` (post-read software
        binning, read noise grows as the binning factor) or ``"on_chip"`` (pre-read
        charge-domain/hardware binning, one read noise per super-pixel). Consumed by
        :meth:`Camera.expose`'s ``binning_mode`` argument.
    full_well_e:
        Image-area (input) full-well capacity in electrons. Collected charge
        saturates here before any EM/avalanche multiplication stage.
    output_full_well_e:
        Optional post-multiplication output-register capacity in electrons. This
        limits amplified charge before conversion to ADU. ``None`` preserves the
        legacy behavior and uses ``full_well_e`` as the digitizer ceiling.
    bit_depth:
        ADC resolution in bits. The output saturates at ``2**bit_depth - 1``.
    gain_e_per_adu:
        Camera conversion gain in electrons per ADU. Electrons reaching the ADC
        are divided by this to produce counts.
    bias_offset_adu:
        Electronic offset (pedestal) added to every pixel, in ADU.
    read_noise_e:
        RMS read noise in electrons. When ``read_noise_nonuniformity`` is zero this
        is every pixel's read noise. Otherwise it is the *scale* of the per-pixel
        distribution, which is log-normal with unit mean --- so the mean per-pixel
        RMS is ``read_noise_e`` and the median is
        ``read_noise_e * exp(-read_noise_nonuniformity**2 / 2)``, a few percent
        lower. See ``read_noise_nonuniformity`` and ``read_noise_rts_fraction``.
    avalanche_input_noise_e:
        RMS per-read noise in input-referred electrons that scales with the mean
        avalanche gain. This empirical term captures gain-dependent tunnelling or
        multiplication-region noise that is not part of the output-amplifier
        ``read_noise_e``. It is added as an output-equivalent Gaussian with RMS
        ``avalanche_input_noise_e * em_gain``. Relevant only to gain-stage sensors;
        ``0`` disables it.
    avalanche_input_noise_gain_exponent, avalanche_input_noise_reference_gain:
        Optional sublinear gain scaling of the avalanche-noise output RMS. The
        ordinary input-referred result is multiplied by ``(em_gain /
        reference_gain)**(gain_exponent - 1)``. An exponent of ``1`` preserves
        linear input-referred scaling.
    read_noise_nonuniformity:
        Fractional pixel-to-pixel spread of the read-noise RMS (e.g. ``0.3`` for a
        30% log-normal spread). Models the per-pixel read-noise distribution of
        sCMOS sensors. ``0`` gives a single uniform read noise.

        The resulting per-pixel RMS is a *fixed* property of the sensor (drawn from
        ``fixed_pattern_seed``, like PRNU and DSNU), not re-drawn each frame, so a
        pixel's temporal noise is repeatable across a stack --- which is what is
        measured in practice.
    read_noise_rts_fraction:
        Fraction of pixels belonging to a second, noisier read-noise population,
        in ``[0, 1]``. These are the random-telegraph-signal (RTS) pixels of a real
        sCMOS array, whose trapped-charge switching gives the read-noise histogram a
        tail much heavier than the single log-normal of
        ``read_noise_nonuniformity``. ``0`` disables the second population.
        Measured values for back-illuminated sCMOS are around ``0.005-0.03``.
    read_noise_rts_factor:
        Multiplier applied to the read-noise RMS of the RTS population selected by
        ``read_noise_rts_fraction``. Ignored when that fraction is ``0``.
    readout_channel_count:
        Number of interleaved video-output channels. Channel ``c`` reads detector
        coordinates whose index along ``readout_channel_axis`` is congruent to
        ``c`` modulo this count. ``1`` disables channel structure. SAPHIRA uses 32
        parallel outputs interleaved across the row.
    readout_channel_axis:
        Detector axis carrying the interleaved channel assignment: ``0`` for rows
        or ``1`` for columns.
    read_noise_channel_nonuniformity:
        Log-normal fractional spread of read-noise RMS between interleaved output
        channels. The factors have unit mean and are fixed by
        ``fixed_pattern_seed``. ``0`` gives equal channel noise.
    read_noise_edge_factor, read_noise_edge_scale_px:
        Multiplicative rise in read-noise RMS at the detector boundary and its
        exponential falloff scale in pixels. A factor of ``1`` or a scale of ``0``
        disables the edge term.
    readout_common_mode_noise_adu:
        Frame-wide electronic offset noise RMS in ADU. Unlike the fixed bias map,
        this scalar is redrawn for each ordinary frame and therefore survives a
        master bias. ``0`` disables it.
    readout_common_mode_correlation:
        Lag-one correlation coefficient of common-mode noise in
        :meth:`Camera.nondestructive_series`, in ``(-1, 1)``. Ordinary independent
        frame methods still draw independent common-mode offsets.
    ndr_bias_offset_adu_per_s, ndr_bias_gain_coefficient_adu_per_s:
        Read-interval-dependent pedestal coefficients for nondestructive sequences.
        The added pedestal is ``read_interval * (offset + gain_coefficient *
        (em_gain - 1))`` ADU. These empirical terms describe read-rate and
        avalanche-dependent ROIC settling; both default to zero.
    ndr_common_mode_gain_noise_adu_per_s:
        Additional frame-wide common-mode RMS in an NDR sequence, equal to this
        coefficient times ``read_interval * (em_gain - 1)``. Defaults to zero.
    ndr_avalanche_input_noise_reference_interval_s,
    ndr_avalanche_input_noise_interval_exponent:
        Optional read-rate scaling of ``avalanche_input_noise_e`` in NDR series.
        Its effective input-referred RMS is multiplied by ``(read_interval /
        reference_interval)**exponent``. An exponent of ``0`` preserves the
        ordinary rate-independent behavior.
    ndr_reset_settling_input_e, ndr_reset_settling_scale_reads,
    ndr_reset_settling_reference_interval_s, ndr_reset_settling_interval_exponent:
        Input-referred amplitude and exponential read-index scale of the negative
        pedestal transient immediately following a global reset. The first read
        is lowered by ``ndr_reset_settling_input_e * em_gain / gain_e_per_adu``
        ADU at the reference read interval, then the term decays as
        ``exp(-read_index / scale_reads)``. A nonzero interval exponent scales the
        amplitude by ``(read_interval / reference_interval)**exponent``. Either
        amplitude or scale at ``0`` disables it.
    detector_glow_edge_scale_px:
        Exponential falloff scale, in pixels, of the ``detector_glow_e_per_s`` term
        away from the detector edges. Amplifier/array glow originates at the readout
        electronics on the array periphery, so real glow is edge-concentrated rather
        than uniform. ``0`` (the default) keeps the glow uniform. When positive, the
        map is renormalised so the *mean* glow over the array is still
        ``detector_glow_e_per_s``, which means the edges run hotter and the centre
        cooler than that figure. The pattern is fixed and exposure-scaling, so an
        exposure-matched master dark still removes it.
    nonlinearity:
        Fractional signal compression at full well, in ``[0, 0.5)``. The collected
        charge is bent as ``q -> q * (1 - nonlinearity * q / full_well_e)``, so a
        pixel at full well reads ``nonlinearity`` fraction low. ``0`` is perfectly
        linear. Superseded by ``nonlinearity_coeffs`` when that is given.
    nonlinearity_coeffs:
        Optional polynomial generalisation of ``nonlinearity``. A sequence
        ``(c1, c2, ...)`` defines the response multiplier
        ``q -> q * (1 + c1 * u + c2 * u**2 + ...)`` with ``u = q / full_well_e``, so
        an arbitrary measured nonlinearity curve (or look-up) can be reproduced.
        When set it replaces the single-parameter ``nonlinearity`` model. ``None``
        keeps the scalar model.
    cti:
        Charge-transfer inefficiency (CTI) of a CCD, the fraction of charge left
        behind per pixel-to-pixel transfer during readout, in ``[0, 1)``. A bright
        pixel ``r`` rows from the readout register undergoes ``r`` transfers and
        smears a deferred-charge tail away from the register. ``0`` is a perfect
        CCD. (Trap-driven deferral; the readout register is taken to be row 0.)
    blooming:
        When ``True``, charge collected above ``full_well_e`` spills (blooms) into
        the vertically adjacent pixels of the same column until it is below full
        well or runs off the array, charge-conserving — the bright bleed columns of
        a saturated CCD. ``False`` simply clips at full well.
    ipc_coupling:
        Inter-pixel capacitance (IPC): the fraction of each pixel's signal that
        couples capacitively into *each* of its four nearest neighbours at readout,
        in ``[0, 0.25)``. Applied as a charge-conserving 3x3 convolution (CMOS/IR
        hybrid arrays). ``0`` disables it.
    charge_diffusion_fwhm_px:
        Lateral charge-diffusion FWHM in *native pixels*. Photo-electrons random
        walk in the silicon before reaching a potential well, so the collected
        charge is the incident irradiance convolved with this Gaussian and only
        then integrated over each pixel's area. It is applied only by
        :func:`~getframes.apply_charge_diffusion` (or its
        :func:`~getframes.charge_diffusion_kernel`), which requires an
        oversampled irradiance map. :class:`Camera` receives an already
        integrated photon-rate map, so it does not apply diffusion a second time
        and records that fact in frame metadata. ``0`` disables it. Distinct from
        ``ipc_coupling``, which couples charge *after* collection.
    reset_noise_e:
        kTC / reset noise RMS in electrons. Ordinary exposures draw an independent
        per-pixel Gaussian; nondestructive reads share one draw per reset ramp.
        ``0`` disables it (or assumes correlated double sampling removes it).
    read_noise_correlated_fraction:
        Fraction of the read-noise *variance* that is common to every read of a
        nondestructive ramp, and therefore cancels when two reads are
        differenced. ``0`` (the default) makes every read an independent draw.

        Read noise measured from a single read is not all white. Reference-level
        drift, bias settling, and 1/f components persist across the microseconds
        between two reads of a correlated-double-sampling pair, and differencing
        removes them --- which is the entire reason CDS is used. A detector
        whose single-read noise is R and whose correlated fraction is ``f``
        therefore shows ``R * sqrt(2 * (1 - f))`` in CDS, which is *below* R
        whenever ``f > 0.5``.

        Fitting this from single-read data alone is impossible: only a
        differenced measurement separates the correlated part. Take it from a
        CDS measurement, or leave it at ``0`` and accept that the model will
        overstate CDS noise by up to ``sqrt(2 / (2 * (1 - f)))``.
    amplifier_layout:
        Multi-amplifier readout as ``(n_rows, n_cols)`` of amplifiers tiling the
        sensor (e.g. ``(2, 2)`` for a four-quadrant CCD). Each amplifier block gets
        its own small gain and offset error (see ``amp_gain_nonuniformity`` /
        ``amp_offset_spread_adu``), producing the characteristic seams. ``(1, 1)``
        is a single amplifier.
    amplifier_boundaries_y_px, amplifier_boundaries_x_px:
        Optional exact internal amplifier split coordinates on the full detector.
        Empty tuples divide ``resolution`` equally according to
        ``amplifier_layout``. :attr:`active_amplifier_boundaries_y_px` and
        :attr:`active_amplifier_boundaries_x_px` translate them into ROI
        coordinates.
    amplifier_gain_factors:
        Optional exact row-major multiplicative conversion-gain factors, one per
        amplifier. These override stochastic ``amp_gain_nonuniformity`` draws.
    amplifier_offsets_adu:
        Optional exact row-major additive bias offsets in ADU, one per amplifier.
        These override stochastic ``amp_offset_spread_adu`` draws.
    amp_gain_nonuniformity:
        Fractional RMS spread of per-amplifier gain about ``gain_e_per_adu`` (a
        fixed pattern keyed on ``fixed_pattern_seed``). Ignored for a single
        amplifier.
    amp_offset_spread_adu:
        RMS spread of per-amplifier bias offset in ADU, about ``bias_offset_adu``
        (fixed pattern). Ignored for a single amplifier.
    cosmic_ray_track_length_px:
        Mean length in pixels of cosmic-ray *tracks*. ``0`` keeps the single-pixel
        hit model; a positive value draws an exponential track length and a random
        direction per hit, depositing the charge along the track (glancing muons).
    bad_column_fraction:
        Fraction of columns that are defective (dead): a fixed, deterministic set of
        whole columns forced to zero signal in every frame — the bad columns a flat
        cannot rescue. ``0`` disables.
    dead_pixel_fraction:
        Fraction of individual pixels that are dead (zero response), a fixed map.
        ``0`` disables.
    bias_structure_amplitude_adu:
        Peak amplitude in ADU of a fixed, structured bias pattern (a smooth gradient
        plus per-column offsets) added on top of the flat ``bias_offset_adu``
        pedestal. ``0`` keeps the bias a flat pedestal.
    bias_channel_spread_adu:
        RMS fixed offset in ADU between the interleaved readout channels. Requires
        ``readout_channel_count > 1``.
    bias_pixel_spread_adu:
        RMS fixed pixel-scale bias texture in ADU, drawn once from
        ``fixed_pattern_seed``. This is additive readout structure, not PRNU or
        dark-signal non-uniformity. ``0`` disables it.
    bias_edge_amplitude_adu, bias_edge_scale_px, bias_edge_axis:
        Additive fixed pedestal at the detector boundary and its exponential
        falloff scale in pixels. ``bias_edge_axis`` optionally restricts the rise
        to row edges (``0``) or column edges (``1``); ``None`` uses the full
        perimeter. Either numeric value at ``0`` disables the edge term.
    bias_edge_secondary_amplitude_adu, bias_edge_secondary_scale_px,
    bias_edge_secondary_axis:
        Optional second exponential edge pedestal. This represents detectors
        with a broad halo on one axis and a weaker, narrower halo on the other.
    cosmic_ray_rate_per_cm2_s:
        Cosmic-ray hit rate in events per cm^2 per second (sea level is ~5). The
        number of hits scales with sensor area and exposure; each deposits a burst
        of charge in a random pixel.
    prnu:
        Photo-response non-uniformity: fractional pixel-to-pixel variation in
        sensitivity (e.g. ``0.01`` for 1% RMS). Imprints a fixed multiplicative
        pattern on the *photo* signal (not the dark signal). Ignored for dark
        frames, where there is no light.
    dark_current_e_per_s:
        Dark current in electrons per pixel per second, specified at
        ``dark_current_ref_temp_c``.
    detector_glow_e_per_s:
        Detector self-emission ("glow") in electrons per pixel per second, added to
        the dark signal (it scales with exposure and so is removed by an
        exposure-matched master dark). A uniform model of amplifier/array glow,
        relevant for IR arrays alongside the thermal background. ``0`` disables it.
    dark_current_ref_temp_c:
        Temperature (deg C) at which ``dark_current_e_per_s`` is quoted.
    dark_current_doubling_temp_c:
        Temperature increase (deg C) that doubles the dark current. Typical CCD/CMOS
        silicon values are 5-8 C.
    em_gain:
        Mean gain of the stochastic multiplication stage: the EM register of an
        EMCCD or the avalanche gain of an eAPD. ``1.0`` disables it (CCD/CMOS).
    avalanche_gain_nonuniformity:
        Fixed pixel-to-pixel avalanche-gain variation per natural logarithm of
        physical gain. The resulting log-normal multiplier has fractional width
        ``avalanche_gain_nonuniformity * log(em_gain)``. This is distinct from
        illumination PRNU and is ``0`` at unity gain.
    excess_noise_factor:
        Excess noise factor ``F`` of the gain stage, quantifying the extra noise
        from stochastic multiplication. ``F = 1`` is noiseless multiplication;
        EMCCDs approach ``F = sqrt(2) ~ 1.41`` at high gain; eAPDs are much
        quieter (``F ~ 1.2-1.4``). If ``None`` (default), an appropriate value is
        used for the sensor type (sqrt(2) for EMCCD, 1.0 otherwise) --- see
        :attr:`gain_excess_noise_factor`.
    clock_induced_charge_e:
        Clock-induced charge (spurious charge) in electrons per pixel per frame.
        Relevant mainly for EMCCD.
    persistence_fraction:
        Fraction of a frame's collected charge captured into traps as a latent
        image (image persistence), in ``[0, 1]``. Relevant for IR arrays (eAPD).
        The trapped charge is released into subsequent frames of an
        :class:`~getframes.observation.Observation` (it needs the cross-frame state
        that :meth:`Camera.observe_series` provides). ``0`` disables persistence.
    persistence_decay:
        Fraction of the trapped charge released each subsequent frame, in
        ``[0, 1]``. ``1`` dumps all latent charge into the very next frame; smaller
        values give a slowly fading ghost over several frames.
    dark_current_nonuniformity:
        Fractional pixel-to-pixel dark-signal non-uniformity (DSNU), e.g. ``0.05``
        for 5% RMS. Models fixed-pattern structure in the dark signal.
    hot_pixel_fraction:
        Fraction of pixels that are "hot" (anomalously high dark current).
    hot_pixel_factor:
        Multiplicative dark-current factor applied to hot pixels.
    fixed_pattern_seed:
        Seed for the sensor's *fixed-pattern* noise (PRNU, DSNU, hot-pixel,
        read-noise scale, channel-offset, and bias-structure maps). These patterns
        are a property of the physical sensor, so they are the
        *same in every frame* this camera produces --- which is exactly what lets a
        master flat or dark capture and remove them. Two configs with the same seed
        and shape share a pattern; change it to mint a different sensor. Independent
        of the per-frame ``seed`` that drives shot/read noise.
    manufacturer, model, notes:
        Optional provenance metadata.
    """

    name: str
    sensor_type: SensorType
    resolution: tuple[int, int]
    pixel_size_um: float
    quantum_efficiency: float
    full_well_e: float
    bit_depth: int
    gain_e_per_adu: float
    bias_offset_adu: float
    read_noise_e: float
    dark_current_e_per_s: float
    qe_curve: QE | None = None
    supported_binnings: tuple[int, ...] = (1,)
    binning_method: str = "digital"
    output_full_well_e: float | None = None
    detector_glow_e_per_s: float = 0.0
    prnu: float = 0.0
    avalanche_input_noise_e: float = 0.0
    avalanche_input_noise_gain_exponent: float = 1.0
    avalanche_input_noise_reference_gain: float = 1.0
    read_noise_nonuniformity: float = 0.0
    read_noise_rts_fraction: float = 0.0
    read_noise_rts_factor: float = 2.5
    readout_channel_count: int = 1
    readout_channel_axis: int = 1
    read_noise_channel_nonuniformity: float = 0.0
    read_noise_edge_factor: float = 1.0
    read_noise_edge_scale_px: float = 0.0
    readout_common_mode_noise_adu: float = 0.0
    readout_common_mode_correlation: float = 0.0
    ndr_bias_offset_adu_per_s: float = 0.0
    ndr_bias_gain_coefficient_adu_per_s: float = 0.0
    ndr_common_mode_gain_noise_adu_per_s: float = 0.0
    ndr_avalanche_input_noise_reference_interval_s: float = 1.0
    ndr_avalanche_input_noise_interval_exponent: float = 0.0
    ndr_reset_settling_input_e: float = 0.0
    ndr_reset_settling_scale_reads: float = 0.0
    ndr_reset_settling_reference_interval_s: float = 1.0
    ndr_reset_settling_interval_exponent: float = 0.0
    detector_glow_edge_scale_px: float = 0.0
    nonlinearity: float = 0.0
    nonlinearity_coeffs: tuple[float, ...] | None = None
    cti: float = 0.0
    blooming: bool = False
    ipc_coupling: float = 0.0
    charge_diffusion_fwhm_px: float = 0.0
    reset_noise_e: float = 0.0
    read_noise_correlated_fraction: float = 0.0
    amplifier_layout: tuple[int, int] = (1, 1)
    amplifier_boundaries_y_px: tuple[int, ...] = ()
    amplifier_boundaries_x_px: tuple[int, ...] = ()
    amplifier_gain_factors: tuple[float, ...] | None = None
    amplifier_offsets_adu: tuple[float, ...] | None = None
    amp_gain_nonuniformity: float = 0.0
    amp_offset_spread_adu: float = 0.0
    cosmic_ray_track_length_px: float = 0.0
    bad_column_fraction: float = 0.0
    dead_pixel_fraction: float = 0.0
    bias_structure_amplitude_adu: float = 0.0
    bias_channel_spread_adu: float = 0.0
    bias_pixel_spread_adu: float = 0.0
    bias_edge_amplitude_adu: float = 0.0
    bias_edge_scale_px: float = 0.0
    bias_edge_axis: int | None = None
    bias_edge_secondary_amplitude_adu: float = 0.0
    bias_edge_secondary_scale_px: float = 0.0
    bias_edge_secondary_axis: int | None = None
    cosmic_ray_rate_per_cm2_s: float = 0.0
    dark_current_ref_temp_c: float = 20.0
    dark_current_doubling_temp_c: float = 6.3
    em_gain: float = 1.0
    avalanche_gain_nonuniformity: float = 0.0
    excess_noise_factor: float | None = None
    clock_induced_charge_e: float = 0.0
    persistence_fraction: float = 0.0
    persistence_decay: float = 0.5
    dark_current_nonuniformity: float = 0.0
    hot_pixel_fraction: float = 0.0
    hot_pixel_factor: float = 100.0
    fixed_pattern_seed: int = 0
    manufacturer: str | None = None
    model: str | None = None
    notes: str | None = None
    extra: dict[str, Any] = field(default_factory=dict)
    roi: tuple[int, int, int, int] | None = None

    def __post_init__(self) -> None:
        # Normalise/validate without mutating frozen fields directly.
        object.__setattr__(self, "sensor_type", SensorType.coerce(self.sensor_type))
        object.__setattr__(self, "resolution", tuple(int(n) for n in self.resolution))
        object.__setattr__(self, "readout_channel_count", int(self.readout_channel_count))
        object.__setattr__(self, "readout_channel_axis", int(self.readout_channel_axis))
        if self.roi is not None:
            object.__setattr__(self, "roi", tuple(int(value) for value in self.roi))
        object.__setattr__(self, "amplifier_layout", tuple(int(n) for n in self.amplifier_layout))
        object.__setattr__(
            self,
            "amplifier_boundaries_y_px",
            tuple(int(value) for value in self.amplifier_boundaries_y_px),
        )
        object.__setattr__(
            self,
            "amplifier_boundaries_x_px",
            tuple(int(value) for value in self.amplifier_boundaries_x_px),
        )
        if self.amplifier_gain_factors is not None:
            object.__setattr__(
                self,
                "amplifier_gain_factors",
                tuple(float(value) for value in self.amplifier_gain_factors),
            )
        if self.amplifier_offsets_adu is not None:
            object.__setattr__(
                self,
                "amplifier_offsets_adu",
                tuple(float(value) for value in self.amplifier_offsets_adu),
            )
        object.__setattr__(
            self, "supported_binnings", tuple(int(n) for n in self.supported_binnings)
        )
        if self.nonlinearity_coeffs is not None:
            object.__setattr__(
                self, "nonlinearity_coeffs", tuple(float(c) for c in self.nonlinearity_coeffs)
            )
        self._validate()

    def _validate(self) -> None:
        if len(self.resolution) != 2 or any(n <= 0 for n in self.resolution):
            raise ValueError(f"resolution must be two positive ints, got {self.resolution!r}.")
        if self.roi is not None:
            if len(self.roi) != 4:
                raise ValueError("roi must be (left, top, width, height).")
            left, top, width, height = self.roi
            sensor_height, sensor_width = self.resolution
            if left < 0 or top < 0 or width <= 0 or height <= 0:
                raise ValueError("roi must have non-negative left/top and positive width/height.")
            if left + width > sensor_width or top + height > sensor_height:
                raise ValueError(
                    f"roi {self.roi!r} exceeds full detector resolution {self.resolution!r}."
                )
        if not 0.0 <= self.quantum_efficiency <= 1.0:
            raise ValueError("quantum_efficiency must be in [0, 1].")
        if self.bit_depth <= 0:
            raise ValueError("bit_depth must be positive.")
        if self.gain_e_per_adu <= 0:
            raise ValueError("gain_e_per_adu must be positive.")
        if self.read_noise_e < 0:
            raise ValueError("read_noise_e must be non-negative.")
        if not self.supported_binnings or any(n < 1 for n in self.supported_binnings):
            raise ValueError("supported_binnings must be positive ints.")
        if 1 not in self.supported_binnings:
            raise ValueError("supported_binnings must include 1 (unbinned readout).")
        if self.binning_method not in ("digital", "on_chip"):
            raise ValueError("binning_method must be 'digital' or 'on_chip'.")
        if self.prnu < 0:
            raise ValueError("prnu must be non-negative.")
        if self.read_noise_nonuniformity < 0:
            raise ValueError("read_noise_nonuniformity must be non-negative.")
        if self.avalanche_input_noise_e < 0:
            raise ValueError("avalanche_input_noise_e must be non-negative.")
        if self.avalanche_input_noise_e > 0 and not self.has_gain_stage:
            raise ValueError("avalanche_input_noise_e requires em_gain > 1.")
        if self.avalanche_input_noise_gain_exponent <= 0:
            raise ValueError("avalanche_input_noise_gain_exponent must be positive.")
        if self.avalanche_input_noise_reference_gain < 1:
            raise ValueError("avalanche_input_noise_reference_gain must be >= 1.")
        if not 0.0 <= self.read_noise_rts_fraction <= 1.0:
            raise ValueError("read_noise_rts_fraction must be in [0, 1].")
        if self.read_noise_rts_factor < 0:
            raise ValueError("read_noise_rts_factor must be non-negative.")
        if self.readout_channel_count < 1:
            raise ValueError("readout_channel_count must be >= 1.")
        if self.readout_channel_axis not in (0, 1):
            raise ValueError("readout_channel_axis must be 0 (rows) or 1 (columns).")
        if self.readout_channel_count > self.resolution[self.readout_channel_axis]:
            raise ValueError("readout_channel_count cannot exceed its detector axis length.")
        if self.read_noise_channel_nonuniformity < 0:
            raise ValueError("read_noise_channel_nonuniformity must be non-negative.")
        if self.read_noise_channel_nonuniformity > 0 and self.readout_channel_count == 1:
            raise ValueError("read_noise_channel_nonuniformity requires readout_channel_count > 1.")
        if self.read_noise_edge_factor < 1:
            raise ValueError("read_noise_edge_factor must be >= 1.")
        if self.read_noise_edge_scale_px < 0:
            raise ValueError("read_noise_edge_scale_px must be non-negative.")
        if self.readout_common_mode_noise_adu < 0:
            raise ValueError("readout_common_mode_noise_adu must be non-negative.")
        if not -1.0 < self.readout_common_mode_correlation < 1.0:
            raise ValueError("readout_common_mode_correlation must be in (-1, 1).")
        if self.ndr_bias_offset_adu_per_s < 0:
            raise ValueError("ndr_bias_offset_adu_per_s must be non-negative.")
        if self.ndr_bias_gain_coefficient_adu_per_s < 0:
            raise ValueError("ndr_bias_gain_coefficient_adu_per_s must be non-negative.")
        if self.ndr_common_mode_gain_noise_adu_per_s < 0:
            raise ValueError("ndr_common_mode_gain_noise_adu_per_s must be non-negative.")
        if self.ndr_avalanche_input_noise_reference_interval_s <= 0:
            raise ValueError("ndr_avalanche_input_noise_reference_interval_s must be positive.")
        if self.ndr_avalanche_input_noise_interval_exponent < 0:
            raise ValueError("ndr_avalanche_input_noise_interval_exponent must be non-negative.")
        if self.ndr_reset_settling_input_e < 0:
            raise ValueError("ndr_reset_settling_input_e must be non-negative.")
        if self.ndr_reset_settling_scale_reads < 0:
            raise ValueError("ndr_reset_settling_scale_reads must be non-negative.")
        if self.ndr_reset_settling_reference_interval_s <= 0:
            raise ValueError("ndr_reset_settling_reference_interval_s must be positive.")
        if self.ndr_reset_settling_interval_exponent < 0:
            raise ValueError("ndr_reset_settling_interval_exponent must be non-negative.")
        if self.detector_glow_edge_scale_px < 0:
            raise ValueError("detector_glow_edge_scale_px must be non-negative.")
        if not 0.0 <= self.nonlinearity < 0.5:
            raise ValueError("nonlinearity must be in [0, 0.5).")
        if self.nonlinearity_coeffs is not None and len(self.nonlinearity_coeffs) == 0:
            raise ValueError("nonlinearity_coeffs must be a non-empty sequence or None.")
        if not 0.0 <= self.cti < 1.0:
            raise ValueError("cti must be in [0, 1).")
        if not 0.0 <= self.ipc_coupling < 0.25:
            raise ValueError("ipc_coupling must be in [0, 0.25).")
        if not math.isfinite(self.charge_diffusion_fwhm_px) or self.charge_diffusion_fwhm_px < 0:
            raise ValueError("charge_diffusion_fwhm_px must be finite and non-negative.")
        if self.reset_noise_e < 0:
            raise ValueError("reset_noise_e must be non-negative.")
        if not 0.0 <= self.read_noise_correlated_fraction < 1.0:
            raise ValueError("read_noise_correlated_fraction must be in [0, 1).")
        if len(self.amplifier_layout) != 2 or any(n <= 0 for n in self.amplifier_layout):
            raise ValueError(
                f"amplifier_layout must be two positive ints, got {self.amplifier_layout!r}."
            )
        n_amp_rows, n_amp_cols = self.amplifier_layout
        for name, boundaries, expected, size in (
            (
                "amplifier_boundaries_y_px",
                self.amplifier_boundaries_y_px,
                n_amp_rows - 1,
                self.resolution[0],
            ),
            (
                "amplifier_boundaries_x_px",
                self.amplifier_boundaries_x_px,
                n_amp_cols - 1,
                self.resolution[1],
            ),
        ):
            if boundaries and (
                len(boundaries) != expected
                or tuple(sorted(set(boundaries))) != boundaries
                or any(value <= 0 or value >= size for value in boundaries)
            ):
                raise ValueError(
                    f"{name} must contain {expected} strictly increasing internal splits."
                )
        amplifier_count = n_amp_rows * n_amp_cols
        if self.amplifier_gain_factors is not None:
            if len(self.amplifier_gain_factors) != amplifier_count or any(
                not math.isfinite(value) or value <= 0 for value in self.amplifier_gain_factors
            ):
                raise ValueError(
                    "amplifier_gain_factors must contain one positive finite value per amplifier."
                )
            if self.amp_gain_nonuniformity > 0:
                raise ValueError(
                    "amplifier_gain_factors and amp_gain_nonuniformity are mutually exclusive."
                )
        if self.amplifier_offsets_adu is not None:
            if len(self.amplifier_offsets_adu) != amplifier_count or any(
                not math.isfinite(value) for value in self.amplifier_offsets_adu
            ):
                raise ValueError(
                    "amplifier_offsets_adu must contain one finite value per amplifier."
                )
            if self.amp_offset_spread_adu > 0:
                raise ValueError(
                    "amplifier_offsets_adu and amp_offset_spread_adu are mutually exclusive."
                )
        if self.amp_gain_nonuniformity < 0:
            raise ValueError("amp_gain_nonuniformity must be non-negative.")
        if self.amp_offset_spread_adu < 0:
            raise ValueError("amp_offset_spread_adu must be non-negative.")
        if self.cosmic_ray_track_length_px < 0:
            raise ValueError("cosmic_ray_track_length_px must be non-negative.")
        if not 0.0 <= self.bad_column_fraction <= 1.0:
            raise ValueError("bad_column_fraction must be in [0, 1].")
        if not 0.0 <= self.dead_pixel_fraction <= 1.0:
            raise ValueError("dead_pixel_fraction must be in [0, 1].")
        if self.bias_structure_amplitude_adu < 0:
            raise ValueError("bias_structure_amplitude_adu must be non-negative.")
        if self.bias_channel_spread_adu < 0:
            raise ValueError("bias_channel_spread_adu must be non-negative.")
        if self.bias_pixel_spread_adu < 0:
            raise ValueError("bias_pixel_spread_adu must be non-negative.")
        if self.bias_channel_spread_adu > 0 and self.readout_channel_count == 1:
            raise ValueError("bias_channel_spread_adu requires readout_channel_count > 1.")
        if self.bias_edge_amplitude_adu < 0:
            raise ValueError("bias_edge_amplitude_adu must be non-negative.")
        if self.bias_edge_scale_px < 0:
            raise ValueError("bias_edge_scale_px must be non-negative.")
        if self.bias_edge_axis not in (None, 0, 1):
            raise ValueError("bias_edge_axis must be None, 0, or 1.")
        if self.bias_edge_secondary_amplitude_adu < 0:
            raise ValueError("bias_edge_secondary_amplitude_adu must be non-negative.")
        if self.bias_edge_secondary_scale_px < 0:
            raise ValueError("bias_edge_secondary_scale_px must be non-negative.")
        if self.bias_edge_secondary_axis not in (None, 0, 1):
            raise ValueError("bias_edge_secondary_axis must be None, 0, or 1.")
        if self.cosmic_ray_rate_per_cm2_s < 0:
            raise ValueError("cosmic_ray_rate_per_cm2_s must be non-negative.")
        if self.dark_current_e_per_s < 0:
            raise ValueError("dark_current_e_per_s must be non-negative.")
        if self.detector_glow_e_per_s < 0:
            raise ValueError("detector_glow_e_per_s must be non-negative.")
        if self.dark_current_doubling_temp_c <= 0:
            raise ValueError("dark_current_doubling_temp_c must be positive.")
        if self.em_gain < 1.0:
            raise ValueError("em_gain must be >= 1.0 (use 1.0 to disable).")
        if self.avalanche_gain_nonuniformity < 0:
            raise ValueError("avalanche_gain_nonuniformity must be non-negative.")
        if self.excess_noise_factor is not None and self.excess_noise_factor < 1.0:
            raise ValueError("excess_noise_factor must be >= 1.0 (1.0 is noiseless).")
        if self.full_well_e <= 0:
            raise ValueError("full_well_e must be positive.")
        if self.output_full_well_e is not None and self.output_full_well_e <= 0:
            raise ValueError("output_full_well_e must be positive or None.")
        if not 0.0 <= self.hot_pixel_fraction <= 1.0:
            raise ValueError("hot_pixel_fraction must be in [0, 1].")
        if not 0.0 <= self.persistence_fraction <= 1.0:
            raise ValueError("persistence_fraction must be in [0, 1].")
        if not 0.0 <= self.persistence_decay <= 1.0:
            raise ValueError("persistence_decay must be in [0, 1].")
        if self.qe_curve is not None and not isinstance(self.qe_curve, QE):
            raise ValueError("qe_curve must be a getframes.spectral.QE instance or None.")

    @property
    def max_adu(self) -> int:
        """The saturation value of the ADC output."""
        return int(2**self.bit_depth - 1)

    @property
    def output_resolution(self) -> tuple[int, int]:
        """Unbinned camera output shape, accounting for an optional ROI."""
        if self.roi is None:
            return self.resolution
        _, _, width, height = self.roi
        return (height, width)

    @property
    def roi_slices(self) -> tuple[slice, slice]:
        """Full-detector array slices selecting the configured ROI."""
        if self.roi is None:
            return (slice(0, self.resolution[0]), slice(0, self.resolution[1]))
        left, top, width, height = self.roi
        return (slice(top, top + height), slice(left, left + width))

    def _full_amplifier_boundaries(self, *, axis: int) -> tuple[int, ...]:
        """Return full-detector amplifier splits, deriving equal ones if omitted."""
        configured = self.amplifier_boundaries_y_px if axis == 0 else self.amplifier_boundaries_x_px
        if configured:
            return configured
        size = self.resolution[axis]
        blocks = self.amplifier_layout[axis]
        block_size, remainder = divmod(size, blocks)
        edges: list[int] = []
        position = 0
        for index in range(blocks - 1):
            position += block_size + (index < remainder)
            edges.append(position)
        return tuple(edges)

    @property
    def active_amplifier_boundaries_y_px(self) -> tuple[int, ...]:
        """Amplifier row splits translated into coordinates of the active ROI."""
        top = 0 if self.roi is None else self.roi[1]
        height = self.output_resolution[0]
        return tuple(
            boundary - top
            for boundary in self._full_amplifier_boundaries(axis=0)
            if top < boundary < top + height
        )

    @property
    def active_amplifier_boundaries_x_px(self) -> tuple[int, ...]:
        """Amplifier column splits translated into coordinates of the active ROI."""
        left = 0 if self.roi is None else self.roi[0]
        width = self.output_resolution[1]
        return tuple(
            boundary - left
            for boundary in self._full_amplifier_boundaries(axis=1)
            if left < boundary < left + width
        )

    @property
    def has_gain_stage(self) -> bool:
        """Whether a stochastic multiplication stage (EM/avalanche) is active."""
        return self.em_gain > 1.0

    @property
    def gain_excess_noise_factor(self) -> float:
        """The effective excess noise factor ``F`` of the gain stage.

        Returns :attr:`excess_noise_factor` if set, else a sensible default for the
        sensor type: ``sqrt(2)`` for EMCCD (the high-gain limit) and ``1.0``
        (noiseless) otherwise.
        """
        if self.excess_noise_factor is not None:
            return self.excess_noise_factor
        if self.sensor_type is SensorType.EMCCD:
            return math.sqrt(2.0)
        return 1.0

    def dark_current_at(self, temperature_c: float) -> float:
        """Dark current (e-/pixel/s) scaled to ``temperature_c``.

        Uses the standard doubling-temperature model::

            D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
        """
        delta = temperature_c - self.dark_current_ref_temp_c
        exponent = delta / self.dark_current_doubling_temp_c
        return float(self.dark_current_e_per_s * 2.0**exponent)

    def replace(self, **changes: Any) -> CameraConfig:
        """Return a copy with the given fields overridden (like ``dataclasses.replace``)."""
        data = self.to_dict()
        data.update(changes)
        return CameraConfig.from_dict(data)

    def to_dict(self) -> dict[str, Any]:
        """Serialise to a plain dict (sensor_type rendered as its string value)."""
        data = asdict(self)
        data["sensor_type"] = self.sensor_type.value
        data["resolution"] = list(self.resolution)
        if self.roi is not None:
            data["roi"] = list(self.roi)
        data["amplifier_layout"] = list(self.amplifier_layout)
        data["amplifier_boundaries_y_px"] = list(self.amplifier_boundaries_y_px)
        data["amplifier_boundaries_x_px"] = list(self.amplifier_boundaries_x_px)
        if self.amplifier_gain_factors is not None:
            data["amplifier_gain_factors"] = list(self.amplifier_gain_factors)
        if self.amplifier_offsets_adu is not None:
            data["amplifier_offsets_adu"] = list(self.amplifier_offsets_adu)
        if self.nonlinearity_coeffs is not None:
            data["nonlinearity_coeffs"] = list(self.nonlinearity_coeffs)
        data["qe_curve"] = _serialize_qe_curve(self.qe_curve)
        return data

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> CameraConfig:
        """Build a config from a dict, ignoring unknown keys (stashed in ``extra``).

        A ``qe_curve`` may be given as a :class:`~getframes.spectral.QE` or as a
        mapping ``{"wavelength_nm": [...], "qe": [...]}`` (the form used in preset
        TOML files).
        """
        known = {f for f in cls.__dataclass_fields__ if f != "extra"}
        kwargs = {k: v for k, v in data.items() if k in known}
        if "qe_curve" in kwargs:
            kwargs["qe_curve"] = _parse_qe_curve(kwargs["qe_curve"])
        passthrough = dict(data.get("extra", {}))
        unknown = {k: v for k, v in data.items() if k not in known and k != "extra"}
        merged = {**passthrough, **unknown}
        if merged:
            kwargs["extra"] = merged
        return cls(**kwargs)

max_adu property

The saturation value of the ADC output.

output_resolution property

Unbinned camera output shape, accounting for an optional ROI.

roi_slices property

Full-detector array slices selecting the configured ROI.

active_amplifier_boundaries_y_px property

Amplifier row splits translated into coordinates of the active ROI.

active_amplifier_boundaries_x_px property

Amplifier column splits translated into coordinates of the active ROI.

has_gain_stage property

Whether a stochastic multiplication stage (EM/avalanche) is active.

gain_excess_noise_factor property

The effective excess noise factor F of the gain stage.

Returns :attr:excess_noise_factor if set, else a sensible default for the sensor type: sqrt(2) for EMCCD (the high-gain limit) and 1.0 (noiseless) otherwise.

dark_current_at(temperature_c)

Dark current (e-/pixel/s) scaled to temperature_c.

Uses the standard doubling-temperature model::

D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
Source code in src/getframes/config.py
781
782
783
784
785
786
787
788
789
790
def dark_current_at(self, temperature_c: float) -> float:
    """Dark current (e-/pixel/s) scaled to ``temperature_c``.

    Uses the standard doubling-temperature model::

        D(T) = D_ref * 2 ** ((T - T_ref) / T_double)
    """
    delta = temperature_c - self.dark_current_ref_temp_c
    exponent = delta / self.dark_current_doubling_temp_c
    return float(self.dark_current_e_per_s * 2.0**exponent)

replace(**changes)

Return a copy with the given fields overridden (like dataclasses.replace).

Source code in src/getframes/config.py
792
793
794
795
796
def replace(self, **changes: Any) -> CameraConfig:
    """Return a copy with the given fields overridden (like ``dataclasses.replace``)."""
    data = self.to_dict()
    data.update(changes)
    return CameraConfig.from_dict(data)

to_dict()

Serialise to a plain dict (sensor_type rendered as its string value).

Source code in src/getframes/config.py
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
def to_dict(self) -> dict[str, Any]:
    """Serialise to a plain dict (sensor_type rendered as its string value)."""
    data = asdict(self)
    data["sensor_type"] = self.sensor_type.value
    data["resolution"] = list(self.resolution)
    if self.roi is not None:
        data["roi"] = list(self.roi)
    data["amplifier_layout"] = list(self.amplifier_layout)
    data["amplifier_boundaries_y_px"] = list(self.amplifier_boundaries_y_px)
    data["amplifier_boundaries_x_px"] = list(self.amplifier_boundaries_x_px)
    if self.amplifier_gain_factors is not None:
        data["amplifier_gain_factors"] = list(self.amplifier_gain_factors)
    if self.amplifier_offsets_adu is not None:
        data["amplifier_offsets_adu"] = list(self.amplifier_offsets_adu)
    if self.nonlinearity_coeffs is not None:
        data["nonlinearity_coeffs"] = list(self.nonlinearity_coeffs)
    data["qe_curve"] = _serialize_qe_curve(self.qe_curve)
    return data

from_dict(data) classmethod

Build a config from a dict, ignoring unknown keys (stashed in extra).

A qe_curve may be given as a :class:~getframes.spectral.QE or as a mapping {"wavelength_nm": [...], "qe": [...]} (the form used in preset TOML files).

Source code in src/getframes/config.py
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
@classmethod
def from_dict(cls, data: dict[str, Any]) -> CameraConfig:
    """Build a config from a dict, ignoring unknown keys (stashed in ``extra``).

    A ``qe_curve`` may be given as a :class:`~getframes.spectral.QE` or as a
    mapping ``{"wavelength_nm": [...], "qe": [...]}`` (the form used in preset
    TOML files).
    """
    known = {f for f in cls.__dataclass_fields__ if f != "extra"}
    kwargs = {k: v for k, v in data.items() if k in known}
    if "qe_curve" in kwargs:
        kwargs["qe_curve"] = _parse_qe_curve(kwargs["qe_curve"])
    passthrough = dict(data.get("extra", {}))
    unknown = {k: v for k, v in data.items() if k not in known and k != "extra"}
    merged = {**passthrough, **unknown}
    if merged:
        kwargs["extra"] = merged
    return cls(**kwargs)

SensorType

getframes.config.SensorType

Bases: str, Enum

The detector architecture, which selects the noise model used.

Source code in src/getframes/config.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class SensorType(str, Enum):
    """The detector architecture, which selects the noise model used."""

    CCD = "CCD"
    CMOS = "CMOS"
    EMCCD = "EMCCD"
    EAPD = "EAPD"  # electron-avalanche photodiode (e.g. SAPHIRA IR arrays)
    SCMOS = "SCMOS"  # scientific CMOS (per-pixel read noise, rolling shutter)

    @classmethod
    def coerce(cls, value: SensorType | str) -> SensorType:
        """Accept either a :class:`SensorType` or a case-insensitive string."""
        if isinstance(value, cls):
            return value
        try:
            return cls(str(value).upper())
        except ValueError as exc:  # pragma: no cover - trivial
            valid = ", ".join(s.value for s in cls)
            raise ValueError(f"Unknown sensor type {value!r}. Expected one of: {valid}.") from exc

coerce(value) classmethod

Accept either a :class:SensorType or a case-insensitive string.

Source code in src/getframes/config.py
28
29
30
31
32
33
34
35
36
37
@classmethod
def coerce(cls, value: SensorType | str) -> SensorType:
    """Accept either a :class:`SensorType` or a case-insensitive string."""
    if isinstance(value, cls):
        return value
    try:
        return cls(str(value).upper())
    except ValueError as exc:  # pragma: no cover - trivial
        valid = ", ".join(s.value for s in cls)
        raise ValueError(f"Unknown sensor type {value!r}. Expected one of: {valid}.") from exc

Frame

getframes.frame.Frame dataclass

A single simulated image plus the metadata describing how it was made.

The pixel values live in :attr:data as a 2-D NumPy or CuPy array in ADU. np.asarray(frame) remains an explicit request for host NumPy storage and therefore copies a GPU frame; use frame.data to keep processing on device.

Attributes:

Name Type Description
data Any

2-D array of pixel values in ADU, shaped (height, width).

metadata dict[str, Any]

Free-form dictionary describing the simulation (camera name, exposure, temperature, frame type, etc.). Suitable for writing to a FITS header.

truth FrameTruth | None

Optional :class:FrameTruth holding the noise-free signal the frame was built from, for ground-truth comparisons. None if not requested.

Source code in src/getframes/frame.py
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
@dataclass(frozen=True)
class Frame:
    """A single simulated image plus the metadata describing how it was made.

    The pixel values live in :attr:`data` as a 2-D NumPy or CuPy array in ADU.
    ``np.asarray(frame)`` remains an explicit request for host NumPy storage and
    therefore copies a GPU frame; use ``frame.data`` to keep processing on device.

    Attributes
    ----------
    data:
        2-D array of pixel values in ADU, shaped ``(height, width)``.
    metadata:
        Free-form dictionary describing the simulation (camera name, exposure,
        temperature, frame type, etc.). Suitable for writing to a FITS header.
    truth:
        Optional :class:`FrameTruth` holding the noise-free signal the frame was
        built from, for ground-truth comparisons. ``None`` if not requested.
    """

    data: Any
    metadata: dict[str, Any] = field(default_factory=dict)
    truth: FrameTruth | None = None

    @property
    def shape(self) -> tuple[int, ...]:
        return tuple(self.data.shape)

    @property
    def dtype(self) -> Any:
        return self.data.dtype

    @property
    def device(self) -> str:
        """Storage device for :attr:`data` (``"cpu"`` or ``"gpu"``)."""
        return "gpu" if get_array_module(self.data).__name__ == "cupy" else "cpu"

    def __array__(self, dtype: Any = None, copy: bool | None = None) -> NDArray[Any]:
        host = to_numpy(self.data)
        if copy is False and host is not self.data:
            raise ValueError("a GPU Frame cannot become a NumPy array without copying")
        return np.asarray(host, dtype=dtype).copy() if copy else np.asarray(host, dtype=dtype)

    def binned(self, factor: int, *, method: str = "sum") -> Frame:
        """Digitally bin the frame into ``factor x factor`` super-pixels after readout.

        Models post-read (digital) binning: a read-out image is combined into
        coarser pixels in software, rather than charge being summed on-chip before
        the amplifier. With ``method="sum"`` the ADU of each ``factor x factor``
        block are added (the charge-combining convention, which also sums the bias
        pedestal and read noise in quadrature); ``method="mean"`` averages them.

        Parameters
        ----------
        factor:
            Positive integer block size. Both image dimensions must be divisible
            by it.
        method:
            ``"sum"`` (default) or ``"mean"``.

        Returns
        -------
        Frame:
            A new frame of shape ``(height // factor, width // factor)`` in ADU.
            Ground-truth (:attr:`truth`) is not propagated through binning and is
            ``None`` on the result; ``metadata`` is copied with a ``binning`` entry
            recording the applied factor.
        """
        if factor < 1:
            raise ValueError("binning factor must be a positive integer.")
        if method not in ("sum", "mean"):
            raise ValueError("method must be 'sum' or 'mean'.")
        data = self.data
        height, width = data.shape
        if height % factor or width % factor:
            raise ValueError(
                f"frame shape {data.shape} is not divisible by binning factor {factor}."
            )
        if factor == 1:
            binned = data.copy()
        else:
            blocks = data.reshape(height // factor, factor, width // factor, factor)
            binned = blocks.sum(axis=(1, 3)) if method == "sum" else blocks.mean(axis=(1, 3))
        metadata = dict(self.metadata)
        metadata["binning"] = int(metadata.get("binning", 1)) * factor
        return Frame(data=binned, metadata=metadata, truth=None)

    def stats(self) -> dict[str, float]:
        """Common host summary statistics (copies GPU data to NumPy)."""
        arr = np.asarray(to_numpy(self.data), dtype=float)
        return {
            "mean": float(arr.mean()),
            "median": float(np.median(arr)),
            "std": float(arr.std()),
            "min": float(arr.min()),
            "max": float(arr.max()),
        }

    def to_fits(self, path: str, overwrite: bool = False) -> None:
        """Write the frame to a FITS file (requires ``astropy``).

        Metadata keys are written to the FITS header where they fit the 8-character
        keyword and value-type constraints.
        """
        try:
            from astropy.io import fits
        except ImportError as exc:  # pragma: no cover - astropy is a core dependency
            raise ImportError(
                "Writing FITS files requires astropy (a core dependency of getframes); "
                "reinstall with: pip install getframes"
            ) from exc

        hdu = fits.PrimaryHDU(data=to_numpy(self.data))
        for key, value in self.metadata.items():
            if isinstance(value, (str, int, float, bool)):
                hdu.header[key[:8].upper()] = value
        hdu.writeto(path, overwrite=overwrite)

    def __repr__(self) -> str:
        ftype = self.metadata.get("frame_type", "frame")
        cam = self.metadata.get("camera", "?")
        return f"Frame(type={ftype!r}, camera={cam!r}, shape={self.shape}, dtype={self.dtype})"

device property

Storage device for :attr:data ("cpu" or "gpu").

binned(factor, *, method='sum')

Digitally bin the frame into factor x factor super-pixels after readout.

Models post-read (digital) binning: a read-out image is combined into coarser pixels in software, rather than charge being summed on-chip before the amplifier. With method="sum" the ADU of each factor x factor block are added (the charge-combining convention, which also sums the bias pedestal and read noise in quadrature); method="mean" averages them.

Parameters:

Name Type Description Default
factor int

Positive integer block size. Both image dimensions must be divisible by it.

required
method str

"sum" (default) or "mean".

'sum'

Returns:

Name Type Description
Frame Frame

A new frame of shape (height // factor, width // factor) in ADU. Ground-truth (:attr:truth) is not propagated through binning and is None on the result; metadata is copied with a binning entry recording the applied factor.

Source code in src/getframes/frame.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def binned(self, factor: int, *, method: str = "sum") -> Frame:
    """Digitally bin the frame into ``factor x factor`` super-pixels after readout.

    Models post-read (digital) binning: a read-out image is combined into
    coarser pixels in software, rather than charge being summed on-chip before
    the amplifier. With ``method="sum"`` the ADU of each ``factor x factor``
    block are added (the charge-combining convention, which also sums the bias
    pedestal and read noise in quadrature); ``method="mean"`` averages them.

    Parameters
    ----------
    factor:
        Positive integer block size. Both image dimensions must be divisible
        by it.
    method:
        ``"sum"`` (default) or ``"mean"``.

    Returns
    -------
    Frame:
        A new frame of shape ``(height // factor, width // factor)`` in ADU.
        Ground-truth (:attr:`truth`) is not propagated through binning and is
        ``None`` on the result; ``metadata`` is copied with a ``binning`` entry
        recording the applied factor.
    """
    if factor < 1:
        raise ValueError("binning factor must be a positive integer.")
    if method not in ("sum", "mean"):
        raise ValueError("method must be 'sum' or 'mean'.")
    data = self.data
    height, width = data.shape
    if height % factor or width % factor:
        raise ValueError(
            f"frame shape {data.shape} is not divisible by binning factor {factor}."
        )
    if factor == 1:
        binned = data.copy()
    else:
        blocks = data.reshape(height // factor, factor, width // factor, factor)
        binned = blocks.sum(axis=(1, 3)) if method == "sum" else blocks.mean(axis=(1, 3))
    metadata = dict(self.metadata)
    metadata["binning"] = int(metadata.get("binning", 1)) * factor
    return Frame(data=binned, metadata=metadata, truth=None)

stats()

Common host summary statistics (copies GPU data to NumPy).

Source code in src/getframes/frame.py
137
138
139
140
141
142
143
144
145
146
def stats(self) -> dict[str, float]:
    """Common host summary statistics (copies GPU data to NumPy)."""
    arr = np.asarray(to_numpy(self.data), dtype=float)
    return {
        "mean": float(arr.mean()),
        "median": float(np.median(arr)),
        "std": float(arr.std()),
        "min": float(arr.min()),
        "max": float(arr.max()),
    }

to_fits(path, overwrite=False)

Write the frame to a FITS file (requires astropy).

Metadata keys are written to the FITS header where they fit the 8-character keyword and value-type constraints.

Source code in src/getframes/frame.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def to_fits(self, path: str, overwrite: bool = False) -> None:
    """Write the frame to a FITS file (requires ``astropy``).

    Metadata keys are written to the FITS header where they fit the 8-character
    keyword and value-type constraints.
    """
    try:
        from astropy.io import fits
    except ImportError as exc:  # pragma: no cover - astropy is a core dependency
        raise ImportError(
            "Writing FITS files requires astropy (a core dependency of getframes); "
            "reinstall with: pip install getframes"
        ) from exc

    hdu = fits.PrimaryHDU(data=to_numpy(self.data))
    for key, value in self.metadata.items():
        if isinstance(value, (str, int, float, bool)):
            hdu.header[key[:8].upper()] = value
    hdu.writeto(path, overwrite=overwrite)

getframes.frame.FrameTruth dataclass

Noise-free ground truth a :class:Frame was generated from.

Useful for validating analysis pipelines against exactly what went in. All arrays are in electrons unless noted, shaped like the frame.

Attributes:

Name Type Description
mean_electrons Any

Noise-free total signal (photo + dark) per pixel, in electrons. This is the expectation value before shot noise, gain, and read noise.

mean_photoelectrons Any

Noise-free photo signal per pixel, in electrons (i.e. excluding dark).

photon_rate Any

The incident photon rate the frame was exposed to, in photons/s/pixel, as provided by the caller (a scalar for uniform illumination, else an array).

spectral_photon_rate Any | None

Optional wavelength-resolved incident photon-rate cube with shape (n_wavelength, height, width). This is populated by :meth:~getframes.camera.Camera.expose_spectral and is kept separate from the integrated photon_rate field.

wavelengths_nm Any | None

Wavelength nodes corresponding to spectral_photon_rate.

Source code in src/getframes/frame.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@dataclass(frozen=True)
class FrameTruth:
    """Noise-free ground truth a :class:`Frame` was generated from.

    Useful for validating analysis pipelines against exactly what went in. All
    arrays are in electrons unless noted, shaped like the frame.

    Attributes
    ----------
    mean_electrons:
        Noise-free total signal (photo + dark) per pixel, in electrons. This is
        the expectation value before shot noise, gain, and read noise.
    mean_photoelectrons:
        Noise-free photo signal per pixel, in electrons (i.e. excluding dark).
    photon_rate:
        The incident photon rate the frame was exposed to, in photons/s/pixel, as
        provided by the caller (a scalar for uniform illumination, else an array).
    spectral_photon_rate:
        Optional wavelength-resolved incident photon-rate cube with shape
        ``(n_wavelength, height, width)``. This is populated by
        :meth:`~getframes.camera.Camera.expose_spectral` and is kept separate from
        the integrated ``photon_rate`` field.
    wavelengths_nm:
        Wavelength nodes corresponding to ``spectral_photon_rate``.
    """

    mean_electrons: Any
    mean_photoelectrons: Any
    photon_rate: Any
    spectral_photon_rate: Any | None = None
    wavelengths_nm: Any | None = None

Array backends

getframes.backend.ArrayBackend dataclass

Array namespace and RNG factory for one detector execution device.

Source code in src/getframes/backend.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
@dataclass(frozen=True)
class ArrayBackend:
    """Array namespace and RNG factory for one detector execution device."""

    xp: Any
    device: str

    @property
    def is_cpu(self) -> bool:
        """Whether arrays live in host NumPy storage."""
        return self.device == "cpu"

    def asarray(self, value: Any, *, dtype: Any | None = None) -> Any:
        """Convert ``value`` to an array on this backend."""
        return self.xp.asarray(value, dtype=dtype)

    def default_rng(self, seed: Any = None, *, float_dtype: Any = np.float64) -> Any:
        """Create a backend-native random generator."""
        if self.is_cpu:
            return self.xp.random.default_rng(seed)
        # CuPy's Generator construction initializes device-side state and is much
        # slower than RandomState for the per-exposure seed contract. RandomState
        # still owns an independent, backend-native cuRAND stream and exposes all
        # distributions used by the detector chain.
        return _CuPyGenerator(self.xp.random.RandomState(_cupy_seed(seed)), self.xp, float_dtype)

    def convolve(self, array: Any, kernel: Any) -> Any:
        """Convolve with constant-zero boundary conditions on this backend."""
        if self.is_cpu:
            from scipy import ndimage

            return ndimage.convolve(array, kernel, mode="constant", cval=0.0)
        from cupyx.scipy import ndimage  # pragma: no cover - optional CUDA dependency

        return ndimage.convolve(array, kernel, mode="constant", cval=0.0)

    def scalar(self, value: Any) -> float:
        """Transfer one scalar to the host for validation or metadata."""
        item = value.item() if hasattr(value, "item") else value
        return float(item)

    def to_numpy(self, value: Any) -> np.ndarray[Any, Any]:
        """Copy an array to host NumPy storage at an explicit boundary."""
        if self.is_cpu:
            return np.asarray(value)
        return cast(np.ndarray[Any, Any], self.xp.asnumpy(value))

is_cpu property

Whether arrays live in host NumPy storage.

asarray(value, *, dtype=None)

Convert value to an array on this backend.

Source code in src/getframes/backend.py
79
80
81
def asarray(self, value: Any, *, dtype: Any | None = None) -> Any:
    """Convert ``value`` to an array on this backend."""
    return self.xp.asarray(value, dtype=dtype)

default_rng(seed=None, *, float_dtype=np.float64)

Create a backend-native random generator.

Source code in src/getframes/backend.py
83
84
85
86
87
88
89
90
91
def default_rng(self, seed: Any = None, *, float_dtype: Any = np.float64) -> Any:
    """Create a backend-native random generator."""
    if self.is_cpu:
        return self.xp.random.default_rng(seed)
    # CuPy's Generator construction initializes device-side state and is much
    # slower than RandomState for the per-exposure seed contract. RandomState
    # still owns an independent, backend-native cuRAND stream and exposes all
    # distributions used by the detector chain.
    return _CuPyGenerator(self.xp.random.RandomState(_cupy_seed(seed)), self.xp, float_dtype)

convolve(array, kernel)

Convolve with constant-zero boundary conditions on this backend.

Source code in src/getframes/backend.py
 93
 94
 95
 96
 97
 98
 99
100
101
def convolve(self, array: Any, kernel: Any) -> Any:
    """Convolve with constant-zero boundary conditions on this backend."""
    if self.is_cpu:
        from scipy import ndimage

        return ndimage.convolve(array, kernel, mode="constant", cval=0.0)
    from cupyx.scipy import ndimage  # pragma: no cover - optional CUDA dependency

    return ndimage.convolve(array, kernel, mode="constant", cval=0.0)

scalar(value)

Transfer one scalar to the host for validation or metadata.

Source code in src/getframes/backend.py
103
104
105
106
def scalar(self, value: Any) -> float:
    """Transfer one scalar to the host for validation or metadata."""
    item = value.item() if hasattr(value, "item") else value
    return float(item)

to_numpy(value)

Copy an array to host NumPy storage at an explicit boundary.

Source code in src/getframes/backend.py
108
109
110
111
112
def to_numpy(self, value: Any) -> np.ndarray[Any, Any]:
    """Copy an array to host NumPy storage at an explicit boundary."""
    if self.is_cpu:
        return np.asarray(value)
    return cast(np.ndarray[Any, Any], self.xp.asnumpy(value))

getframes.backend.get_backend(device='cpu')

Return the backend for device ("cpu" or "gpu").

CuPy is an optional dependency and is imported lazily only for "gpu". "cuda" and "cupy" are accepted aliases.

Source code in src/getframes/backend.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def get_backend(device: str = "cpu") -> ArrayBackend:
    """Return the backend for ``device`` (``"cpu"`` or ``"gpu"``).

    CuPy is an optional dependency and is imported lazily only for ``"gpu"``.
    ``"cuda"`` and ``"cupy"`` are accepted aliases.
    """
    name = str(device).lower()
    if name in {"cpu", "numpy"}:
        return _CPU_BACKEND
    if name in {"gpu", "cuda", "cupy"}:
        try:
            import cupy
        except ImportError as exc:  # pragma: no cover - depends on optional install
            raise ImportError(f"device={device!r} requires CuPy; install getframes[gpu]") from exc
        return ArrayBackend(cupy, "gpu")
    raise ValueError(f"unknown device {device!r}; expected 'cpu' or 'gpu'.")

getframes.backend.get_array_module(value)

Return NumPy or CuPy for an existing array without copying it.

Source code in src/getframes/backend.py
136
137
138
139
140
141
def get_array_module(value: Any) -> Any:
    """Return NumPy or CuPy for an existing array without copying it."""
    module = type(value).__module__.split(".", 1)[0]
    if module == "cupy":
        return get_backend("gpu").xp
    return np

getframes.backend.to_numpy(value)

Return value in host NumPy storage, copying device arrays explicitly.

Source code in src/getframes/backend.py
144
145
146
147
def to_numpy(value: Any) -> np.ndarray[Any, Any]:
    """Return ``value`` in host NumPy storage, copying device arrays explicitly."""
    module = type(value).__module__.split(".", 1)[0]
    return get_backend("gpu" if module == "cupy" else "cpu").to_numpy(value)

Reusable detector execution

getframes.noise.DetectorWorkspace

Reusable private scratch storage for repeated detector simulations.

A workspace is lazy: its arrays are allocated only when a compatible call to :func:simulate_frame or :meth:getframes.Camera.expose needs them. It may be reused sequentially, but not concurrently. Returned frame and truth arrays never alias workspace storage; only an explicit caller-owned out array is returned without a copy.

One workspace binds to the detector shape, working dtype, backend, and CUDA device of its first use. Construct a separate workspace for a different camera geometry or execution device.

Source code in src/getframes/noise.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
class DetectorWorkspace:
    """Reusable private scratch storage for repeated detector simulations.

    A workspace is lazy: its arrays are allocated only when a compatible call to
    :func:`simulate_frame` or :meth:`getframes.Camera.expose` needs them.  It may
    be reused sequentially, but not concurrently.  Returned frame and truth
    arrays never alias workspace storage; only an explicit caller-owned ``out``
    array is returned without a copy.

    One workspace binds to the detector shape, working dtype, backend, and CUDA
    device of its first use.  Construct a separate workspace for a different
    camera geometry or execution device.
    """

    def __init__(self) -> None:
        self._signature: tuple[str, int | None, tuple[int, int], str] | None = None
        self._buffers: dict[tuple[str, tuple[int, ...], str], Any] = {}
        self._lock = threading.Lock()

    @staticmethod
    def _device_index(backend: ArrayBackend) -> int | None:
        if backend.is_cpu:
            return None
        return int(backend.xp.cuda.runtime.getDevice())

    @contextmanager
    def _using(
        self,
        backend: ArrayBackend,
        shape: tuple[int, int],
        float_dtype: DTypeLike,
    ) -> Any:
        signature = (
            backend.device,
            self._device_index(backend),
            shape,
            np.dtype(float_dtype).str,
        )
        if not self._lock.acquire(blocking=False):
            raise RuntimeError("DetectorWorkspace cannot be used concurrently.")
        try:
            if self._signature is None:
                self._signature = signature
            elif self._signature != signature:
                raise ValueError(
                    "DetectorWorkspace is already bound to a different detector "
                    "shape, precision, backend, or CUDA device."
                )
            yield self
        finally:
            self._lock.release()

    def _buffer(
        self,
        name: str,
        backend: ArrayBackend,
        shape: tuple[int, ...],
        dtype: DTypeLike,
    ) -> Any:
        dtype_obj = np.dtype(dtype)
        key = (name, shape, dtype_obj.str)
        buffer = self._buffers.get(key)
        if buffer is None:
            buffer = backend.xp.empty(shape, dtype=dtype_obj)
            self._buffers[key] = buffer
        return buffer

Charge diffusion

getframes.noise.charge_diffusion_kernel(fwhm_px, *, oversampling)

Return a flux-normalized lateral charge-diffusion kernel.

The detector diffusion profile is represented by a circular Gaussian whose full width at half maximum is fwhm_px native pixels. Each returned tap is the Gaussian probability integrated over one focal-plane sample cell, rather than a point sample, and the finite four-sigma support is renormalized to unit sum. The kernel is intended for an oversampled focal-plane irradiance before native detector pixels collect charge.

Parameters:

Name Type Description Default
fwhm_px float

Lateral diffusion FWHM in native detector pixels. Zero returns an identity 1 x 1 kernel.

required
oversampling int

Focal-plane samples per native detector pixel. A nonzero width must span at least one sample at FWHM so the configured detector property cannot silently collapse to a numerical no-op.

required

Returns:

Type Description
ndarray

Odd, square, symmetric float64 convolution kernel with unit sum.

Source code in src/getframes/noise.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def charge_diffusion_kernel(fwhm_px: float, *, oversampling: int) -> NDArray[np.float64]:
    """Return a flux-normalized lateral charge-diffusion kernel.

    The detector diffusion profile is represented by a circular Gaussian whose
    full width at half maximum is ``fwhm_px`` native pixels. Each returned tap is
    the Gaussian probability integrated over one focal-plane sample cell, rather
    than a point sample, and the finite four-sigma support is renormalized to unit
    sum. The kernel is intended for an oversampled focal-plane irradiance before
    native detector pixels collect charge.

    Parameters
    ----------
    fwhm_px:
        Lateral diffusion FWHM in native detector pixels. Zero returns an identity
        ``1 x 1`` kernel.
    oversampling:
        Focal-plane samples per native detector pixel. A nonzero width must span
        at least one sample at FWHM so the configured detector property cannot
        silently collapse to a numerical no-op.

    Returns
    -------
    numpy.ndarray
        Odd, square, symmetric ``float64`` convolution kernel with unit sum.
    """
    if not isinstance(oversampling, (int, np.integer)) or isinstance(oversampling, bool):
        raise ValueError("oversampling must be a positive integer.")
    samples_per_pixel = int(oversampling)
    if samples_per_pixel < 1:
        raise ValueError("oversampling must be a positive integer.")
    width = float(fwhm_px)
    if not math.isfinite(width) or width < 0:
        raise ValueError("fwhm_px must be finite and non-negative.")
    if width == 0.0:
        return np.ones((1, 1), dtype=np.float64)
    if width * samples_per_pixel < 1.0:
        required = math.ceil(1.0 / width)
        raise ValueError(
            f"charge diffusion FWHM {width:g} px requires at least {required} "
            "samples per native pixel"
        )

    from scipy.special import erf

    sigma_px = width / _GAUSSIAN_FWHM_PER_SIGMA
    radius = max(
        1,
        math.ceil(_CHARGE_DIFFUSION_TRUNCATE_SIGMA * sigma_px * samples_per_pixel + 0.5),
    )
    centers_px = np.arange(-radius, radius + 1, dtype=np.float64) / samples_per_pixel
    half_cell_px = 0.5 / samples_per_pixel
    scale = math.sqrt(2.0) * sigma_px
    weights: NDArray[np.float64] = np.asarray(
        0.5 * (erf((centers_px + half_cell_px) / scale) - erf((centers_px - half_cell_px) / scale)),
        dtype=np.float64,
    )
    kernel: NDArray[np.float64] = np.multiply.outer(weights, weights)
    kernel /= kernel.sum()
    return kernel

getframes.noise.apply_charge_diffusion(values, fwhm_px, *, oversampling, backend=None)

Diffuse an oversampled irradiance map before pixel-area integration.

values is a two-dimensional irradiance or photon-rate map, or a batch of such maps, sampled at oversampling cells per native detector pixel. The returned map has the same shape and dtype. A zero width leaves values untouched. Charge that diffuses off the supplied map is lost at its edge.

Use this before summing focal-plane samples into native pixels. It accepts CPU NumPy and optional GPU CuPy arrays; the public kernel itself remains a portable NumPy array for callers that use another convolution implementation.

Parameters:

Name Type Description Default
values Any

Two-dimensional irradiance or photon-rate map, or a leading batch of maps, on the oversampled focal-plane grid.

required
fwhm_px float

Gaussian lateral charge-diffusion FWHM in native detector pixels.

required
oversampling int

Number of focal-plane grid samples per native detector pixel.

required
backend ArrayBackend | None

Array backend containing values. Defaults to NumPy.

None

Returns:

Type Description
array

Diffused array on the same backend, with the input shape and dtype.

Source code in src/getframes/noise.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def apply_charge_diffusion(
    values: Any,
    fwhm_px: float,
    *,
    oversampling: int,
    backend: ArrayBackend | None = None,
) -> Any:
    """Diffuse an oversampled irradiance map before pixel-area integration.

    ``values`` is a two-dimensional irradiance or photon-rate map, or a batch of
    such maps, sampled at ``oversampling`` cells per native detector pixel. The
    returned map has the same shape and dtype. A zero width leaves ``values``
    untouched. Charge that diffuses off the supplied map is lost at its edge.

    Use this before summing focal-plane samples into native pixels. It accepts
    CPU NumPy and optional GPU CuPy arrays; the public kernel itself remains a
    portable NumPy array for callers that use another convolution implementation.

    Parameters
    ----------
    values:
        Two-dimensional irradiance or photon-rate map, or a leading batch of
        maps, on the oversampled focal-plane grid.
    fwhm_px:
        Gaussian lateral charge-diffusion FWHM in native detector pixels.
    oversampling:
        Number of focal-plane grid samples per native detector pixel.
    backend:
        Array backend containing ``values``. Defaults to NumPy.

    Returns
    -------
    array
        Diffused array on the same backend, with the input shape and dtype.
    """
    if values.ndim not in (2, 3):
        raise ValueError("charge diffusion expects a 2-D map or a batch of 2-D maps.")
    if not np.issubdtype(values.dtype, np.floating):
        raise TypeError("charge diffusion expects a floating-point irradiance map.")
    kernel_host = charge_diffusion_kernel(fwhm_px, oversampling=oversampling)
    if fwhm_px == 0:
        return values
    resolved = backend or get_backend()
    kernel = resolved.asarray(kernel_host, dtype=values.dtype)
    if values.ndim == 3:
        kernel = kernel[None, ...]
    convolved = resolved.convolve(values, kernel)
    return convolved.astype(values.dtype, copy=False)

Calibration

getframes.calibrate

Calibration: combine frames into masters and reduce raw frames against them.

These helpers close the loop the library is built for: generate raw frames (each optionally carrying :class:~getframes.frame.FrameTruth), then reduce them with master calibration frames and compare the result to the ground truth.

The reduction follows the standard, exposure-matched CCD equation::

reduced = (raw - dark) / normalised(flat)

where dark is an exposure-matched master dark (which still contains the bias pedestal, so subtracting it removes bias and dark current together) and flat is a pedestal-free master flat (see :meth:getframes.Camera.master_flat). Pass bias instead of dark to subtract only the bias pedestal.

combine(frames, *, method='median', sigma=3.0)

Combine a stack of frames pixel-wise into a single master frame.

Parameters:

Name Type Description Default
frames Iterable[FrameLike]

An iterable of :class:~getframes.frame.Frame (or plain 2-D arrays), all the same shape. Averaging n independent frames reduces the random noise by roughly sqrt(n).

required
method str

"median" (default, robust to outliers such as cosmic rays), "mean", or "sigma_clip" (reject pixels more than sigma standard deviations from the per-pixel median, then average the rest).

'median'
sigma float

Clipping threshold for method="sigma_clip".

3.0

Returns:

Type Description
Frame

The master frame (ADU, float64), with metadata recording the combination. Metadata common to all inputs is preserved; frame_type is prefixed with master_ when the inputs agree.

Source code in src/getframes/calibrate.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def combine(
    frames: Iterable[FrameLike],
    *,
    method: str = "median",
    sigma: float = 3.0,
) -> Frame:
    """Combine a stack of frames pixel-wise into a single master frame.

    Parameters
    ----------
    frames:
        An iterable of :class:`~getframes.frame.Frame` (or plain 2-D arrays), all
        the same shape. Averaging ``n`` independent frames reduces the random noise
        by roughly ``sqrt(n)``.
    method:
        ``"median"`` (default, robust to outliers such as cosmic rays), ``"mean"``,
        or ``"sigma_clip"`` (reject pixels more than ``sigma`` standard deviations
        from the per-pixel median, then average the rest).
    sigma:
        Clipping threshold for ``method="sigma_clip"``.

    Returns
    -------
    Frame
        The master frame (ADU, ``float64``), with metadata recording the
        combination. Metadata common to all inputs is preserved; ``frame_type`` is
        prefixed with ``master_`` when the inputs agree.
    """
    if method not in _COMBINE_METHODS:
        raise ValueError(f"method must be one of {_COMBINE_METHODS}, got {method!r}.")

    frame_list = list(frames)
    if not frame_list:
        raise ValueError("combine() needs at least one frame.")
    stack = np.stack([_as_array(f) for f in frame_list], axis=0)

    if method == "mean":
        data = stack.mean(axis=0)
    elif method == "median":
        data = np.median(stack, axis=0)
    else:  # sigma_clip
        median = np.median(stack, axis=0)
        std = stack.std(axis=0)
        # Keep pixels within sigma*std of the per-pixel median; where std == 0 every
        # value is identical, so keep them all.
        keep = (std == 0.0) | (np.abs(stack - median) <= sigma * std)
        kept = np.where(keep, stack, np.nan)
        with np.errstate(invalid="ignore"):
            data = np.nanmean(kept, axis=0)
        # Pixels with everything clipped (shouldn't happen) fall back to the median.
        data = np.where(np.isnan(data), median, data)

    metadata = _master_metadata(frame_list, method)
    return Frame(data=np.asarray(data, dtype=np.float64), metadata=metadata)

calibrate(raw, *, bias=None, dark=None, flat=None, dark_scale=1.0)

Reduce a raw frame with master calibration frames.

Performs, in order: subtract the additive pedestal (an exposure-matched master dark if given, else a bias), then divide by the normalised flat::

out = raw - dark_scale * dark        # or raw - bias if no dark
out = out / (flat / mean(flat))

Parameters:

Name Type Description Default
raw FrameLike

The frame to reduce (a :class:~getframes.frame.Frame or array, in ADU).

required
bias FrameLike | None

Master bias. Subtracted only when dark is not given (a master dark already contains the bias pedestal).

None
dark FrameLike | None

Exposure-matched master dark (including bias). Subtracted from raw.

None
flat FrameLike | None

Pedestal-free master flat. Divided out after normalising it to unit mean, so only its relative pixel-to-pixel response remains.

None
dark_scale float

Multiplier applied to dark before subtraction, to scale a master dark to a different exposure (use with a separately subtracted bias for strict correctness; 1.0 for the matched-exposure default).

1.0

Returns:

Type Description
Frame

The reduced frame (float64, in ADU), with frame_type="reduced" and provenance describing the steps applied.

Source code in src/getframes/calibrate.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def calibrate(
    raw: FrameLike,
    *,
    bias: FrameLike | None = None,
    dark: FrameLike | None = None,
    flat: FrameLike | None = None,
    dark_scale: float = 1.0,
) -> Frame:
    """Reduce a raw frame with master calibration frames.

    Performs, in order: subtract the additive pedestal (an exposure-matched master
    ``dark`` if given, else a ``bias``), then divide by the normalised ``flat``::

        out = raw - dark_scale * dark        # or raw - bias if no dark
        out = out / (flat / mean(flat))

    Parameters
    ----------
    raw:
        The frame to reduce (a :class:`~getframes.frame.Frame` or array, in ADU).
    bias:
        Master bias. Subtracted only when ``dark`` is not given (a master dark
        already contains the bias pedestal).
    dark:
        Exposure-matched master dark (including bias). Subtracted from ``raw``.
    flat:
        Pedestal-free master flat. Divided out after normalising it to unit mean,
        so only its relative pixel-to-pixel response remains.
    dark_scale:
        Multiplier applied to ``dark`` before subtraction, to scale a master dark
        to a different exposure (use with a separately subtracted ``bias`` for
        strict correctness; ``1.0`` for the matched-exposure default).

    Returns
    -------
    Frame
        The reduced frame (``float64``, in ADU), with ``frame_type="reduced"`` and
        provenance describing the steps applied.
    """
    out = _as_array(raw)
    steps: list[str] = []

    if dark is not None:
        out = out - dark_scale * _as_array(dark)
        steps.append("dark")
    elif bias is not None:
        out = out - _as_array(bias)
        steps.append("bias")

    if flat is not None:
        flat_arr = _as_array(flat)
        norm = float(flat_arr.mean())
        if norm == 0.0:
            raise ValueError("flat has zero mean; cannot normalise.")
        # Guard against divide-by-zero in dead pixels: leave them unscaled.
        safe = np.where(flat_arr != 0.0, flat_arr, norm)
        out = out * (norm / safe)
        steps.append("flat")

    metadata: dict[str, Any] = {}
    if isinstance(raw, Frame):
        metadata.update(raw.metadata)
    metadata["frame_type"] = "reduced"
    metadata["calibration"] = steps
    return Frame(data=out, metadata=metadata)

Presets

getframes.presets

Built-in library of camera/detector presets.

Presets are stored as TOML files in :mod:getframes.presets.data. They are loaded lazily and cached. Add a new camera by dropping a <name>.toml file into that directory (see the existing files for the schema) — no code changes required.

available_presets()

Return the sorted list of available preset names.

from getframes import available_presets "andor_ikon_m934" in available_presets() True

Source code in src/getframes/presets/__init__.py
38
39
40
41
42
43
44
45
def available_presets() -> list[str]:
    """Return the sorted list of available preset names.

    >>> from getframes import available_presets
    >>> "andor_ikon_m934" in available_presets()
    True
    """
    return list(_preset_files())

preset_info()

Return lightweight descriptors (name, manufacturer, model, sensor_type) for each preset.

Source code in src/getframes/presets/__init__.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def preset_info() -> list[dict[str, Any]]:
    """Return lightweight descriptors (name, manufacturer, model, sensor_type) for each preset."""
    info: list[dict[str, Any]] = []
    for slug in available_presets():
        data = _read_preset(slug)
        info.append(
            {
                "preset": slug,
                "name": data.get("name", slug),
                "manufacturer": data.get("manufacturer"),
                "model": data.get("model"),
                "sensor_type": data.get("sensor_type"),
            }
        )
    return info

load_preset(name)

Load a preset by name and return a :class:~getframes.config.CameraConfig.

Parameters:

Name Type Description Default
name str

A preset slug, e.g. "andor_ikon_m934". See :func:available_presets.

required
Source code in src/getframes/presets/__init__.py
75
76
77
78
79
80
81
82
83
84
85
86
87
def load_preset(name: str) -> CameraConfig:
    """Load a preset by name and return a :class:`~getframes.config.CameraConfig`.

    Parameters
    ----------
    name:
        A preset slug, e.g. ``"andor_ikon_m934"``. See :func:`available_presets`.
    """
    from ..config import CameraConfig

    data = dict(_read_preset(name))
    data.setdefault("name", name)
    return CameraConfig.from_dict(data)

Scene & optics

getframes.scene.scene.Scene dataclass

A focal-plane scene that renders to an incident photon-rate map.

Parameters:

Name Type Description Default
shape tuple[int, int]

Output size as (height, width) in pixels; should match the camera you intend to observe it with.

required
optics Telescope

The :class:~getframes.scene.optics.Telescope providing collecting area, throughput, plate scale, and the magnitude conversion.

required
psf PSF

The :class:~getframes.scene.psf.PSF used to spread each source.

required
sources Sequence[Source]

The sources in the field (point, extended, catalog, or uniform).

tuple()
sky Sky | None

Optional uniform sky background.

None
thermal Thermal | None

Optional :class:~getframes.scene.thermal.Thermal graybody background (warm optics / enclosure emission), added as a uniform background like the sky. Dominant in the thermal infrared; needs a band with a spectral response.

None
wcs WCSInfo | None

Optional :class:~getframes.scene.wcs.WCSInfo tagging the frame with sky coordinates; its FITS header cards are copied into the observed frame's metadata, and sources placed by RA/Dec are projected through it.

None
Source code in src/getframes/scene/scene.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
@dataclass
class Scene:
    """A focal-plane scene that renders to an incident photon-rate map.

    Parameters
    ----------
    shape:
        Output size as ``(height, width)`` in pixels; should match the camera you
        intend to observe it with.
    optics:
        The :class:`~getframes.scene.optics.Telescope` providing collecting area,
        throughput, plate scale, and the magnitude conversion.
    psf:
        The :class:`~getframes.scene.psf.PSF` used to spread each source.
    sources:
        The sources in the field (point, extended, catalog, or uniform).
    sky:
        Optional uniform sky background.
    thermal:
        Optional :class:`~getframes.scene.thermal.Thermal` graybody background (warm
        optics / enclosure emission), added as a uniform background like the sky.
        Dominant in the thermal infrared; needs a band with a spectral response.
    wcs:
        Optional :class:`~getframes.scene.wcs.WCSInfo` tagging the frame with sky
        coordinates; its FITS header cards are copied into the observed frame's
        metadata, and sources placed by RA/Dec are projected through it.
    """

    shape: tuple[int, int]
    optics: Telescope
    psf: PSF
    sources: Sequence[Source] = field(default_factory=tuple)
    sky: Sky | None = None
    thermal: Thermal | None = None
    wcs: WCSInfo | None = None

    def __post_init__(self) -> None:
        self.shape = tuple(int(n) for n in self.shape)  # type: ignore[assignment]
        if len(self.shape) != 2 or any(n <= 0 for n in self.shape):
            raise ValueError(f"shape must be two positive ints, got {self.shape!r}.")

    def add(self, *sources: Source) -> None:
        """Append one or more sources to the scene."""
        self.sources = [*self.sources, *sources]

    def _source_photon_rate(self, source: Source, time_s: float | None = None) -> float:
        """Total photons/s reaching the detector from a single source.

        When ``time_s`` is given and the source carries a
        :class:`~getframes.scene.sources.LightCurve`, the baseline rate is scaled by
        ``brightness(time_s)`` so the source varies in time.
        """
        return source.total_photon_rate(self.optics, time_s)

    def _pixel_transform(self) -> Callable[[float, float], tuple[float, float]] | None:
        """The distortion remap for source positions (``None`` if no distortion)."""
        distortion = self.optics.distortion
        if distortion is None:
            return None
        height, width = self.shape
        cx, cy = (width - 1) / 2.0, (height - 1) / 2.0
        return lambda x, y: distortion.apply(x, y, cx, cy)

    def _render(
        self,
        qe_scale: Callable[[SED | None], float],
        time_s: float | None,
        offset_xy: tuple[float, float],
        dtype: DTypeLike = np.float64,
    ) -> NDArray[np.float64]:
        """Deposit every source into a fresh map and apply vignetting."""
        ctx = RenderContext(
            optics=self.optics,
            psf=self.psf,
            wcs=self.wcs,
            time_s=time_s,
            offset_xy=offset_xy,
            qe_scale=qe_scale,
            pixel_transform=self._pixel_transform(),
        )
        image = np.zeros(self.shape, dtype=dtype)
        for source in self.sources:
            source.deposit(image, ctx)
        illumination = self.optics.illumination_map(self.shape)
        if illumination is not None:
            image *= illumination
        return image

    def photon_rate_map(
        self,
        time_s: float | None = None,
        offset_xy: tuple[float, float] = (0.0, 0.0),
        dtype: DTypeLike = np.float64,
    ) -> NDArray[np.float64]:
        """Render the sources through the PSF into a photons/s/pixel map.

        This is the incident rate at the detector *before* quantum efficiency; the
        camera applies QE, dark current, and noise when it exposes the scene.

        Parameters
        ----------
        time_s:
            Optional observation time in seconds. When set, sources carrying a
            :class:`~getframes.scene.sources.LightCurve` are sampled at this time.
            ``None`` (the default) renders the static, baseline scene.
        offset_xy:
            A whole-field pointing offset ``(dx, dy)`` in pixels added to every
            source position (models jitter / drift / dither). Defaults to no shift.
        dtype:
            Output (and working) floating-point dtype. ``float64`` is the exact
            default; ``float32`` halves the map's memory for the fast path.
        """
        return self._render(lambda _sed: 1.0, time_s, offset_xy, dtype)

    def sky_photon_rate(self) -> float:
        """Uniform sky background in photons/s/pixel (``0`` if no sky is set)."""
        if self.sky is None:
            return 0.0
        return self.optics.surface_brightness_photon_rate(self.sky.surface_brightness_mag_arcsec2)

    def thermal_photon_rate(self) -> float:
        """Uniform thermal (graybody) background in photons/s/pixel (``0`` if unset)."""
        if self.thermal is None:
            return 0.0
        return self.thermal.photon_rate(self.optics)

    @property
    def is_spectral_capable(self) -> bool:
        """Whether this scene's band carries a spectral response for spectral mode."""
        return self.optics.band is not None and self.optics.band.response is not None

    def photoelectron_rate_map(
        self,
        qe_curve: QE,
        time_s: float | None = None,
        offset_xy: tuple[float, float] = (0.0, 0.0),
        dtype: DTypeLike = np.float64,
    ) -> NDArray[np.float64]:
        """Render sources to a *photoelectron*-rate map (e-/s/pixel) in spectral mode.

        Like :meth:`photon_rate_map`, but each source's incident photon rate is
        multiplied by the colour-dependent effective QE for its SED (folding the
        detector ``qe_curve`` with the band's spectral response). The result is
        already in photoelectrons, so the camera applies a unit QE downstream.

        ``time_s`` and ``offset_xy`` behave as in :meth:`photon_rate_map`.

        Requires a band with a spectral response (see :attr:`is_spectral_capable`).
        """
        band = self.optics.band
        if band is None or band.response is None:
            raise ValueError("photoelectron_rate_map requires a band with a spectral response.")
        return self._render(lambda sed: band.effective_qe(qe_curve, sed), time_s, offset_xy, dtype)

    def sky_electron_rate(self, qe_curve: QE) -> float:
        """Uniform sky background in photoelectrons/s/pixel for spectral mode."""
        if self.sky is None:
            return 0.0
        band = self.optics.band
        if band is None or band.response is None:
            raise ValueError("sky_electron_rate requires a band with a spectral response.")
        return self.sky_photon_rate() * band.effective_qe(qe_curve, self.sky.sed)

    def thermal_electron_rate(self, qe_curve: QE) -> float:
        """Uniform thermal background in photoelectrons/s/pixel for spectral mode."""
        if self.thermal is None:
            return 0.0
        band = self.optics.band
        if band is None or band.response is None:
            raise ValueError("thermal_electron_rate requires a band with a spectral response.")
        return self.thermal_photon_rate() * band.effective_qe(qe_curve, self.thermal.photon_sed())

    def background_photon_rate(self) -> float:
        """Total uniform background (sky + thermal) in photons/s/pixel."""
        return self.sky_photon_rate() + self.thermal_photon_rate()

    def background_electron_rate(self, qe_curve: QE) -> float:
        """Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral)."""
        return self.sky_electron_rate(qe_curve) + self.thermal_electron_rate(qe_curve)

is_spectral_capable property

Whether this scene's band carries a spectral response for spectral mode.

add(*sources)

Append one or more sources to the scene.

Source code in src/getframes/scene/scene.py
68
69
70
def add(self, *sources: Source) -> None:
    """Append one or more sources to the scene."""
    self.sources = [*self.sources, *sources]

photon_rate_map(time_s=None, offset_xy=(0.0, 0.0), dtype=np.float64)

Render the sources through the PSF into a photons/s/pixel map.

This is the incident rate at the detector before quantum efficiency; the camera applies QE, dark current, and noise when it exposes the scene.

Parameters:

Name Type Description Default
time_s float | None

Optional observation time in seconds. When set, sources carrying a :class:~getframes.scene.sources.LightCurve are sampled at this time. None (the default) renders the static, baseline scene.

None
offset_xy tuple[float, float]

A whole-field pointing offset (dx, dy) in pixels added to every source position (models jitter / drift / dither). Defaults to no shift.

(0.0, 0.0)
dtype DTypeLike

Output (and working) floating-point dtype. float64 is the exact default; float32 halves the map's memory for the fast path.

float64
Source code in src/getframes/scene/scene.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def photon_rate_map(
    self,
    time_s: float | None = None,
    offset_xy: tuple[float, float] = (0.0, 0.0),
    dtype: DTypeLike = np.float64,
) -> NDArray[np.float64]:
    """Render the sources through the PSF into a photons/s/pixel map.

    This is the incident rate at the detector *before* quantum efficiency; the
    camera applies QE, dark current, and noise when it exposes the scene.

    Parameters
    ----------
    time_s:
        Optional observation time in seconds. When set, sources carrying a
        :class:`~getframes.scene.sources.LightCurve` are sampled at this time.
        ``None`` (the default) renders the static, baseline scene.
    offset_xy:
        A whole-field pointing offset ``(dx, dy)`` in pixels added to every
        source position (models jitter / drift / dither). Defaults to no shift.
    dtype:
        Output (and working) floating-point dtype. ``float64`` is the exact
        default; ``float32`` halves the map's memory for the fast path.
    """
    return self._render(lambda _sed: 1.0, time_s, offset_xy, dtype)

sky_photon_rate()

Uniform sky background in photons/s/pixel (0 if no sky is set).

Source code in src/getframes/scene/scene.py
141
142
143
144
145
def sky_photon_rate(self) -> float:
    """Uniform sky background in photons/s/pixel (``0`` if no sky is set)."""
    if self.sky is None:
        return 0.0
    return self.optics.surface_brightness_photon_rate(self.sky.surface_brightness_mag_arcsec2)

thermal_photon_rate()

Uniform thermal (graybody) background in photons/s/pixel (0 if unset).

Source code in src/getframes/scene/scene.py
147
148
149
150
151
def thermal_photon_rate(self) -> float:
    """Uniform thermal (graybody) background in photons/s/pixel (``0`` if unset)."""
    if self.thermal is None:
        return 0.0
    return self.thermal.photon_rate(self.optics)

photoelectron_rate_map(qe_curve, time_s=None, offset_xy=(0.0, 0.0), dtype=np.float64)

Render sources to a photoelectron-rate map (e-/s/pixel) in spectral mode.

Like :meth:photon_rate_map, but each source's incident photon rate is multiplied by the colour-dependent effective QE for its SED (folding the detector qe_curve with the band's spectral response). The result is already in photoelectrons, so the camera applies a unit QE downstream.

time_s and offset_xy behave as in :meth:photon_rate_map.

Requires a band with a spectral response (see :attr:is_spectral_capable).

Source code in src/getframes/scene/scene.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def photoelectron_rate_map(
    self,
    qe_curve: QE,
    time_s: float | None = None,
    offset_xy: tuple[float, float] = (0.0, 0.0),
    dtype: DTypeLike = np.float64,
) -> NDArray[np.float64]:
    """Render sources to a *photoelectron*-rate map (e-/s/pixel) in spectral mode.

    Like :meth:`photon_rate_map`, but each source's incident photon rate is
    multiplied by the colour-dependent effective QE for its SED (folding the
    detector ``qe_curve`` with the band's spectral response). The result is
    already in photoelectrons, so the camera applies a unit QE downstream.

    ``time_s`` and ``offset_xy`` behave as in :meth:`photon_rate_map`.

    Requires a band with a spectral response (see :attr:`is_spectral_capable`).
    """
    band = self.optics.band
    if band is None or band.response is None:
        raise ValueError("photoelectron_rate_map requires a band with a spectral response.")
    return self._render(lambda sed: band.effective_qe(qe_curve, sed), time_s, offset_xy, dtype)

sky_electron_rate(qe_curve)

Uniform sky background in photoelectrons/s/pixel for spectral mode.

Source code in src/getframes/scene/scene.py
181
182
183
184
185
186
187
188
def sky_electron_rate(self, qe_curve: QE) -> float:
    """Uniform sky background in photoelectrons/s/pixel for spectral mode."""
    if self.sky is None:
        return 0.0
    band = self.optics.band
    if band is None or band.response is None:
        raise ValueError("sky_electron_rate requires a band with a spectral response.")
    return self.sky_photon_rate() * band.effective_qe(qe_curve, self.sky.sed)

thermal_electron_rate(qe_curve)

Uniform thermal background in photoelectrons/s/pixel for spectral mode.

Source code in src/getframes/scene/scene.py
190
191
192
193
194
195
196
197
def thermal_electron_rate(self, qe_curve: QE) -> float:
    """Uniform thermal background in photoelectrons/s/pixel for spectral mode."""
    if self.thermal is None:
        return 0.0
    band = self.optics.band
    if band is None or band.response is None:
        raise ValueError("thermal_electron_rate requires a band with a spectral response.")
    return self.thermal_photon_rate() * band.effective_qe(qe_curve, self.thermal.photon_sed())

background_photon_rate()

Total uniform background (sky + thermal) in photons/s/pixel.

Source code in src/getframes/scene/scene.py
199
200
201
def background_photon_rate(self) -> float:
    """Total uniform background (sky + thermal) in photons/s/pixel."""
    return self.sky_photon_rate() + self.thermal_photon_rate()

background_electron_rate(qe_curve)

Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral).

Source code in src/getframes/scene/scene.py
203
204
205
def background_electron_rate(self, qe_curve: QE) -> float:
    """Total uniform background (sky + thermal) in photoelectrons/s/pixel (spectral)."""
    return self.sky_electron_rate(qe_curve) + self.thermal_electron_rate(qe_curve)

getframes.scene.sources.PointSource dataclass

Bases: Source

An unresolved point source (e.g. a star) at pixel position (x, y).

Specify the brightness in exactly one of two ways:

  • magnitude --- converted to a photon rate by the telescope's bandpass, or
  • photon_rate --- photons/s already arriving at the detector (post-optics, pre-quantum-efficiency), handy when you know the flux directly (e.g. an AO sub-aperture).

x is the column and y the row, in pixels; sub-pixel positions are fine.

sed is an optional spectral energy distribution (:class:~getframes.spectral.SED). It is used only in spectral mode, to give the source a colour-dependent effective QE; it has no effect on the integrated photon rate (the magnitude sets that). Defaults to a flat photon spectrum.

brightness is an optional :class:LightCurve. When set, the source's photon rate is multiplied by brightness(t) at each timestamp sampled by :meth:getframes.Camera.observe_series, making the source variable in time. A static :meth:getframes.Camera.observe (no time) ignores it.

name is an optional label used to key the source in an observation's per-frame truth light curve.

flux_sed is an alternative to magnitude/photon_rate: an absolute :class:~getframes.spectral.SED (SED.from_flux_density) whose integral over the band sets the photon rate directly (true spectral flux integration). When given it also serves as the colour SED for spectral mode.

Source code in src/getframes/scene/sources.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
@dataclass(frozen=True)
class PointSource(Source):
    """An unresolved point source (e.g. a star) at pixel position ``(x, y)``.

    Specify the brightness in exactly one of two ways:

    * ``magnitude`` --- converted to a photon rate by the telescope's bandpass, or
    * ``photon_rate`` --- photons/s already arriving at the detector (post-optics,
      pre-quantum-efficiency), handy when you know the flux directly (e.g. an AO
      sub-aperture).

    ``x`` is the column and ``y`` the row, in pixels; sub-pixel positions are fine.

    ``sed`` is an optional spectral energy distribution
    (:class:`~getframes.spectral.SED`). It is used only in spectral mode, to give
    the source a colour-dependent effective QE; it has no effect on the integrated
    photon rate (the magnitude sets that). Defaults to a flat photon spectrum.

    ``brightness`` is an optional :class:`LightCurve`. When set, the source's
    photon rate is multiplied by ``brightness(t)`` at each timestamp sampled by
    :meth:`getframes.Camera.observe_series`, making the source variable in time.
    A static :meth:`getframes.Camera.observe` (no time) ignores it.

    ``name`` is an optional label used to key the source in an observation's
    per-frame truth light curve.

    ``flux_sed`` is an alternative to ``magnitude``/``photon_rate``: an *absolute*
    :class:`~getframes.spectral.SED` (``SED.from_flux_density``) whose integral over
    the band sets the photon rate directly (true spectral flux integration). When
    given it also serves as the colour SED for spectral mode.
    """

    x: float
    y: float
    magnitude: float | None = None
    photon_rate: float | None = None
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None
    flux_sed: SED | None = None

    def __post_init__(self) -> None:
        _check_one_brightness(self.magnitude, self.photon_rate, self.flux_sed)

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        rate = _resolve_rate(self.magnitude, self.photon_rate, optics, self.flux_sed)
        return rate * _brightness_scale(self.brightness, time_s)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        rate = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(_color_sed(self))
        if rate <= 0:
            return
        px, py = ctx.place(self.x, self.y, None, None)
        ctx.psf.add_source(image, px, py, rate, ctx.optics.plate_scale_arcsec_per_pixel)

getframes.scene.sources.ExtendedSource dataclass

Bases: Source

A resolved source rendered from an analytic Sersic profile or a pixel array.

Place it by pixel (x, y) or, with a scene :class:~getframes.scene.wcs.WCSInfo, by sky (ra_deg, dec_deg). Total brightness is set by magnitude or an explicit photon_rate (exactly one), as for :class:PointSource; the profile distributes that total flux over pixels and is normalised to conserve it.

Construct via :meth:sersic (a Sersic surface-brightness profile, optionally elliptical) or :meth:from_array (an arbitrary normalised image, e.g. a galaxy cutout). The profile is rendered directly to the focal plane and is not additionally convolved with the scene PSF --- supply a pre-convolved array, or rely on the profile being broad compared with the PSF.

Source code in src/getframes/scene/sources.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
@dataclass(frozen=True)
class ExtendedSource(Source):
    """A resolved source rendered from an analytic Sersic profile or a pixel array.

    Place it by pixel ``(x, y)`` or, with a scene :class:`~getframes.scene.wcs.WCSInfo`,
    by sky ``(ra_deg, dec_deg)``. Total brightness is set by ``magnitude`` or an
    explicit ``photon_rate`` (exactly one), as for :class:`PointSource`; the profile
    distributes that total flux over pixels and is normalised to conserve it.

    Construct via :meth:`sersic` (a Sersic surface-brightness profile, optionally
    elliptical) or :meth:`from_array` (an arbitrary normalised image, e.g. a galaxy
    cutout). The profile is rendered directly to the focal plane and is *not*
    additionally convolved with the scene PSF --- supply a pre-convolved array, or
    rely on the profile being broad compared with the PSF.
    """

    x: float | None = None
    y: float | None = None
    ra_deg: float | None = None
    dec_deg: float | None = None
    magnitude: float | None = None
    photon_rate: float | None = None
    profile: NDArray[np.float64] | None = None
    sersic_n: float | None = None
    r_eff_arcsec: float | None = None
    ellipticity: float = 0.0
    position_angle_deg: float = 0.0
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None
    flux_sed: SED | None = None

    def __post_init__(self) -> None:
        _check_one_brightness(self.magnitude, self.photon_rate, self.flux_sed)
        if (self.profile is None) == (self.sersic_n is None):
            raise ValueError("ExtendedSource needs exactly one of a `profile` or Sersic params.")
        if not 0.0 <= self.ellipticity < 1.0:
            raise ValueError("ellipticity must be in [0, 1).")
        if self.sersic_n is not None:
            if self.sersic_n <= 0:
                raise ValueError("Sersic index n must be positive.")
            if self.r_eff_arcsec is None or self.r_eff_arcsec <= 0:
                raise ValueError("Sersic profile requires a positive r_eff_arcsec.")

    @classmethod
    def sersic(
        cls,
        *,
        x: float | None = None,
        y: float | None = None,
        ra: float | None = None,
        dec: float | None = None,
        magnitude: float | None = None,
        photon_rate: float | None = None,
        n: float = 1.0,
        r_eff_arcsec: float,
        ellipticity: float = 0.0,
        position_angle_deg: float = 0.0,
        sed: SED | None = None,
        brightness: LightCurve | None = None,
        name: str | None = None,
        flux_sed: SED | None = None,
    ) -> ExtendedSource:
        """A Sersic profile ``I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1])``.

        ``n=1`` is an exponential disk, ``n=4`` a de Vaucouleurs bulge. ``ellipticity``
        (``1 - b/a``) and ``position_angle_deg`` (of the major axis, measured
        counter-clockwise from the +x axis) shape an elliptical isophote.
        """
        return cls(
            x=x,
            y=y,
            ra_deg=ra,
            dec_deg=dec,
            magnitude=magnitude,
            photon_rate=photon_rate,
            sersic_n=n,
            r_eff_arcsec=r_eff_arcsec,
            ellipticity=ellipticity,
            position_angle_deg=position_angle_deg,
            sed=sed,
            brightness=brightness,
            name=name,
            flux_sed=flux_sed,
        )

    @classmethod
    def from_array(
        cls,
        image: NDArray[np.float64],
        *,
        x: float | None = None,
        y: float | None = None,
        ra: float | None = None,
        dec: float | None = None,
        magnitude: float | None = None,
        photon_rate: float | None = None,
        sed: SED | None = None,
        brightness: LightCurve | None = None,
        name: str | None = None,
        flux_sed: SED | None = None,
    ) -> ExtendedSource:
        """An arbitrary 2D ``image`` (e.g. a galaxy cutout) used as the profile.

        The array is normalised to unit sum and pasted centred on the source
        position at detector-pixel resolution, then scaled to the total flux.
        """
        arr = np.asarray(image, dtype=np.float64)
        if arr.ndim != 2 or arr.size == 0:
            raise ValueError("ExtendedSource.from_array needs a non-empty 2D image.")
        total = float(arr.sum())
        if total <= 0:
            raise ValueError("ExtendedSource.from_array image must have a positive sum.")
        return cls(
            x=x,
            y=y,
            ra_deg=ra,
            dec_deg=dec,
            magnitude=magnitude,
            photon_rate=photon_rate,
            profile=arr / total,
            sed=sed,
            brightness=brightness,
            name=name,
            flux_sed=flux_sed,
        )

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        rate = _resolve_rate(self.magnitude, self.photon_rate, optics, self.flux_sed)
        return rate * _brightness_scale(self.brightness, time_s)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        flux = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(_color_sed(self))
        if flux <= 0:
            return
        px, py = ctx.place(self.x, self.y, self.ra_deg, self.dec_deg)
        if self.profile is not None:
            _paste_centered(image, self.profile * flux, px, py)
        else:
            self._deposit_sersic(image, px, py, flux, ctx.optics.plate_scale_arcsec_per_pixel)

    def _deposit_sersic(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        assert self.sersic_n is not None and self.r_eff_arcsec is not None
        r_eff_pix = self.r_eff_arcsec / plate_scale_arcsec_per_pixel
        if r_eff_pix <= 0:
            raise ValueError("plate scale and r_eff must yield a positive r_eff in pixels.")
        n = self.sersic_n
        # Ciotti & Bertin (1999) approximation to b_n.
        b_n = 2.0 * n - 1.0 / 3.0 + 4.0 / (405.0 * n) + 46.0 / (25515.0 * n * n)
        q = 1.0 - self.ellipticity  # minor/major axis ratio

        # Stamp out to ~8 effective radii along the major axis captures the flux.
        radius = int(np.ceil(8.0 * r_eff_pix)) + 1
        height, width = image.shape
        ix, iy = round(x), round(y)
        x0, x1 = max(0, ix - radius), min(width, ix + radius + 1)
        y0, y1 = max(0, iy - radius), min(height, iy + radius + 1)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        theta = math.radians(self.position_angle_deg)
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        # Coordinates along (major, minor) axes of the ellipse.
        u = xs[None, :] * cos_t + ys[:, None] * sin_t
        v = -xs[None, :] * sin_t + ys[:, None] * cos_t
        r = np.sqrt(u**2 + (v / q) ** 2) / r_eff_pix
        profile = np.exp(-b_n * (np.power(r, 1.0 / n) - 1.0))
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

    @property
    def position_angle(self) -> float:
        """Position angle of the major axis, in degrees (alias of the field)."""
        return self.position_angle_deg

position_angle property

Position angle of the major axis, in degrees (alias of the field).

sersic(*, x=None, y=None, ra=None, dec=None, magnitude=None, photon_rate=None, n=1.0, r_eff_arcsec, ellipticity=0.0, position_angle_deg=0.0, sed=None, brightness=None, name=None, flux_sed=None) classmethod

A Sersic profile I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1]).

n=1 is an exponential disk, n=4 a de Vaucouleurs bulge. ellipticity (1 - b/a) and position_angle_deg (of the major axis, measured counter-clockwise from the +x axis) shape an elliptical isophote.

Source code in src/getframes/scene/sources.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
@classmethod
def sersic(
    cls,
    *,
    x: float | None = None,
    y: float | None = None,
    ra: float | None = None,
    dec: float | None = None,
    magnitude: float | None = None,
    photon_rate: float | None = None,
    n: float = 1.0,
    r_eff_arcsec: float,
    ellipticity: float = 0.0,
    position_angle_deg: float = 0.0,
    sed: SED | None = None,
    brightness: LightCurve | None = None,
    name: str | None = None,
    flux_sed: SED | None = None,
) -> ExtendedSource:
    """A Sersic profile ``I(r) ~ exp(-b_n[(r/r_eff)^(1/n) - 1])``.

    ``n=1`` is an exponential disk, ``n=4`` a de Vaucouleurs bulge. ``ellipticity``
    (``1 - b/a``) and ``position_angle_deg`` (of the major axis, measured
    counter-clockwise from the +x axis) shape an elliptical isophote.
    """
    return cls(
        x=x,
        y=y,
        ra_deg=ra,
        dec_deg=dec,
        magnitude=magnitude,
        photon_rate=photon_rate,
        sersic_n=n,
        r_eff_arcsec=r_eff_arcsec,
        ellipticity=ellipticity,
        position_angle_deg=position_angle_deg,
        sed=sed,
        brightness=brightness,
        name=name,
        flux_sed=flux_sed,
    )

from_array(image, *, x=None, y=None, ra=None, dec=None, magnitude=None, photon_rate=None, sed=None, brightness=None, name=None, flux_sed=None) classmethod

An arbitrary 2D image (e.g. a galaxy cutout) used as the profile.

The array is normalised to unit sum and pasted centred on the source position at detector-pixel resolution, then scaled to the total flux.

Source code in src/getframes/scene/sources.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
@classmethod
def from_array(
    cls,
    image: NDArray[np.float64],
    *,
    x: float | None = None,
    y: float | None = None,
    ra: float | None = None,
    dec: float | None = None,
    magnitude: float | None = None,
    photon_rate: float | None = None,
    sed: SED | None = None,
    brightness: LightCurve | None = None,
    name: str | None = None,
    flux_sed: SED | None = None,
) -> ExtendedSource:
    """An arbitrary 2D ``image`` (e.g. a galaxy cutout) used as the profile.

    The array is normalised to unit sum and pasted centred on the source
    position at detector-pixel resolution, then scaled to the total flux.
    """
    arr = np.asarray(image, dtype=np.float64)
    if arr.ndim != 2 or arr.size == 0:
        raise ValueError("ExtendedSource.from_array needs a non-empty 2D image.")
    total = float(arr.sum())
    if total <= 0:
        raise ValueError("ExtendedSource.from_array image must have a positive sum.")
    return cls(
        x=x,
        y=y,
        ra_deg=ra,
        dec_deg=dec,
        magnitude=magnitude,
        photon_rate=photon_rate,
        profile=arr / total,
        sed=sed,
        brightness=brightness,
        name=name,
        flux_sed=flux_sed,
    )

getframes.scene.sources.UniformIllumination dataclass

Bases: Source

A spatially flat illumination of photon_rate photons/s/pixel.

A clean, PSF-free flat field --- the natural input for a photon-transfer curve (PTC) or for building synthetic flats. brightness and sed behave as for other sources (time variability and spectral effective QE).

Source code in src/getframes/scene/sources.py
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
@dataclass(frozen=True)
class UniformIllumination(Source):
    """A spatially flat illumination of ``photon_rate`` photons/s/pixel.

    A clean, PSF-free flat field --- the natural input for a photon-transfer curve
    (PTC) or for building synthetic flats. ``brightness`` and ``sed`` behave as for
    other sources (time variability and spectral effective QE).
    """

    photon_rate: float
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None

    def __post_init__(self) -> None:
        if self.photon_rate < 0:
            raise ValueError("photon_rate must be non-negative.")

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        return self.photon_rate * _brightness_scale(self.brightness, time_s)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        rate = self.total_photon_rate(ctx.optics, ctx.time_s) * ctx.qe_scale(self.sed)
        if rate != 0.0:
            image += rate

getframes.scene.sources.Catalog dataclass

Bases: Source

Many point sources sharing a PSF, SED, and optional light curve.

Build one from a table with :meth:from_table. Entries may be placed by pixel (x, y) or by sky (ra, dec); sky coordinates are projected to pixels through the scene's :class:~getframes.scene.wcs.WCSInfo, so a Gaia/2MASS-style catalogue drops straight into a WCS-tagged scene. The whole catalogue is keyed by a single :attr:name in observation truth (its summed flux).

Source code in src/getframes/scene/sources.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@dataclass(frozen=True)
class Catalog(Source):
    """Many point sources sharing a PSF, SED, and optional light curve.

    Build one from a table with :meth:`from_table`. Entries may be placed by pixel
    ``(x, y)`` or by sky ``(ra, dec)``; sky coordinates are projected to pixels
    through the scene's :class:`~getframes.scene.wcs.WCSInfo`, so a Gaia/2MASS-style
    catalogue drops straight into a WCS-tagged scene. The whole catalogue is keyed
    by a single :attr:`name` in observation truth (its summed flux).
    """

    entries: tuple[CatalogEntry, ...] = field(default_factory=tuple)
    sed: SED | None = None
    brightness: LightCurve | None = None
    name: str | None = None

    @classmethod
    def from_table(
        cls,
        table: Mapping[str, Sequence[Any]] | Any,
        *,
        magnitude: str | None = None,
        photon_rate: str | None = None,
        x: str | None = None,
        y: str | None = None,
        ra: str | None = None,
        dec: str | None = None,
        sed: SED | None = None,
        brightness: LightCurve | None = None,
        name: str | None = None,
    ) -> Catalog:
        """Build a catalogue from column names of ``table``.

        ``table`` is anything column-indexable by name (an ``astropy`` ``Table``, a
        pandas ``DataFrame``, or a ``dict`` of arrays). Give the brightness column as
        ``magnitude`` or ``photon_rate``, and the position columns as either
        (``x``, ``y``) pixels or (``ra``, ``dec``) degrees.
        """
        if (magnitude is None) == (photon_rate is None):
            raise ValueError("Specify exactly one of `magnitude` or `photon_rate` column.")
        use_radec = ra is not None and dec is not None
        use_xy = x is not None and y is not None
        if use_radec == use_xy:
            raise ValueError("Specify position columns as either (ra, dec) or (x, y).")

        bright_col = magnitude if magnitude is not None else photon_rate
        assert bright_col is not None
        bright_vals = np.asarray(table[bright_col], dtype=np.float64)
        if use_radec:
            assert ra is not None and dec is not None
            pos_a = np.asarray(table[ra], dtype=np.float64)
            pos_b = np.asarray(table[dec], dtype=np.float64)
        else:
            assert x is not None and y is not None
            pos_a = np.asarray(table[x], dtype=np.float64)
            pos_b = np.asarray(table[y], dtype=np.float64)

        entries: list[CatalogEntry] = []
        is_mag = magnitude is not None
        for i in range(len(bright_vals)):
            mag = float(bright_vals[i]) if is_mag else None
            rate = None if is_mag else float(bright_vals[i])
            if use_radec:
                entries.append(
                    CatalogEntry(
                        magnitude=mag,
                        photon_rate=rate,
                        ra_deg=float(pos_a[i]),
                        dec_deg=float(pos_b[i]),
                    )
                )
            else:
                entries.append(
                    CatalogEntry(
                        magnitude=mag,
                        photon_rate=rate,
                        x=float(pos_a[i]),
                        y=float(pos_b[i]),
                    )
                )
        return cls(entries=tuple(entries), sed=sed, brightness=brightness, name=name)

    def __len__(self) -> int:
        return len(self.entries)

    def total_photon_rate(self, optics: Telescope, time_s: float | None = None) -> float:
        scale = _brightness_scale(self.brightness, time_s)
        return scale * sum(_resolve_rate(e.magnitude, e.photon_rate, optics) for e in self.entries)

    def deposit(self, image: NDArray[np.float64], ctx: RenderContext) -> None:
        scale = _brightness_scale(self.brightness, ctx.time_s) * ctx.qe_scale(self.sed)
        if scale <= 0 or not self.entries:
            return
        plate_scale = ctx.optics.plate_scale_arcsec_per_pixel
        rates = np.array(
            [_resolve_rate(e.magnitude, e.photon_rate, ctx.optics) for e in self.entries],
            dtype=np.float64,
        )
        rates *= scale
        xs, ys = self._placed_positions(ctx)
        # One batched (vectorised, chunked) deposit instead of a Python per-star loop.
        ctx.psf.add_sources(image, xs, ys, rates, plate_scale)

    def _placed_positions(
        self, ctx: RenderContext
    ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
        """Resolve every entry to detector pixels, vectorising the common path.

        Pixel-placed catalogues with no optical distortion are projected in one
        array op; RA/Dec or distortion fall back to the per-entry
        :meth:`RenderContext.place` so WCS and distortion behave exactly as for a
        single source.
        """
        pixel_only = all(e.ra_deg is None and e.x is not None for e in self.entries)
        if pixel_only and ctx.pixel_transform is None:
            xs = np.array([e.x for e in self.entries], dtype=np.float64)
            ys = np.array([e.y for e in self.entries], dtype=np.float64)
            return xs + ctx.offset_xy[0], ys + ctx.offset_xy[1]
        placed = [ctx.place(e.x, e.y, e.ra_deg, e.dec_deg) for e in self.entries]
        arr = np.asarray(placed, dtype=np.float64).reshape(len(placed), 2)
        return arr[:, 0], arr[:, 1]

from_table(table, *, magnitude=None, photon_rate=None, x=None, y=None, ra=None, dec=None, sed=None, brightness=None, name=None) classmethod

Build a catalogue from column names of table.

table is anything column-indexable by name (an astropy Table, a pandas DataFrame, or a dict of arrays). Give the brightness column as magnitude or photon_rate, and the position columns as either (x, y) pixels or (ra, dec) degrees.

Source code in src/getframes/scene/sources.py
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
@classmethod
def from_table(
    cls,
    table: Mapping[str, Sequence[Any]] | Any,
    *,
    magnitude: str | None = None,
    photon_rate: str | None = None,
    x: str | None = None,
    y: str | None = None,
    ra: str | None = None,
    dec: str | None = None,
    sed: SED | None = None,
    brightness: LightCurve | None = None,
    name: str | None = None,
) -> Catalog:
    """Build a catalogue from column names of ``table``.

    ``table`` is anything column-indexable by name (an ``astropy`` ``Table``, a
    pandas ``DataFrame``, or a ``dict`` of arrays). Give the brightness column as
    ``magnitude`` or ``photon_rate``, and the position columns as either
    (``x``, ``y``) pixels or (``ra``, ``dec``) degrees.
    """
    if (magnitude is None) == (photon_rate is None):
        raise ValueError("Specify exactly one of `magnitude` or `photon_rate` column.")
    use_radec = ra is not None and dec is not None
    use_xy = x is not None and y is not None
    if use_radec == use_xy:
        raise ValueError("Specify position columns as either (ra, dec) or (x, y).")

    bright_col = magnitude if magnitude is not None else photon_rate
    assert bright_col is not None
    bright_vals = np.asarray(table[bright_col], dtype=np.float64)
    if use_radec:
        assert ra is not None and dec is not None
        pos_a = np.asarray(table[ra], dtype=np.float64)
        pos_b = np.asarray(table[dec], dtype=np.float64)
    else:
        assert x is not None and y is not None
        pos_a = np.asarray(table[x], dtype=np.float64)
        pos_b = np.asarray(table[y], dtype=np.float64)

    entries: list[CatalogEntry] = []
    is_mag = magnitude is not None
    for i in range(len(bright_vals)):
        mag = float(bright_vals[i]) if is_mag else None
        rate = None if is_mag else float(bright_vals[i])
        if use_radec:
            entries.append(
                CatalogEntry(
                    magnitude=mag,
                    photon_rate=rate,
                    ra_deg=float(pos_a[i]),
                    dec_deg=float(pos_b[i]),
                )
            )
        else:
            entries.append(
                CatalogEntry(
                    magnitude=mag,
                    photon_rate=rate,
                    x=float(pos_a[i]),
                    y=float(pos_b[i]),
                )
            )
    return cls(entries=tuple(entries), sed=sed, brightness=brightness, name=name)

getframes.scene.sources.Sky dataclass

A uniform sky background of a given surface brightness.

The :class:~getframes.scene.scene.Scene treats the sky specially: it is added by the camera as a uniform background rather than deposited into the rendered source map, and is therefore not affected by vignetting.

Parameters:

Name Type Description Default
surface_brightness_mag_arcsec2 float

Sky brightness in magnitudes per square arcsecond (fainter = larger).

required
sed SED | None

Optional spectral energy distribution for the sky, used only in spectral mode for the sky's effective QE. Defaults to a flat photon spectrum.

None
Source code in src/getframes/scene/sources.py
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
@dataclass(frozen=True)
class Sky:
    """A uniform sky background of a given surface brightness.

    The :class:`~getframes.scene.scene.Scene` treats the sky specially: it is added
    by the camera as a uniform background rather than deposited into the rendered
    source map, and is therefore *not* affected by vignetting.

    Parameters
    ----------
    surface_brightness_mag_arcsec2:
        Sky brightness in magnitudes per square arcsecond (fainter = larger).
    sed:
        Optional spectral energy distribution for the sky, used only in spectral
        mode for the sky's effective QE. Defaults to a flat photon spectrum.
    """

    surface_brightness_mag_arcsec2: float
    sed: SED | None = None

getframes.scene.thermal.Thermal dataclass

A graybody thermal background from warm optics/enclosure.

Models the thermal emission seen by the detector as a graybody of emissivity :attr:emissivity at temperature :attr:temperature_k, integrated over the telescope band into a per-pixel photon rate. Attach it to a :class:~getframes.scene.scene.Scene (scene.thermal = Thermal(...)) and it is added as a uniform background by :meth:getframes.Camera.observe, like the sky but dominant in the thermal infrared.

Computing the rate requires the telescope band to carry a spectral response (the graybody is integrated over it).

Parameters:

Name Type Description Default
temperature_k float

Graybody temperature in kelvin (e.g. ~273--293 K for a warm enclosure).

required
emissivity float

Effective emissivity in [0, 1] (the warm optics' grey emission factor).

1.0
Source code in src/getframes/scene/thermal.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@dataclass(frozen=True)
class Thermal:
    """A graybody thermal background from warm optics/enclosure.

    Models the thermal emission seen by the detector as a graybody of emissivity
    :attr:`emissivity` at temperature :attr:`temperature_k`, integrated over the
    telescope band into a per-pixel photon rate. Attach it to a
    :class:`~getframes.scene.scene.Scene` (``scene.thermal = Thermal(...)``) and it
    is added as a uniform background by :meth:`getframes.Camera.observe`, like the
    sky but dominant in the thermal infrared.

    Computing the rate requires the telescope band to carry a spectral
    ``response`` (the graybody is integrated over it).

    Parameters
    ----------
    temperature_k:
        Graybody temperature in kelvin (e.g. ~273--293 K for a warm enclosure).
    emissivity:
        Effective emissivity in ``[0, 1]`` (the warm optics' grey emission factor).
    """

    temperature_k: float
    emissivity: float = 1.0

    def __post_init__(self) -> None:
        if self.temperature_k <= 0:
            raise ValueError("temperature_k must be positive.")
        if not 0.0 <= self.emissivity <= 1.0:
            raise ValueError("emissivity must be in [0, 1].")

    def photon_rate(self, optics: Telescope) -> float:
        """Thermal background in photons/s/pixel reaching the detector through ``optics``.

        ``emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda)
        dlambda``, with ``Omega_pixel`` the per-pixel solid angle and ``A_collect``
        the collecting area. Requires a band with a spectral response.
        """
        band = optics.band
        if band is None or band.response is None:
            raise ValueError("Thermal.photon_rate requires the telescope band to have a response.")
        resp = band.response.response
        wl_m = resp.wavelength_nm * 1e-9
        integrand = _photon_radiance(wl_m, self.temperature_k) * resp.value
        radiance = float(_trapezoid(integrand, wl_m))  # photons/s/m^2/sr
        omega_sr = (optics.plate_scale_arcsec_per_pixel * _ARCSEC_TO_RAD) ** 2
        return self.emissivity * radiance * optics.collecting_area_m2 * omega_sr

    def photon_sed(
        self,
        wavelength_min_nm: float = 300.0,
        wavelength_max_nm: float = 3000.0,
        n_samples: int = 256,
    ) -> SED:
        """A *relative* SED of the graybody photon spectrum (for spectral effective QE)."""
        wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples, dtype=np.float64)
        radiance = _photon_radiance(wl_nm * 1e-9, self.temperature_k)
        return SED.from_arrays(wl_nm, radiance / radiance.max())

photon_rate(optics)

Thermal background in photons/s/pixel reaching the detector through optics.

emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda) dlambda, with Omega_pixel the per-pixel solid angle and A_collect the collecting area. Requires a band with a spectral response.

Source code in src/getframes/scene/thermal.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def photon_rate(self, optics: Telescope) -> float:
    """Thermal background in photons/s/pixel reaching the detector through ``optics``.

    ``emissivity * Omega_pixel * A_collect * int L_ph(lambda, T) T_band(lambda)
    dlambda``, with ``Omega_pixel`` the per-pixel solid angle and ``A_collect``
    the collecting area. Requires a band with a spectral response.
    """
    band = optics.band
    if band is None or band.response is None:
        raise ValueError("Thermal.photon_rate requires the telescope band to have a response.")
    resp = band.response.response
    wl_m = resp.wavelength_nm * 1e-9
    integrand = _photon_radiance(wl_m, self.temperature_k) * resp.value
    radiance = float(_trapezoid(integrand, wl_m))  # photons/s/m^2/sr
    omega_sr = (optics.plate_scale_arcsec_per_pixel * _ARCSEC_TO_RAD) ** 2
    return self.emissivity * radiance * optics.collecting_area_m2 * omega_sr

photon_sed(wavelength_min_nm=300.0, wavelength_max_nm=3000.0, n_samples=256)

A relative SED of the graybody photon spectrum (for spectral effective QE).

Source code in src/getframes/scene/thermal.py
102
103
104
105
106
107
108
109
110
111
def photon_sed(
    self,
    wavelength_min_nm: float = 300.0,
    wavelength_max_nm: float = 3000.0,
    n_samples: int = 256,
) -> SED:
    """A *relative* SED of the graybody photon spectrum (for spectral effective QE)."""
    wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples, dtype=np.float64)
    radiance = _photon_radiance(wl_nm * 1e-9, self.temperature_k)
    return SED.from_arrays(wl_nm, radiance / radiance.max())

getframes.scene.optics.Telescope dataclass

An optical system that turns source magnitudes into photon rates at the focal plane.

Parameters:

Name Type Description Default
aperture_diameter_m float

Primary aperture diameter in metres.

required
plate_scale_arcsec_per_pixel float

Angular size of one detector pixel, in arcseconds.

required
throughput float

End-to-end fraction of photons transmitted (optics x filter x atmosphere), in [0, 1].

1.0
central_obstruction float

Diameter of the central obstruction as a fraction of the aperture diameter (e.g. the secondary mirror); 0 for an unobstructed aperture.

0.0
band Bandpass | None

The :class:~getframes.scene.photometry.Bandpass used to convert magnitudes to photon rates. Required only if any source is specified by magnitude (rather than an explicit photon rate).

None
vignetting Vignetting | None

Optional :class:Vignetting illumination falloff applied to the rendered source map (sources only, not the uniform sky).

None
distortion RadialDistortion | None

Optional :class:RadialDistortion displacing source positions about the field centre before they are deposited.

None
Source code in src/getframes/scene/optics.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
@dataclass(frozen=True)
class Telescope:
    """An optical system that turns source magnitudes into photon rates at the focal plane.

    Parameters
    ----------
    aperture_diameter_m:
        Primary aperture diameter in metres.
    plate_scale_arcsec_per_pixel:
        Angular size of one detector pixel, in arcseconds.
    throughput:
        End-to-end fraction of photons transmitted (optics x filter x atmosphere),
        in ``[0, 1]``.
    central_obstruction:
        Diameter of the central obstruction as a fraction of the aperture diameter
        (e.g. the secondary mirror); ``0`` for an unobstructed aperture.
    band:
        The :class:`~getframes.scene.photometry.Bandpass` used to convert
        magnitudes to photon rates. Required only if any source is specified by
        magnitude (rather than an explicit photon rate).
    vignetting:
        Optional :class:`Vignetting` illumination falloff applied to the rendered
        source map (sources only, not the uniform sky).
    distortion:
        Optional :class:`RadialDistortion` displacing source positions about the
        field centre before they are deposited.
    """

    aperture_diameter_m: float
    plate_scale_arcsec_per_pixel: float
    throughput: float = 1.0
    central_obstruction: float = 0.0
    band: Bandpass | None = None
    vignetting: Vignetting | None = None
    distortion: RadialDistortion | None = None

    def __post_init__(self) -> None:
        if self.aperture_diameter_m <= 0:
            raise ValueError("aperture_diameter_m must be positive.")
        if self.plate_scale_arcsec_per_pixel <= 0:
            raise ValueError("plate_scale_arcsec_per_pixel must be positive.")
        if not 0.0 <= self.throughput <= 1.0:
            raise ValueError("throughput must be in [0, 1].")
        if not 0.0 <= self.central_obstruction < 1.0:
            raise ValueError("central_obstruction must be in [0, 1).")

    def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64] | None:
        """Relative illumination map for ``shape`` (``None`` if no vignetting set)."""
        if self.vignetting is None:
            return None
        return self.vignetting.illumination_map(shape)

    @classmethod
    def unit(cls, plate_scale_arcsec_per_pixel: float = 1.0) -> Telescope:
        """A trivial 1 m, unit-throughput telescope.

        Handy when you supply source photon rates directly (already at the
        detector) and only need a plate scale --- e.g. AO sub-aperture simulations.
        """
        return cls(
            aperture_diameter_m=1.0,
            plate_scale_arcsec_per_pixel=plate_scale_arcsec_per_pixel,
            throughput=1.0,
        )

    @property
    def collecting_area_m2(self) -> float:
        """Unobstructed collecting area in square metres."""
        d = self.aperture_diameter_m
        return math.pi / 4.0 * (d**2 - (self.central_obstruction * d) ** 2)

    @property
    def pixel_solid_angle_arcsec2(self) -> float:
        """Solid angle subtended by one pixel, in square arcseconds."""
        return self.plate_scale_arcsec_per_pixel**2

    def photon_rate_from_magnitude(self, magnitude: float) -> float:
        """Photons/s reaching the detector from a point source of this magnitude."""
        if self.band is None:
            raise ValueError(
                "Telescope.band is required to use magnitudes; set a Bandpass or "
                "specify sources by photon_rate instead."
            )
        return self.band.photon_flux(magnitude) * self.collecting_area_m2 * self.throughput

    def photon_rate_from_sed(self, sed: SED) -> float:
        """Photons/s at the detector from a source described by an *absolute* SED.

        Integrates the SED over the band's spectral response
        (:meth:`~getframes.scene.photometry.Bandpass.photon_flux_from_sed`) and
        scales by collecting area and throughput --- the spectral-flux-integration
        counterpart of :meth:`photon_rate_from_magnitude`. Requires a band with a
        spectral response and an absolute SED
        (:meth:`getframes.spectral.SED.from_flux_density`).
        """
        if self.band is None:
            raise ValueError(
                "Telescope.band is required to integrate an SED; set a Bandpass with "
                "a spectral response."
            )
        return self.band.photon_flux_from_sed(sed) * self.collecting_area_m2 * self.throughput

    def surface_brightness_photon_rate(self, surface_brightness_mag_arcsec2: float) -> float:
        """Photons/s/pixel from a uniform sky of the given surface brightness."""
        per_arcsec2 = self.photon_rate_from_magnitude(surface_brightness_mag_arcsec2)
        return per_arcsec2 * self.pixel_solid_angle_arcsec2

collecting_area_m2 property

Unobstructed collecting area in square metres.

pixel_solid_angle_arcsec2 property

Solid angle subtended by one pixel, in square arcseconds.

illumination_map(shape)

Relative illumination map for shape (None if no vignetting set).

Source code in src/getframes/scene/optics.py
121
122
123
124
125
def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64] | None:
    """Relative illumination map for ``shape`` (``None`` if no vignetting set)."""
    if self.vignetting is None:
        return None
    return self.vignetting.illumination_map(shape)

unit(plate_scale_arcsec_per_pixel=1.0) classmethod

A trivial 1 m, unit-throughput telescope.

Handy when you supply source photon rates directly (already at the detector) and only need a plate scale --- e.g. AO sub-aperture simulations.

Source code in src/getframes/scene/optics.py
127
128
129
130
131
132
133
134
135
136
137
138
@classmethod
def unit(cls, plate_scale_arcsec_per_pixel: float = 1.0) -> Telescope:
    """A trivial 1 m, unit-throughput telescope.

    Handy when you supply source photon rates directly (already at the
    detector) and only need a plate scale --- e.g. AO sub-aperture simulations.
    """
    return cls(
        aperture_diameter_m=1.0,
        plate_scale_arcsec_per_pixel=plate_scale_arcsec_per_pixel,
        throughput=1.0,
    )

photon_rate_from_magnitude(magnitude)

Photons/s reaching the detector from a point source of this magnitude.

Source code in src/getframes/scene/optics.py
151
152
153
154
155
156
157
158
def photon_rate_from_magnitude(self, magnitude: float) -> float:
    """Photons/s reaching the detector from a point source of this magnitude."""
    if self.band is None:
        raise ValueError(
            "Telescope.band is required to use magnitudes; set a Bandpass or "
            "specify sources by photon_rate instead."
        )
    return self.band.photon_flux(magnitude) * self.collecting_area_m2 * self.throughput

photon_rate_from_sed(sed)

Photons/s at the detector from a source described by an absolute SED.

Integrates the SED over the band's spectral response (:meth:~getframes.scene.photometry.Bandpass.photon_flux_from_sed) and scales by collecting area and throughput --- the spectral-flux-integration counterpart of :meth:photon_rate_from_magnitude. Requires a band with a spectral response and an absolute SED (:meth:getframes.spectral.SED.from_flux_density).

Source code in src/getframes/scene/optics.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def photon_rate_from_sed(self, sed: SED) -> float:
    """Photons/s at the detector from a source described by an *absolute* SED.

    Integrates the SED over the band's spectral response
    (:meth:`~getframes.scene.photometry.Bandpass.photon_flux_from_sed`) and
    scales by collecting area and throughput --- the spectral-flux-integration
    counterpart of :meth:`photon_rate_from_magnitude`. Requires a band with a
    spectral response and an absolute SED
    (:meth:`getframes.spectral.SED.from_flux_density`).
    """
    if self.band is None:
        raise ValueError(
            "Telescope.band is required to integrate an SED; set a Bandpass with "
            "a spectral response."
        )
    return self.band.photon_flux_from_sed(sed) * self.collecting_area_m2 * self.throughput

surface_brightness_photon_rate(surface_brightness_mag_arcsec2)

Photons/s/pixel from a uniform sky of the given surface brightness.

Source code in src/getframes/scene/optics.py
177
178
179
180
def surface_brightness_photon_rate(self, surface_brightness_mag_arcsec2: float) -> float:
    """Photons/s/pixel from a uniform sky of the given surface brightness."""
    per_arcsec2 = self.photon_rate_from_magnitude(surface_brightness_mag_arcsec2)
    return per_arcsec2 * self.pixel_solid_angle_arcsec2

getframes.scene.optics.Vignetting dataclass

A radial illumination falloff toward the edges of the field.

Relative illumination is 1 - strength * (r / r_corner)^power, where r is the distance from the optical centre and r_corner is the distance to the farthest corner. strength is the fractional light loss at that corner; power=2 gives a gentle quadratic roll-off (power=4 approximates the cos^4 law). The map is clipped to [0, 1].

Source code in src/getframes/scene/optics.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclass(frozen=True)
class Vignetting:
    """A radial illumination falloff toward the edges of the field.

    Relative illumination is ``1 - strength * (r / r_corner)^power``, where ``r`` is
    the distance from the optical centre and ``r_corner`` is the distance to the
    farthest corner. ``strength`` is the fractional light loss at that corner;
    ``power=2`` gives a gentle quadratic roll-off (``power=4`` approximates the cos^4
    law). The map is clipped to ``[0, 1]``.
    """

    strength: float
    power: float = 2.0

    def __post_init__(self) -> None:
        if not 0.0 <= self.strength <= 1.0:
            raise ValueError("vignetting strength must be in [0, 1].")
        if self.power <= 0:
            raise ValueError("vignetting power must be positive.")

    def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64]:
        """Relative illumination in ``[0, 1]`` for a frame of ``(height, width)``."""
        height, width = shape
        cy, cx = (height - 1) / 2.0, (width - 1) / 2.0
        yy, xx = np.mgrid[0:height, 0:width]
        r = np.hypot(xx - cx, yy - cy)
        r_corner = math.hypot(max(cx, width - 1 - cx), max(cy, height - 1 - cy))
        if r_corner == 0:
            return np.ones(shape, dtype=np.float64)
        rel = 1.0 - self.strength * (r / r_corner) ** self.power
        clipped: NDArray[np.float64] = np.clip(rel, 0.0, 1.0).astype(np.float64)
        return clipped

illumination_map(shape)

Relative illumination in [0, 1] for a frame of (height, width).

Source code in src/getframes/scene/optics.py
40
41
42
43
44
45
46
47
48
49
50
51
def illumination_map(self, shape: tuple[int, int]) -> NDArray[np.float64]:
    """Relative illumination in ``[0, 1]`` for a frame of ``(height, width)``."""
    height, width = shape
    cy, cx = (height - 1) / 2.0, (width - 1) / 2.0
    yy, xx = np.mgrid[0:height, 0:width]
    r = np.hypot(xx - cx, yy - cy)
    r_corner = math.hypot(max(cx, width - 1 - cx), max(cy, height - 1 - cy))
    if r_corner == 0:
        return np.ones(shape, dtype=np.float64)
    rel = 1.0 - self.strength * (r / r_corner) ** self.power
    clipped: NDArray[np.float64] = np.clip(rel, 0.0, 1.0).astype(np.float64)
    return clipped

getframes.scene.optics.RadialDistortion dataclass

A simple radial (barrel/pincushion) distortion about the field centre.

A source at pixel distance r from the centre is displaced to r * (1 + k1 r^2 + k2 r^4). k1 < 0 gives barrel distortion, k1 > 0 pincushion; both coefficients carry inverse-pixel-power units (k1 is small, e.g. 1e-7 per pixel^2 for a 2k detector).

Source code in src/getframes/scene/optics.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@dataclass(frozen=True)
class RadialDistortion:
    """A simple radial (barrel/pincushion) distortion about the field centre.

    A source at pixel distance ``r`` from the centre is displaced to
    ``r * (1 + k1 r^2 + k2 r^4)``. ``k1 < 0`` gives barrel distortion, ``k1 > 0``
    pincushion; both coefficients carry inverse-pixel-power units (``k1`` is small,
    e.g. ``1e-7`` per pixel^2 for a 2k detector).
    """

    k1: float
    k2: float = 0.0

    def apply(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float]:
        """Map pixel ``(x, y)`` to its distorted position about centre ``(cx, cy)``."""
        dx, dy = x - cx, y - cy
        r2 = dx * dx + dy * dy
        factor = 1.0 + self.k1 * r2 + self.k2 * r2 * r2
        return cx + dx * factor, cy + dy * factor

apply(x, y, cx, cy)

Map pixel (x, y) to its distorted position about centre (cx, cy).

Source code in src/getframes/scene/optics.py
67
68
69
70
71
72
def apply(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float]:
    """Map pixel ``(x, y)`` to its distorted position about centre ``(cx, cy)``."""
    dx, dy = x - cx, y - cy
    r2 = dx * dx + dy * dy
    factor = 1.0 + self.k1 * r2 + self.k2 * r2 * r2
    return cx + dx * factor, cy + dy * factor

getframes.scene.photometry.Bandpass dataclass

A photometric band, summarised by its photon zero point.

Parameters:

Name Type Description Default
name str

Human-readable label, e.g. "Johnson V".

required
photon_zeropoint float

Photons per second per square metre, above the atmosphere, from a magnitude-0 source integrated over the band.

required
response SpectralBandpass | None

Optional spectral transmission curve for the band. Enables spectral mode (colour-dependent effective QE); None keeps the band-integrated model.

None
Source code in src/getframes/scene/photometry.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
@dataclass(frozen=True)
class Bandpass:
    """A photometric band, summarised by its photon zero point.

    Parameters
    ----------
    name:
        Human-readable label, e.g. ``"Johnson V"``.
    photon_zeropoint:
        Photons per second per square metre, above the atmosphere, from a
        magnitude-0 source integrated over the band.
    response:
        Optional spectral transmission curve for the band. Enables spectral mode
        (colour-dependent effective QE); ``None`` keeps the band-integrated model.
    """

    name: str
    photon_zeropoint: float
    response: SpectralBandpass | None = None

    def __post_init__(self) -> None:
        if self.photon_zeropoint <= 0:
            raise ValueError("photon_zeropoint must be positive.")

    @classmethod
    def johnson(cls, band: str, *, spectral: bool = True) -> Bandpass:
        """Return a Vega-system band (``U B V R I``, or 2MASS ``J H Ks``).

        By default the band also carries a tophat spectral ``response`` so spectral
        mode works out of the box; pass ``spectral=False`` for the bare zero point.

        ``U`` through ``I`` carry representative textbook band-integrated photon
        zero points. ``J``, ``H``, and ``Ks`` are derived from the 2MASS absolute
        calibration instead, so near-infrared work does not have to leave the
        Vega system to reach a defensible zero point. Use :meth:`ab` for the AB
        system, which is a different magnitude for the same star.
        """
        key = band.strip().upper()
        if key not in _JOHNSON_PHOTON_ZEROPOINTS:
            valid = ", ".join(_JOHNSON_PHOTON_ZEROPOINTS)
            raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
        response = SpectralBandpass.johnson(key) if spectral else None
        return cls(
            name=f"Johnson {key}",
            photon_zeropoint=_JOHNSON_PHOTON_ZEROPOINTS[key],
            response=response,
        )

    @classmethod
    def ab(cls, band: str) -> Bandpass:
        """Return an **AB-system** band for a common survey filter.

        The AB system references every band to a flat :math:`f_\\nu = 3631`
        Jy source, so the zero point is *computed* from the band's transmission
        shape (see :func:`_ab_photon_zeropoint`) rather than tabulated. Supported
        ``band`` names (case-insensitive): SDSS ``u g r i z``, Gaia
        ``gaia_g gaia_bp gaia_rp`` (also ``G BP RP``), and 2MASS ``J H Ks``. Each
        carries a tophat spectral response, so spectral mode works out of the box;
        supply a measured curve via :meth:`SpectralBandpass.from_file` for rigour.

        Gaia bands are ``gaia_g``, ``gaia_bp``, ``gaia_rp`` (``bp``/``rp`` also
        accepted); ``g`` is SDSS g. Use :meth:`johnson` for the Vega system instead.
        """
        key = _canonical_band(band)
        if key not in _AB_BANDS:
            valid = ", ".join(sorted(_AB_BANDS))
            raise ValueError(f"Unknown AB band {band!r}. Expected one of: {valid}.")
        label, center, width = _AB_BANDS[key]
        response = SpectralBandpass.tophat(center, width)
        return cls(
            name=f"AB {label}",
            photon_zeropoint=_ab_photon_zeropoint(response),
            response=response,
        )

    def photon_flux(self, magnitude: float) -> float:
        """Photons/s/m^2 above the atmosphere for a source of the given magnitude."""
        return float(self.photon_zeropoint * 10.0 ** (-0.4 * magnitude))

    def photon_flux_from_sed(self, sed: SED) -> float:
        """Photons/s/m^2 above the atmosphere from an *absolute* SED through this band.

        Integrates ``int S(lambda) T(lambda) dlambda`` over the band's spectral
        response, where ``S`` is the absolute photon flux density
        (``photons/s/m^2/nm``) of an SED built with
        :meth:`getframes.spectral.SED.from_flux_density`. This is the "true spectral
        flux integration" path: the spectrum itself sets the rate, rather than a
        magnitude. Requires a spectral :attr:`response`.
        """
        if self.response is None:
            raise ValueError(
                f"Bandpass {self.name!r} has no spectral response; "
                "photon_flux_from_sed needs one to integrate the SED over the band."
            )
        if not sed.is_absolute:
            raise ValueError(
                "photon_flux_from_sed needs an absolute SED (build it with SED.from_flux_density)."
            )
        return float(overlap_integral(sed, self.response.response))

    def effective_qe(self, qe: QE, sed: SED | None = None) -> float:
        """Photon-weighted effective QE for a source of SED ``sed`` seen through this band.

        Requires a spectral :attr:`response`. ``sed`` defaults to a flat photon
        spectrum (the bandpass-weighted mean QE). See
        :func:`getframes.spectral.effective_qe`.
        """
        if self.response is None:
            raise ValueError(
                f"Bandpass {self.name!r} has no spectral response; "
                "construct it with a response to use spectral mode."
            )
        return effective_qe(qe, self.response, sed)

johnson(band, *, spectral=True) classmethod

Return a Vega-system band (U B V R I, or 2MASS J H Ks).

By default the band also carries a tophat spectral response so spectral mode works out of the box; pass spectral=False for the bare zero point.

U through I carry representative textbook band-integrated photon zero points. J, H, and Ks are derived from the 2MASS absolute calibration instead, so near-infrared work does not have to leave the Vega system to reach a defensible zero point. Use :meth:ab for the AB system, which is a different magnitude for the same star.

Source code in src/getframes/scene/photometry.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
@classmethod
def johnson(cls, band: str, *, spectral: bool = True) -> Bandpass:
    """Return a Vega-system band (``U B V R I``, or 2MASS ``J H Ks``).

    By default the band also carries a tophat spectral ``response`` so spectral
    mode works out of the box; pass ``spectral=False`` for the bare zero point.

    ``U`` through ``I`` carry representative textbook band-integrated photon
    zero points. ``J``, ``H``, and ``Ks`` are derived from the 2MASS absolute
    calibration instead, so near-infrared work does not have to leave the
    Vega system to reach a defensible zero point. Use :meth:`ab` for the AB
    system, which is a different magnitude for the same star.
    """
    key = band.strip().upper()
    if key not in _JOHNSON_PHOTON_ZEROPOINTS:
        valid = ", ".join(_JOHNSON_PHOTON_ZEROPOINTS)
        raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
    response = SpectralBandpass.johnson(key) if spectral else None
    return cls(
        name=f"Johnson {key}",
        photon_zeropoint=_JOHNSON_PHOTON_ZEROPOINTS[key],
        response=response,
    )

ab(band) classmethod

Return an AB-system band for a common survey filter.

The AB system references every band to a flat :math:f_\nu = 3631 Jy source, so the zero point is computed from the band's transmission shape (see :func:_ab_photon_zeropoint) rather than tabulated. Supported band names (case-insensitive): SDSS u g r i z, Gaia gaia_g gaia_bp gaia_rp (also G BP RP), and 2MASS J H Ks. Each carries a tophat spectral response, so spectral mode works out of the box; supply a measured curve via :meth:SpectralBandpass.from_file for rigour.

Gaia bands are gaia_g, gaia_bp, gaia_rp (bp/rp also accepted); g is SDSS g. Use :meth:johnson for the Vega system instead.

Source code in src/getframes/scene/photometry.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
@classmethod
def ab(cls, band: str) -> Bandpass:
    """Return an **AB-system** band for a common survey filter.

    The AB system references every band to a flat :math:`f_\\nu = 3631`
    Jy source, so the zero point is *computed* from the band's transmission
    shape (see :func:`_ab_photon_zeropoint`) rather than tabulated. Supported
    ``band`` names (case-insensitive): SDSS ``u g r i z``, Gaia
    ``gaia_g gaia_bp gaia_rp`` (also ``G BP RP``), and 2MASS ``J H Ks``. Each
    carries a tophat spectral response, so spectral mode works out of the box;
    supply a measured curve via :meth:`SpectralBandpass.from_file` for rigour.

    Gaia bands are ``gaia_g``, ``gaia_bp``, ``gaia_rp`` (``bp``/``rp`` also
    accepted); ``g`` is SDSS g. Use :meth:`johnson` for the Vega system instead.
    """
    key = _canonical_band(band)
    if key not in _AB_BANDS:
        valid = ", ".join(sorted(_AB_BANDS))
        raise ValueError(f"Unknown AB band {band!r}. Expected one of: {valid}.")
    label, center, width = _AB_BANDS[key]
    response = SpectralBandpass.tophat(center, width)
    return cls(
        name=f"AB {label}",
        photon_zeropoint=_ab_photon_zeropoint(response),
        response=response,
    )

photon_flux(magnitude)

Photons/s/m^2 above the atmosphere for a source of the given magnitude.

Source code in src/getframes/scene/photometry.py
192
193
194
def photon_flux(self, magnitude: float) -> float:
    """Photons/s/m^2 above the atmosphere for a source of the given magnitude."""
    return float(self.photon_zeropoint * 10.0 ** (-0.4 * magnitude))

photon_flux_from_sed(sed)

Photons/s/m^2 above the atmosphere from an absolute SED through this band.

Integrates int S(lambda) T(lambda) dlambda over the band's spectral response, where S is the absolute photon flux density (photons/s/m^2/nm) of an SED built with :meth:getframes.spectral.SED.from_flux_density. This is the "true spectral flux integration" path: the spectrum itself sets the rate, rather than a magnitude. Requires a spectral :attr:response.

Source code in src/getframes/scene/photometry.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def photon_flux_from_sed(self, sed: SED) -> float:
    """Photons/s/m^2 above the atmosphere from an *absolute* SED through this band.

    Integrates ``int S(lambda) T(lambda) dlambda`` over the band's spectral
    response, where ``S`` is the absolute photon flux density
    (``photons/s/m^2/nm``) of an SED built with
    :meth:`getframes.spectral.SED.from_flux_density`. This is the "true spectral
    flux integration" path: the spectrum itself sets the rate, rather than a
    magnitude. Requires a spectral :attr:`response`.
    """
    if self.response is None:
        raise ValueError(
            f"Bandpass {self.name!r} has no spectral response; "
            "photon_flux_from_sed needs one to integrate the SED over the band."
        )
    if not sed.is_absolute:
        raise ValueError(
            "photon_flux_from_sed needs an absolute SED (build it with SED.from_flux_density)."
        )
    return float(overlap_integral(sed, self.response.response))

effective_qe(qe, sed=None)

Photon-weighted effective QE for a source of SED sed seen through this band.

Requires a spectral :attr:response. sed defaults to a flat photon spectrum (the bandpass-weighted mean QE). See :func:getframes.spectral.effective_qe.

Source code in src/getframes/scene/photometry.py
217
218
219
220
221
222
223
224
225
226
227
228
229
def effective_qe(self, qe: QE, sed: SED | None = None) -> float:
    """Photon-weighted effective QE for a source of SED ``sed`` seen through this band.

    Requires a spectral :attr:`response`. ``sed`` defaults to a flat photon
    spectrum (the bandpass-weighted mean QE). See
    :func:`getframes.spectral.effective_qe`.
    """
    if self.response is None:
        raise ValueError(
            f"Bandpass {self.name!r} has no spectral response; "
            "construct it with a response to use spectral mode."
        )
    return effective_qe(qe, self.response, sed)

getframes.scene.photometry.Extinction dataclass

Interstellar extinction (reddening) by intervening dust.

A Cardelli, Clayton & Mathis (1989) extinction curve, parameterised by the visual extinction a_v (magnitudes of attenuation in V) and the total-to- selective ratio r_v (3.1 for the diffuse Galactic ISM). It dims and reddens a source: redder dust passes more light, so a blue source is attenuated more.

Use :meth:transmission for the wavelength-dependent throughput 10**(-0.4 A(lambda)), :meth:redden to apply it to an :class:~getframes.spectral.SED, or :meth:band_attenuation_mag for the band-integrated magnitude shift to add to a source magnitude.

Parameters:

Name Type Description Default
a_v float

Visual extinction A_V in magnitudes (non-negative).

required
r_v float

Total-to-selective extinction ratio R_V = A_V / E(B-V) (default 3.1).

3.1
Source code in src/getframes/scene/photometry.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
@dataclass(frozen=True)
class Extinction:
    """Interstellar extinction (reddening) by intervening dust.

    A Cardelli, Clayton & Mathis (1989) extinction curve, parameterised by the
    visual extinction ``a_v`` (magnitudes of attenuation in V) and the total-to-
    selective ratio ``r_v`` (3.1 for the diffuse Galactic ISM). It dims and reddens a
    source: redder dust passes more light, so a blue source is attenuated more.

    Use :meth:`transmission` for the wavelength-dependent throughput
    ``10**(-0.4 A(lambda))``, :meth:`redden` to apply it to an
    :class:`~getframes.spectral.SED`, or :meth:`band_attenuation_mag` for the
    band-integrated magnitude shift to add to a source magnitude.

    Parameters
    ----------
    a_v:
        Visual extinction ``A_V`` in magnitudes (non-negative).
    r_v:
        Total-to-selective extinction ratio ``R_V = A_V / E(B-V)`` (default 3.1).
    """

    a_v: float
    r_v: float = 3.1

    def __post_init__(self) -> None:
        if self.a_v < 0:
            raise ValueError("a_v must be non-negative.")
        if self.r_v <= 0:
            raise ValueError("r_v must be positive.")

    def attenuation_mag(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
        """Extinction ``A(lambda)`` in magnitudes at each wavelength (nm).

        Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the
        nearest valid value.
        """
        wl_um = np.asarray(wavelength_nm, dtype=np.float64) / 1000.0
        x = np.clip(1.0 / wl_um, 0.3, 3.3)
        a, b = _ccm89_ab(x)
        return self.a_v * (a + b / self.r_v)

    def transmission(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
        """Fractional transmission ``10**(-0.4 A(lambda))`` at each wavelength (nm)."""
        return np.asarray(10.0 ** (-0.4 * self.attenuation_mag(wavelength_nm)), dtype=np.float64)

    def transmission_curve(self, wavelength_nm: ArrayLike) -> Spectrum:
        """The transmission as a :class:`~getframes.spectral.Spectrum` (for :func:`product`)."""
        wl = np.asarray(wavelength_nm, dtype=np.float64)
        return Spectrum(wl, self.transmission(wl))

    def redden(self, sed: SED) -> SED:
        """Apply extinction to ``sed``, returning a reddened copy (units preserved)."""
        reddened = sed.value * self.transmission(sed.wavelength_nm)
        return SED(sed.wavelength_nm.copy(), reddened, is_absolute=sed.is_absolute)

    def band_attenuation_mag(self, band: Bandpass, sed: SED | None = None) -> float:
        """Band-integrated extinction in magnitudes through ``band`` for a source ``sed``.

        The photon-weighted mean attenuation,
        ``-2.5 log10(int S T 10^{-0.4 A} dl / int S T dl)``, evaluated on the band's
        response grid. ``sed`` defaults to a flat photon spectrum. Add the result to
        a source magnitude to dim it by the dust column. Requires a spectral
        :attr:`~Bandpass.response`.
        """
        if band.response is None:
            raise ValueError("band_attenuation_mag requires a band with a spectral response.")
        wl = band.response.response.wavelength_nm
        weight = band.response.response.value.astype(np.float64)
        if sed is not None:
            weight = weight * sed(wl)
        denom = float(_trapezoid(weight, wl))
        if denom <= 0:
            raise ValueError("band response (times SED) integrates to zero; cannot weight.")
        numer = float(_trapezoid(weight * self.transmission(wl), wl))
        return float(-2.5 * np.log10(numer / denom))

attenuation_mag(wavelength_nm)

Extinction A(lambda) in magnitudes at each wavelength (nm).

Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the nearest valid value.

Source code in src/getframes/scene/photometry.py
305
306
307
308
309
310
311
312
313
314
def attenuation_mag(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
    """Extinction ``A(lambda)`` in magnitudes at each wavelength (nm).

    Wavelengths outside the CCM89 range (~303--3333 nm) are clamped to the
    nearest valid value.
    """
    wl_um = np.asarray(wavelength_nm, dtype=np.float64) / 1000.0
    x = np.clip(1.0 / wl_um, 0.3, 3.3)
    a, b = _ccm89_ab(x)
    return self.a_v * (a + b / self.r_v)

transmission(wavelength_nm)

Fractional transmission 10**(-0.4 A(lambda)) at each wavelength (nm).

Source code in src/getframes/scene/photometry.py
316
317
318
def transmission(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
    """Fractional transmission ``10**(-0.4 A(lambda))`` at each wavelength (nm)."""
    return np.asarray(10.0 ** (-0.4 * self.attenuation_mag(wavelength_nm)), dtype=np.float64)

transmission_curve(wavelength_nm)

The transmission as a :class:~getframes.spectral.Spectrum (for :func:product).

Source code in src/getframes/scene/photometry.py
320
321
322
323
def transmission_curve(self, wavelength_nm: ArrayLike) -> Spectrum:
    """The transmission as a :class:`~getframes.spectral.Spectrum` (for :func:`product`)."""
    wl = np.asarray(wavelength_nm, dtype=np.float64)
    return Spectrum(wl, self.transmission(wl))

redden(sed)

Apply extinction to sed, returning a reddened copy (units preserved).

Source code in src/getframes/scene/photometry.py
325
326
327
328
def redden(self, sed: SED) -> SED:
    """Apply extinction to ``sed``, returning a reddened copy (units preserved)."""
    reddened = sed.value * self.transmission(sed.wavelength_nm)
    return SED(sed.wavelength_nm.copy(), reddened, is_absolute=sed.is_absolute)

band_attenuation_mag(band, sed=None)

Band-integrated extinction in magnitudes through band for a source sed.

The photon-weighted mean attenuation, -2.5 log10(int S T 10^{-0.4 A} dl / int S T dl), evaluated on the band's response grid. sed defaults to a flat photon spectrum. Add the result to a source magnitude to dim it by the dust column. Requires a spectral :attr:~Bandpass.response.

Source code in src/getframes/scene/photometry.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
def band_attenuation_mag(self, band: Bandpass, sed: SED | None = None) -> float:
    """Band-integrated extinction in magnitudes through ``band`` for a source ``sed``.

    The photon-weighted mean attenuation,
    ``-2.5 log10(int S T 10^{-0.4 A} dl / int S T dl)``, evaluated on the band's
    response grid. ``sed`` defaults to a flat photon spectrum. Add the result to
    a source magnitude to dim it by the dust column. Requires a spectral
    :attr:`~Bandpass.response`.
    """
    if band.response is None:
        raise ValueError("band_attenuation_mag requires a band with a spectral response.")
    wl = band.response.response.wavelength_nm
    weight = band.response.response.value.astype(np.float64)
    if sed is not None:
        weight = weight * sed(wl)
    denom = float(_trapezoid(weight, wl))
    if denom <= 0:
        raise ValueError("band response (times SED) integrates to zero; cannot weight.")
    numer = float(_trapezoid(weight * self.transmission(wl), wl))
    return float(-2.5 * np.log10(numer / denom))

getframes.scene.wcs.WCSInfo dataclass

A tangent-plane (TAN) world coordinate system for a detector frame.

Parameters:

Name Type Description Default
crval_ra_deg float

Sky coordinates (degrees) of the reference point.

required
crval_dec_deg float

Sky coordinates (degrees) of the reference point.

required
crpix_x float

Pixel coordinates of the reference point, in 0-based array convention (crpix_x is the column, crpix_y the row). They are written to the FITS header in the 1-based convention the standard requires.

required
crpix_y float

Pixel coordinates of the reference point, in 0-based array convention (crpix_x is the column, crpix_y the row). They are written to the FITS header in the 1-based convention the standard requires.

required
plate_scale_arcsec_per_pixel float

Angular pixel size, matching the telescope's plate scale.

required
rotation_deg float

Position angle of the y-axis east of north, in degrees (0 puts north up and east left, the usual astronomical orientation).

0.0
Source code in src/getframes/scene/wcs.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@dataclass(frozen=True)
class WCSInfo:
    """A tangent-plane (TAN) world coordinate system for a detector frame.

    Parameters
    ----------
    crval_ra_deg, crval_dec_deg:
        Sky coordinates (degrees) of the reference point.
    crpix_x, crpix_y:
        Pixel coordinates of the reference point, in 0-based array convention
        (``crpix_x`` is the column, ``crpix_y`` the row). They are written to the
        FITS header in the 1-based convention the standard requires.
    plate_scale_arcsec_per_pixel:
        Angular pixel size, matching the telescope's plate scale.
    rotation_deg:
        Position angle of the y-axis east of north, in degrees (``0`` puts north up
        and east left, the usual astronomical orientation).
    """

    crval_ra_deg: float
    crval_dec_deg: float
    crpix_x: float
    crpix_y: float
    plate_scale_arcsec_per_pixel: float
    rotation_deg: float = 0.0

    def __post_init__(self) -> None:
        if self.plate_scale_arcsec_per_pixel <= 0:
            raise ValueError("plate_scale_arcsec_per_pixel must be positive.")
        if not -90.0 <= self.crval_dec_deg <= 90.0:
            raise ValueError("crval_dec_deg must be in [-90, 90].")

    @property
    def _cdelt_deg(self) -> float:
        return self.plate_scale_arcsec_per_pixel / 3600.0

    def header_cards(self) -> dict[str, Any]:
        """FITS WCS header cards for a TAN projection (8-char keywords, no astropy).

        RA increases to the left (east), so ``CD1_1`` carries the sign flip. The
        rotation is folded into the CD matrix.
        """
        cd = self._cdelt_deg
        theta = math.radians(self.rotation_deg)
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        # RA runs east (to smaller pixel-x for north-up), hence the leading minus.
        return {
            "CTYPE1": "RA---TAN",
            "CTYPE2": "DEC--TAN",
            "CUNIT1": "deg",
            "CUNIT2": "deg",
            "CRVAL1": float(self.crval_ra_deg),
            "CRVAL2": float(self.crval_dec_deg),
            "CRPIX1": float(self.crpix_x) + 1.0,
            "CRPIX2": float(self.crpix_y) + 1.0,
            "CD1_1": -cd * cos_t,
            "CD1_2": cd * sin_t,
            "CD2_1": cd * sin_t,
            "CD2_2": cd * cos_t,
        }

    def to_astropy(self) -> WCS:
        """Build an :class:`astropy.wcs.WCS` (requires ``astropy``)."""
        try:
            from astropy.wcs import WCS
        except ImportError as exc:  # pragma: no cover - astropy is a core dependency
            raise ImportError(
                "WCS pixel/world conversion requires astropy (a core dependency of "
                "getframes); reinstall with: pip install getframes"
            ) from exc
        wcs = WCS(naxis=2)
        cards = self.header_cards()
        wcs.wcs.ctype = [cards["CTYPE1"], cards["CTYPE2"]]
        wcs.wcs.crval = [cards["CRVAL1"], cards["CRVAL2"]]
        wcs.wcs.crpix = [cards["CRPIX1"], cards["CRPIX2"]]
        wcs.wcs.cd = [[cards["CD1_1"], cards["CD1_2"]], [cards["CD2_1"], cards["CD2_2"]]]
        return wcs

    def pixel_to_world(self, x: float, y: float) -> tuple[float, float]:
        """Convert a 0-based pixel ``(x, y)`` to ``(ra_deg, dec_deg)`` (needs astropy)."""
        ra, dec = self.to_astropy().all_pix2world([[x, y]], 0)[0]
        return float(ra), float(dec)

    def world_to_pixel(self, ra_deg: float, dec_deg: float) -> tuple[float, float]:
        """Convert ``(ra_deg, dec_deg)`` to a 0-based pixel ``(x, y)`` (needs astropy)."""
        x, y = self.to_astropy().all_world2pix([[ra_deg, dec_deg]], 0)[0]
        return float(x), float(y)

header_cards()

FITS WCS header cards for a TAN projection (8-char keywords, no astropy).

RA increases to the left (east), so CD1_1 carries the sign flip. The rotation is folded into the CD matrix.

Source code in src/getframes/scene/wcs.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def header_cards(self) -> dict[str, Any]:
    """FITS WCS header cards for a TAN projection (8-char keywords, no astropy).

    RA increases to the left (east), so ``CD1_1`` carries the sign flip. The
    rotation is folded into the CD matrix.
    """
    cd = self._cdelt_deg
    theta = math.radians(self.rotation_deg)
    cos_t, sin_t = math.cos(theta), math.sin(theta)
    # RA runs east (to smaller pixel-x for north-up), hence the leading minus.
    return {
        "CTYPE1": "RA---TAN",
        "CTYPE2": "DEC--TAN",
        "CUNIT1": "deg",
        "CUNIT2": "deg",
        "CRVAL1": float(self.crval_ra_deg),
        "CRVAL2": float(self.crval_dec_deg),
        "CRPIX1": float(self.crpix_x) + 1.0,
        "CRPIX2": float(self.crpix_y) + 1.0,
        "CD1_1": -cd * cos_t,
        "CD1_2": cd * sin_t,
        "CD2_1": cd * sin_t,
        "CD2_2": cd * cos_t,
    }

to_astropy()

Build an :class:astropy.wcs.WCS (requires astropy).

Source code in src/getframes/scene/wcs.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def to_astropy(self) -> WCS:
    """Build an :class:`astropy.wcs.WCS` (requires ``astropy``)."""
    try:
        from astropy.wcs import WCS
    except ImportError as exc:  # pragma: no cover - astropy is a core dependency
        raise ImportError(
            "WCS pixel/world conversion requires astropy (a core dependency of "
            "getframes); reinstall with: pip install getframes"
        ) from exc
    wcs = WCS(naxis=2)
    cards = self.header_cards()
    wcs.wcs.ctype = [cards["CTYPE1"], cards["CTYPE2"]]
    wcs.wcs.crval = [cards["CRVAL1"], cards["CRVAL2"]]
    wcs.wcs.crpix = [cards["CRPIX1"], cards["CRPIX2"]]
    wcs.wcs.cd = [[cards["CD1_1"], cards["CD1_2"]], [cards["CD2_1"], cards["CD2_2"]]]
    return wcs

pixel_to_world(x, y)

Convert a 0-based pixel (x, y) to (ra_deg, dec_deg) (needs astropy).

Source code in src/getframes/scene/wcs.py
 99
100
101
102
def pixel_to_world(self, x: float, y: float) -> tuple[float, float]:
    """Convert a 0-based pixel ``(x, y)`` to ``(ra_deg, dec_deg)`` (needs astropy)."""
    ra, dec = self.to_astropy().all_pix2world([[x, y]], 0)[0]
    return float(ra), float(dec)

world_to_pixel(ra_deg, dec_deg)

Convert (ra_deg, dec_deg) to a 0-based pixel (x, y) (needs astropy).

Source code in src/getframes/scene/wcs.py
104
105
106
107
def world_to_pixel(self, ra_deg: float, dec_deg: float) -> tuple[float, float]:
    """Convert ``(ra_deg, dec_deg)`` to a 0-based pixel ``(x, y)`` (needs astropy)."""
    x, y = self.to_astropy().all_world2pix([[ra_deg, dec_deg]], 0)[0]
    return float(x), float(y)

getframes.scene.psf

Point-spread functions: how a point source's flux is spread over pixels.

Each PSF knows how to add a source of a given total flux at a sub-pixel position into an image, conserving flux. Models are evaluated on a small stamp around the source for efficiency. The Gaussian uses the exact per-pixel integral (via the error function) so it is flux-conserving to machine precision; the Moffat is sampled on a stamp and normalised.

PSF

Base class for point-spread functions.

Source code in src/getframes/scene/psf.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class PSF:
    """Base class for point-spread functions."""

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        """Add ``flux`` photons/s of a point source at sub-pixel ``(x, y)`` into ``image``."""
        raise NotImplementedError

    def add_sources(
        self,
        image: NDArray[np.float64],
        xs: NDArray[np.float64],
        ys: NDArray[np.float64],
        fluxes: NDArray[np.float64],
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        """Add many point sources at once (vectorised where the PSF supports it).

        ``xs``, ``ys``, ``fluxes`` are equal-length 1-D arrays of sub-pixel column,
        row, and total flux. The generic implementation loops over
        :meth:`add_source`; subclasses (e.g. :class:`GaussianPSF`) override it with a
        batched, chunked evaluation so a large :class:`~getframes.scene.sources.Catalog`
        does not pay a Python-level per-source loop.
        """
        for x, y, flux in zip(xs, ys, fluxes):
            self.add_source(image, float(x), float(y), float(flux), plate_scale_arcsec_per_pixel)

add_source(image, x, y, flux, plate_scale_arcsec_per_pixel)

Add flux photons/s of a point source at sub-pixel (x, y) into image.

Source code in src/getframes/scene/psf.py
43
44
45
46
47
48
49
50
51
52
def add_source(
    self,
    image: NDArray[np.float64],
    x: float,
    y: float,
    flux: float,
    plate_scale_arcsec_per_pixel: float,
) -> None:
    """Add ``flux`` photons/s of a point source at sub-pixel ``(x, y)`` into ``image``."""
    raise NotImplementedError

add_sources(image, xs, ys, fluxes, plate_scale_arcsec_per_pixel)

Add many point sources at once (vectorised where the PSF supports it).

xs, ys, fluxes are equal-length 1-D arrays of sub-pixel column, row, and total flux. The generic implementation loops over :meth:add_source; subclasses (e.g. :class:GaussianPSF) override it with a batched, chunked evaluation so a large :class:~getframes.scene.sources.Catalog does not pay a Python-level per-source loop.

Source code in src/getframes/scene/psf.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def add_sources(
    self,
    image: NDArray[np.float64],
    xs: NDArray[np.float64],
    ys: NDArray[np.float64],
    fluxes: NDArray[np.float64],
    plate_scale_arcsec_per_pixel: float,
) -> None:
    """Add many point sources at once (vectorised where the PSF supports it).

    ``xs``, ``ys``, ``fluxes`` are equal-length 1-D arrays of sub-pixel column,
    row, and total flux. The generic implementation loops over
    :meth:`add_source`; subclasses (e.g. :class:`GaussianPSF`) override it with a
    batched, chunked evaluation so a large :class:`~getframes.scene.sources.Catalog`
    does not pay a Python-level per-source loop.
    """
    for x, y, flux in zip(xs, ys, fluxes):
        self.add_source(image, float(x), float(y), float(flux), plate_scale_arcsec_per_pixel)

GaussianPSF dataclass

Bases: PSF

A circular Gaussian PSF specified by its full width at half maximum.

Source code in src/getframes/scene/psf.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
@dataclass(frozen=True)
class GaussianPSF(PSF):
    """A circular Gaussian PSF specified by its full width at half maximum."""

    fwhm_arcsec: float

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        if sigma <= 0:
            raise ValueError("PSF FWHM and plate scale must be positive.")

        radius = int(np.ceil(5.0 * sigma)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return  # source falls entirely off the frame

        # Exact per-pixel integral: pixel i spans [i-0.5, i+0.5]; integrate the
        # Gaussian over each pixel using the error-function CDF at the edges.
        scale = sigma * np.sqrt(2.0)
        edges_x = np.arange(x0, x1 + 1) - 0.5
        edges_y = np.arange(y0, y1 + 1) - 0.5
        cdf_x = 0.5 * (1.0 + erf((edges_x - x) / scale))
        cdf_y = 0.5 * (1.0 + erf((edges_y - y) / scale))
        px = np.diff(cdf_x)
        py = np.diff(cdf_y)
        image[y0:y1, x0:x1] += flux * np.outer(py, px)

    def add_sources(
        self,
        image: NDArray[np.float64],
        xs: NDArray[np.float64],
        ys: NDArray[np.float64],
        fluxes: NDArray[np.float64],
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        """Vectorised, chunked deposition of many Gaussian point sources.

        Builds every source's exact per-pixel error-function integral on a common
        stamp in one batched NumPy expression and scatter-adds it into ``image``,
        replacing the Python per-source loop. Identical pixel values to repeated
        :meth:`add_source` calls (flux off the frame is clipped the same way). Work
        is chunked over sources to keep the intermediate ``(chunk, stamp, stamp)``
        buffer bounded for very large catalogues.
        """
        xs = np.asarray(xs, dtype=np.float64)
        ys = np.asarray(ys, dtype=np.float64)
        fluxes = np.asarray(fluxes, dtype=np.float64)
        sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        if sigma <= 0:
            raise ValueError("PSF FWHM and plate scale must be positive.")
        keep = fluxes > 0
        if not keep.any():
            return
        xs, ys, fluxes = xs[keep], ys[keep], fluxes[keep]

        radius = int(np.ceil(5.0 * sigma)) + 1
        span = 2 * radius + 1
        scale = sigma * np.sqrt(2.0)
        height, width = image.shape
        # Process in chunks so the (n, span, span) stamp buffer stays bounded.
        chunk = max(1, _STAMP_BUDGET // (span * span))
        offsets = np.arange(span)
        edge_offsets = np.arange(span + 1) - 0.5
        for start in range(0, xs.shape[0], chunk):
            cx = xs[start : start + chunk]
            cy = ys[start : start + chunk]
            cf = fluxes[start : start + chunk]
            ix = np.round(cx).astype(np.intp)
            iy = np.round(cy).astype(np.intp)
            # Exact per-pixel integral on the common stamp, per source (separable).
            edges_x = (ix[:, None] - radius) + edge_offsets[None, :]
            edges_y = (iy[:, None] - radius) + edge_offsets[None, :]
            px = np.diff(0.5 * (1.0 + erf((edges_x - cx[:, None]) / scale)), axis=1)
            py = np.diff(0.5 * (1.0 + erf((edges_y - cy[:, None]) / scale)), axis=1)
            stamps = cf[:, None, None] * py[:, :, None] * px[:, None, :]
            cols = (ix[:, None] - radius) + offsets[None, :]  # (n, span)
            rows = (iy[:, None] - radius) + offsets[None, :]  # (n, span)
            rr = rows[:, :, None]
            cc = cols[:, None, :]
            inb = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
            r_full = np.broadcast_to(rr, stamps.shape)[inb]
            c_full = np.broadcast_to(cc, stamps.shape)[inb]
            np.add.at(image, (r_full, c_full), stamps[inb])

add_sources(image, xs, ys, fluxes, plate_scale_arcsec_per_pixel)

Vectorised, chunked deposition of many Gaussian point sources.

Builds every source's exact per-pixel error-function integral on a common stamp in one batched NumPy expression and scatter-adds it into image, replacing the Python per-source loop. Identical pixel values to repeated :meth:add_source calls (flux off the frame is clipped the same way). Work is chunked over sources to keep the intermediate (chunk, stamp, stamp) buffer bounded for very large catalogues.

Source code in src/getframes/scene/psf.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
def add_sources(
    self,
    image: NDArray[np.float64],
    xs: NDArray[np.float64],
    ys: NDArray[np.float64],
    fluxes: NDArray[np.float64],
    plate_scale_arcsec_per_pixel: float,
) -> None:
    """Vectorised, chunked deposition of many Gaussian point sources.

    Builds every source's exact per-pixel error-function integral on a common
    stamp in one batched NumPy expression and scatter-adds it into ``image``,
    replacing the Python per-source loop. Identical pixel values to repeated
    :meth:`add_source` calls (flux off the frame is clipped the same way). Work
    is chunked over sources to keep the intermediate ``(chunk, stamp, stamp)``
    buffer bounded for very large catalogues.
    """
    xs = np.asarray(xs, dtype=np.float64)
    ys = np.asarray(ys, dtype=np.float64)
    fluxes = np.asarray(fluxes, dtype=np.float64)
    sigma = self.fwhm_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
    if sigma <= 0:
        raise ValueError("PSF FWHM and plate scale must be positive.")
    keep = fluxes > 0
    if not keep.any():
        return
    xs, ys, fluxes = xs[keep], ys[keep], fluxes[keep]

    radius = int(np.ceil(5.0 * sigma)) + 1
    span = 2 * radius + 1
    scale = sigma * np.sqrt(2.0)
    height, width = image.shape
    # Process in chunks so the (n, span, span) stamp buffer stays bounded.
    chunk = max(1, _STAMP_BUDGET // (span * span))
    offsets = np.arange(span)
    edge_offsets = np.arange(span + 1) - 0.5
    for start in range(0, xs.shape[0], chunk):
        cx = xs[start : start + chunk]
        cy = ys[start : start + chunk]
        cf = fluxes[start : start + chunk]
        ix = np.round(cx).astype(np.intp)
        iy = np.round(cy).astype(np.intp)
        # Exact per-pixel integral on the common stamp, per source (separable).
        edges_x = (ix[:, None] - radius) + edge_offsets[None, :]
        edges_y = (iy[:, None] - radius) + edge_offsets[None, :]
        px = np.diff(0.5 * (1.0 + erf((edges_x - cx[:, None]) / scale)), axis=1)
        py = np.diff(0.5 * (1.0 + erf((edges_y - cy[:, None]) / scale)), axis=1)
        stamps = cf[:, None, None] * py[:, :, None] * px[:, None, :]
        cols = (ix[:, None] - radius) + offsets[None, :]  # (n, span)
        rows = (iy[:, None] - radius) + offsets[None, :]  # (n, span)
        rr = rows[:, :, None]
        cc = cols[:, None, :]
        inb = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width)
        r_full = np.broadcast_to(rr, stamps.shape)[inb]
        c_full = np.broadcast_to(cc, stamps.shape)[inb]
        np.add.at(image, (r_full, c_full), stamps[inb])

MoffatPSF dataclass

Bases: PSF

A Moffat PSF, a better match to seeing-limited stars than a Gaussian.

The beta parameter controls the wings: smaller beta gives broader wings (beta -> infinity approaches a Gaussian). beta ~ 3 is typical for atmospheric seeing.

Source code in src/getframes/scene/psf.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
@dataclass(frozen=True)
class MoffatPSF(PSF):
    """A Moffat PSF, a better match to seeing-limited stars than a Gaussian.

    The ``beta`` parameter controls the wings: smaller ``beta`` gives broader wings
    (``beta -> infinity`` approaches a Gaussian). ``beta ~ 3`` is typical for
    atmospheric seeing.
    """

    fwhm_arcsec: float
    beta: float = 3.0

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        if self.beta <= 1.0:
            raise ValueError("Moffat beta must be > 1.")
        fwhm_pix = self.fwhm_arcsec / plate_scale_arcsec_per_pixel
        alpha = fwhm_pix / (2.0 * np.sqrt(2.0 ** (1.0 / self.beta) - 1.0))

        radius = int(np.ceil(6.0 * alpha)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        rr = xs[None, :] ** 2 + ys[:, None] ** 2
        profile = (1.0 + rr / alpha**2) ** (-self.beta)
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

EllipticalGaussianPSF dataclass

Bases: PSF

An elliptical Gaussian PSF with independent major/minor widths and an angle.

position_angle_deg is the angle of the major axis, measured counter-clockwise from the +x axis. The profile is sampled on a stamp and normalised (not the exact error-function integral the circular :class:GaussianPSF uses), so flux is conserved to the sampling accuracy.

Source code in src/getframes/scene/psf.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
@dataclass(frozen=True)
class EllipticalGaussianPSF(PSF):
    """An elliptical Gaussian PSF with independent major/minor widths and an angle.

    ``position_angle_deg`` is the angle of the major axis, measured counter-clockwise
    from the +x axis. The profile is sampled on a stamp and normalised (not the exact
    error-function integral the circular :class:`GaussianPSF` uses), so flux is
    conserved to the sampling accuracy.
    """

    fwhm_major_arcsec: float
    fwhm_minor_arcsec: float
    position_angle_deg: float = 0.0

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        if self.fwhm_minor_arcsec > self.fwhm_major_arcsec:
            raise ValueError("fwhm_minor_arcsec must not exceed fwhm_major_arcsec.")
        sigma_major = self.fwhm_major_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        sigma_minor = self.fwhm_minor_arcsec / _FWHM_PER_SIGMA / plate_scale_arcsec_per_pixel
        if sigma_minor <= 0:
            raise ValueError("PSF FWHM and plate scale must be positive.")

        radius = int(np.ceil(5.0 * sigma_major)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        theta = math.radians(self.position_angle_deg)
        cos_t, sin_t = math.cos(theta), math.sin(theta)
        u = xs[None, :] * cos_t + ys[:, None] * sin_t
        v = -xs[None, :] * sin_t + ys[:, None] * cos_t
        profile = np.exp(-0.5 * ((u / sigma_major) ** 2 + (v / sigma_minor) ** 2))
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

AiryPSF dataclass

Bases: PSF

The diffraction-limited Airy pattern of a circular aperture.

Models a space- or AO-corrected diffraction-limited core: the intensity is [2 J1(x)/x]^2 with x = pi * D * theta / lambda, optionally including a central obstruction of fractional diameter obstruction. The first dark ring sits at theta = 1.22 lambda / D. Sampled on a stamp and normalised.

Parameters:

Name Type Description Default
aperture_diameter_m float

Aperture diameter in metres (sets the angular scale of the pattern).

required
wavelength_m float

Observing wavelength in metres.

required
obstruction float

Central-obstruction diameter as a fraction of the aperture, in [0, 1).

0.0
Source code in src/getframes/scene/psf.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
@dataclass(frozen=True)
class AiryPSF(PSF):
    """The diffraction-limited Airy pattern of a circular aperture.

    Models a space- or AO-corrected diffraction-limited core: the intensity is
    ``[2 J1(x)/x]^2`` with ``x = pi * D * theta / lambda``, optionally including a
    central obstruction of fractional diameter ``obstruction``. The first dark ring
    sits at ``theta = 1.22 lambda / D``. Sampled on a stamp and normalised.

    Parameters
    ----------
    aperture_diameter_m:
        Aperture diameter in metres (sets the angular scale of the pattern).
    wavelength_m:
        Observing wavelength in metres.
    obstruction:
        Central-obstruction diameter as a fraction of the aperture, in ``[0, 1)``.
    """

    aperture_diameter_m: float
    wavelength_m: float
    obstruction: float = 0.0

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        if self.aperture_diameter_m <= 0 or self.wavelength_m <= 0:
            raise ValueError("AiryPSF aperture_diameter_m and wavelength_m must be positive.")
        if not 0.0 <= self.obstruction < 1.0:
            raise ValueError("AiryPSF obstruction must be in [0, 1).")

        # Radians per pixel, then the argument scale x = pi D theta / lambda.
        rad_per_pixel = plate_scale_arcsec_per_pixel * (math.pi / 180.0 / 3600.0)
        arg_per_pixel = math.pi * self.aperture_diameter_m / self.wavelength_m * rad_per_pixel
        # First null at 1.22 lambda / D; size the stamp to a few Airy rings.
        first_null_pix = 1.22 / (arg_per_pixel / math.pi) if arg_per_pixel > 0 else 1.0
        radius = int(np.ceil(5.0 * first_null_pix)) + 1
        x0, x1, y0, y1 = _stamp_bounds(x, y, radius, image.shape)
        if x0 >= x1 or y0 >= y1:
            return

        xs = np.arange(x0, x1) - x
        ys = np.arange(y0, y1) - y
        rr = np.sqrt(xs[None, :] ** 2 + ys[:, None] ** 2)
        arg = arg_per_pixel * rr
        profile = _airy_intensity(arg, self.obstruction)
        total = profile.sum()
        if total > 0:
            image[y0:y1, x0:x1] += flux * profile / total

ArrayPSF dataclass

Bases: PSF

A user-supplied PSF kernel, e.g. straight from an AO/optics simulation.

The kernel is a 2D array sampled at detector-pixel resolution; it is normalised to unit sum on construction. Sub-pixel source positions are handled by a first-order (bilinear) shift of the kernel before it is pasted, so the centroid lands at the requested location. Flux falling off the frame is clipped.

Source code in src/getframes/scene/psf.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
@dataclass(frozen=True)
class ArrayPSF(PSF):
    """A user-supplied PSF kernel, e.g. straight from an AO/optics simulation.

    The ``kernel`` is a 2D array sampled at detector-pixel resolution; it is
    normalised to unit sum on construction. Sub-pixel source positions are handled by
    a first-order (bilinear) shift of the kernel before it is pasted, so the centroid
    lands at the requested location. Flux falling off the frame is clipped.
    """

    kernel: NDArray[np.float64]

    def __post_init__(self) -> None:
        arr = np.asarray(self.kernel, dtype=np.float64)
        if arr.ndim != 2 or arr.size == 0:
            raise ValueError("ArrayPSF kernel must be a non-empty 2D array.")
        total = float(arr.sum())
        if total <= 0:
            raise ValueError("ArrayPSF kernel must have a positive sum.")
        object.__setattr__(self, "kernel", arr / total)

    def add_source(
        self,
        image: NDArray[np.float64],
        x: float,
        y: float,
        flux: float,
        plate_scale_arcsec_per_pixel: float,
    ) -> None:
        if flux <= 0:
            return
        kh, kw = self.kernel.shape
        ix, iy = round(x), round(y)
        fx, fy = x - ix, y - iy
        stamp = _ndimage_shift(self.kernel, (fy, fx), order=1, mode="constant", cval=0.0)

        top, left = iy - kh // 2, ix - kw // 2
        height, width = image.shape
        y0, y1 = max(0, top), min(height, top + kh)
        x0, x1 = max(0, left), min(width, left + kw)
        if y0 >= y1 or x0 >= x1:
            return
        image[y0:y1, x0:x1] += flux * stamp[y0 - top : y1 - top, x0 - left : x1 - left]

Time series

getframes.scene.sources.LightCurve dataclass

A time-varying brightness multiplier for a source.

A light curve maps a time t (seconds, measured from the start of an observation) to a dimensionless factor that multiplies the source's baseline brightness. A constant 1.0 leaves the source unchanged; 0.99 during a transit dims it by 1%.

Time variability is owned by the source (see :attr:PointSource.brightness): :meth:getframes.Camera.observe_series samples the curve at each frame's timestamp, so the injected signal is reproducible and recorded in the observation's per-frame truth.

Construct one with a factory (:meth:box, :meth:sinusoidal, :meth:constant) or wrap any callable with :meth:from_function. The instance itself is callable: lc(t) returns the multiplier.

Parameters:

Name Type Description Default
func Callable[[float], float]

Callable mapping time in seconds to a non-negative brightness multiplier.

required
Source code in src/getframes/scene/sources.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@dataclass(frozen=True)
class LightCurve:
    """A time-varying brightness multiplier for a source.

    A light curve maps a time ``t`` (seconds, measured from the start of an
    observation) to a dimensionless factor that multiplies the source's baseline
    brightness. A constant ``1.0`` leaves the source unchanged; ``0.99`` during a
    transit dims it by 1%.

    Time variability is *owned by the source* (see :attr:`PointSource.brightness`):
    :meth:`getframes.Camera.observe_series` samples the curve at each frame's
    timestamp, so the injected signal is reproducible and recorded in the
    observation's per-frame truth.

    Construct one with a factory (:meth:`box`, :meth:`sinusoidal`,
    :meth:`constant`) or wrap any callable with :meth:`from_function`. The instance
    itself is callable: ``lc(t)`` returns the multiplier.

    Parameters
    ----------
    func:
        Callable mapping time in seconds to a non-negative brightness multiplier.
    """

    func: Callable[[float], float]

    def __call__(self, time_s: float) -> float:
        value = float(self.func(time_s))
        if value < 0.0:
            raise ValueError("LightCurve produced a negative brightness multiplier.")
        return value

    @classmethod
    def constant(cls, level: float = 1.0) -> LightCurve:
        """A flat light curve at ``level`` (default ``1.0``, i.e. no variation)."""
        return cls(lambda _t: level)

    @classmethod
    def box(cls, depth: float, t0: float, t1: float, baseline: float = 1.0) -> LightCurve:
        """A box-shaped dip of fractional ``depth`` between times ``t0`` and ``t1``.

        Outside ``[t0, t1)`` the multiplier is ``baseline``; inside it is
        ``baseline * (1 - depth)``. A simple model of a flat-bottomed transit
        (``depth=0.01`` for a 1% transit).
        """
        if not 0.0 <= depth <= 1.0:
            raise ValueError("box depth must be in [0, 1].")
        if t1 < t0:
            raise ValueError("box requires t1 >= t0.")

        def curve(t: float) -> float:
            return baseline * (1.0 - depth) if t0 <= t < t1 else baseline

        return cls(curve)

    @classmethod
    def sinusoidal(
        cls,
        amplitude: float,
        period_s: float,
        *,
        phase: float = 0.0,
        baseline: float = 1.0,
    ) -> LightCurve:
        """A sinusoid: ``baseline + amplitude * sin(2*pi*t/period + phase)``.

        Models a pulsating or rotating variable. ``amplitude`` is in the same units
        as ``baseline`` (i.e. a fraction of the unit baseline); keep
        ``amplitude <= baseline`` to stay non-negative.
        """
        if period_s <= 0:
            raise ValueError("sinusoidal period_s must be positive.")
        omega = 2.0 * math.pi / period_s

        def curve(t: float) -> float:
            return baseline + amplitude * math.sin(omega * t + phase)

        return cls(curve)

    @classmethod
    def from_function(cls, func: Callable[[float], float]) -> LightCurve:
        """Wrap an arbitrary ``t -> multiplier`` callable as a light curve."""
        return cls(func)

constant(level=1.0) classmethod

A flat light curve at level (default 1.0, i.e. no variation).

Source code in src/getframes/scene/sources.py
119
120
121
122
@classmethod
def constant(cls, level: float = 1.0) -> LightCurve:
    """A flat light curve at ``level`` (default ``1.0``, i.e. no variation)."""
    return cls(lambda _t: level)

box(depth, t0, t1, baseline=1.0) classmethod

A box-shaped dip of fractional depth between times t0 and t1.

Outside [t0, t1) the multiplier is baseline; inside it is baseline * (1 - depth). A simple model of a flat-bottomed transit (depth=0.01 for a 1% transit).

Source code in src/getframes/scene/sources.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
@classmethod
def box(cls, depth: float, t0: float, t1: float, baseline: float = 1.0) -> LightCurve:
    """A box-shaped dip of fractional ``depth`` between times ``t0`` and ``t1``.

    Outside ``[t0, t1)`` the multiplier is ``baseline``; inside it is
    ``baseline * (1 - depth)``. A simple model of a flat-bottomed transit
    (``depth=0.01`` for a 1% transit).
    """
    if not 0.0 <= depth <= 1.0:
        raise ValueError("box depth must be in [0, 1].")
    if t1 < t0:
        raise ValueError("box requires t1 >= t0.")

    def curve(t: float) -> float:
        return baseline * (1.0 - depth) if t0 <= t < t1 else baseline

    return cls(curve)

sinusoidal(amplitude, period_s, *, phase=0.0, baseline=1.0) classmethod

A sinusoid: baseline + amplitude * sin(2*pi*t/period + phase).

Models a pulsating or rotating variable. amplitude is in the same units as baseline (i.e. a fraction of the unit baseline); keep amplitude <= baseline to stay non-negative.

Source code in src/getframes/scene/sources.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@classmethod
def sinusoidal(
    cls,
    amplitude: float,
    period_s: float,
    *,
    phase: float = 0.0,
    baseline: float = 1.0,
) -> LightCurve:
    """A sinusoid: ``baseline + amplitude * sin(2*pi*t/period + phase)``.

    Models a pulsating or rotating variable. ``amplitude`` is in the same units
    as ``baseline`` (i.e. a fraction of the unit baseline); keep
    ``amplitude <= baseline`` to stay non-negative.
    """
    if period_s <= 0:
        raise ValueError("sinusoidal period_s must be positive.")
    omega = 2.0 * math.pi / period_s

    def curve(t: float) -> float:
        return baseline + amplitude * math.sin(omega * t + phase)

    return cls(curve)

from_function(func) classmethod

Wrap an arbitrary t -> multiplier callable as a light curve.

Source code in src/getframes/scene/sources.py
166
167
168
169
@classmethod
def from_function(cls, func: Callable[[float], float]) -> LightCurve:
    """Wrap an arbitrary ``t -> multiplier`` callable as a light curve."""
    return cls(func)

getframes.observation.Observation dataclass

A reproducible stack of frames of one scene over time.

Returned by :meth:getframes.Camera.observe_series. It is iterable and indexable over its :attr:frames, so existing for frame in obs: style code keeps working, while :attr:truth, :attr:times_s, and :attr:offsets_pixels expose the time and pointing information.

Attributes:

Name Type Description
frames list[Frame]

The realised science :class:~getframes.frame.Frame stack, in time order.

times_s NDArray[float64]

Frame timestamps in seconds, shape (n_frames,).

offsets_pixels NDArray[float64]

The realised pointing offset (dx, dy) applied to each frame, in pixels, shape (n_frames, 2).

truth ObservationTruth | None

The :class:ObservationTruth light curve, or None when truth was not requested.

Source code in src/getframes/observation.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
@dataclass(frozen=True)
class Observation:
    """A reproducible stack of frames of one scene over time.

    Returned by :meth:`getframes.Camera.observe_series`. It is iterable and
    indexable over its :attr:`frames`, so existing ``for frame in obs:`` style code
    keeps working, while :attr:`truth`, :attr:`times_s`, and :attr:`offsets_pixels`
    expose the time and pointing information.

    Attributes
    ----------
    frames:
        The realised science :class:`~getframes.frame.Frame` stack, in time order.
    times_s:
        Frame timestamps in seconds, shape ``(n_frames,)``.
    offsets_pixels:
        The realised pointing offset ``(dx, dy)`` applied to each frame, in pixels,
        shape ``(n_frames, 2)``.
    truth:
        The :class:`ObservationTruth` light curve, or ``None`` when truth was not
        requested.
    """

    frames: list[Frame]
    times_s: NDArray[np.float64]
    offsets_pixels: NDArray[np.float64]
    truth: ObservationTruth | None = field(default=None)

    def __iter__(self) -> Iterator[Frame]:
        return iter(self.frames)

    def __len__(self) -> int:
        return len(self.frames)

    def __getitem__(self, index: int) -> Frame:
        return self.frames[index]

    def __repr__(self) -> str:
        cam = self.frames[0].metadata.get("camera", "?") if self.frames else "?"
        return f"Observation(n_frames={len(self.frames)}, camera={cam!r})"

getframes.observation.ObservationTruth dataclass

The noise-free ground truth of an :class:Observation.

Attributes:

Name Type Description
times_s NDArray[float64]

The frame timestamps, in seconds from the start of the observation, shape (n_frames,).

light_curve dict[str, NDArray[float64]]

Per-source injected signal: a mapping from source name to an array of the noise-free incident photons collected from that source in each frame (photon rate x exposure, post-optics, pre-quantum-efficiency), shape (n_frames,). This is the true light curve to validate measured photometry against. Unnamed sources are keyed "source_{index}".

Source code in src/getframes/observation.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@dataclass(frozen=True)
class ObservationTruth:
    """The noise-free ground truth of an :class:`Observation`.

    Attributes
    ----------
    times_s:
        The frame timestamps, in seconds from the start of the observation,
        shape ``(n_frames,)``.
    light_curve:
        Per-source injected signal: a mapping from source name to an array of the
        noise-free incident photons collected from that source in each frame
        (photon rate x exposure, post-optics, pre-quantum-efficiency), shape
        ``(n_frames,)``. This is the true light curve to validate measured
        photometry against. Unnamed sources are keyed ``"source_{index}"``.
    """

    times_s: NDArray[np.float64]
    light_curve: dict[str, NDArray[np.float64]]

getframes.observation.Pointing dataclass

A per-frame pointing model: jitter, slow drift, and a programmed dither.

The three components combine additively into a whole-field offset applied to every source in the scene at each frame. Offsets are specified in arcseconds (converted to pixels with the scene's plate scale) so the model is independent of the detector sampling.

Parameters:

Name Type Description Default
jitter_arcsec float

RMS of a per-frame Gaussian offset drawn independently for each axis and each frame. Models random tracking jitter and atmospheric tip-tilt / image motion (e.g. for AO sub-apertures). 0 disables it.

0.0
drift_arcsec_per_s tuple[float, float]

A constant (vx, vy) velocity giving a slow linear drift; the offset at time t is (vx * t, vy * t). Models tracking error / field rotation creep.

(0.0, 0.0)
dither_arcsec Sequence[tuple[float, float]] | None

An optional sequence of programmed (dx, dy) offsets, cycled by frame index (frame i uses entry i % len). Models a deliberate dither pattern. None for no dither.

None
Source code in src/getframes/observation.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True)
class Pointing:
    """A per-frame pointing model: jitter, slow drift, and a programmed dither.

    The three components combine additively into a whole-field offset applied to
    every source in the scene at each frame. Offsets are specified in arcseconds
    (converted to pixels with the scene's plate scale) so the model is independent
    of the detector sampling.

    Parameters
    ----------
    jitter_arcsec:
        RMS of a per-frame Gaussian offset drawn independently for each axis and
        each frame. Models random tracking jitter and atmospheric tip-tilt / image
        motion (e.g. for AO sub-apertures). ``0`` disables it.
    drift_arcsec_per_s:
        A constant ``(vx, vy)`` velocity giving a slow linear drift; the offset at
        time ``t`` is ``(vx * t, vy * t)``. Models tracking error / field rotation
        creep.
    dither_arcsec:
        An optional sequence of programmed ``(dx, dy)`` offsets, cycled by frame
        index (frame ``i`` uses entry ``i % len``). Models a deliberate dither
        pattern. ``None`` for no dither.
    """

    jitter_arcsec: float = 0.0
    drift_arcsec_per_s: tuple[float, float] = (0.0, 0.0)
    dither_arcsec: Sequence[tuple[float, float]] | None = None

    def __post_init__(self) -> None:
        if self.jitter_arcsec < 0:
            raise ValueError("jitter_arcsec must be non-negative.")

    @property
    def is_static(self) -> bool:
        """Whether this model never moves the field (a no-op pointing)."""
        return (
            self.jitter_arcsec == 0.0
            and self.drift_arcsec_per_s == (0.0, 0.0)
            and not self.dither_arcsec
        )

    def offset_pixels(
        self,
        frame_index: int,
        time_s: float,
        plate_scale_arcsec_per_pixel: float,
        rng: np.random.Generator,
    ) -> tuple[float, float]:
        """The realised ``(dx, dy)`` offset in pixels for one frame.

        Combines drift (deterministic in ``time_s``), the cycled dither entry, and a
        fresh Gaussian jitter draw, then converts arcseconds to pixels.
        """
        dx_as = self.drift_arcsec_per_s[0] * time_s
        dy_as = self.drift_arcsec_per_s[1] * time_s
        if self.dither_arcsec:
            ddx, ddy = self.dither_arcsec[frame_index % len(self.dither_arcsec)]
            dx_as += ddx
            dy_as += ddy
        if self.jitter_arcsec > 0:
            dx_as += float(rng.normal(0.0, self.jitter_arcsec))
            dy_as += float(rng.normal(0.0, self.jitter_arcsec))
        return dx_as / plate_scale_arcsec_per_pixel, dy_as / plate_scale_arcsec_per_pixel

is_static property

Whether this model never moves the field (a no-op pointing).

offset_pixels(frame_index, time_s, plate_scale_arcsec_per_pixel, rng)

The realised (dx, dy) offset in pixels for one frame.

Combines drift (deterministic in time_s), the cycled dither entry, and a fresh Gaussian jitter draw, then converts arcseconds to pixels.

Source code in src/getframes/observation.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def offset_pixels(
    self,
    frame_index: int,
    time_s: float,
    plate_scale_arcsec_per_pixel: float,
    rng: np.random.Generator,
) -> tuple[float, float]:
    """The realised ``(dx, dy)`` offset in pixels for one frame.

    Combines drift (deterministic in ``time_s``), the cycled dither entry, and a
    fresh Gaussian jitter draw, then converts arcseconds to pixels.
    """
    dx_as = self.drift_arcsec_per_s[0] * time_s
    dy_as = self.drift_arcsec_per_s[1] * time_s
    if self.dither_arcsec:
        ddx, ddy = self.dither_arcsec[frame_index % len(self.dither_arcsec)]
        dx_as += ddx
        dy_as += ddy
    if self.jitter_arcsec > 0:
        dx_as += float(rng.normal(0.0, self.jitter_arcsec))
        dy_as += float(rng.normal(0.0, self.jitter_arcsec))
    return dx_as / plate_scale_arcsec_per_pixel, dy_as / plate_scale_arcsec_per_pixel

Spectral mode

getframes.spectral

Wavelength-resolved primitives for the opt-in spectral mode.

The band-integrated model (a scalar quantum efficiency and a single photon zero point per band) is accurate enough for exposure planning, but it cannot capture how a detector's response colour interacts with a source's spectral energy distribution (SED). Spectral mode adds that, additively, through three tabulated curves on a shared wavelength axis (nanometres):

  • :class:SED --- a source's spectral photon flux density (shape only; the absolute level is still set by the source magnitude),
  • :class:SpectralBandpass --- a filter/optics transmission response in [0, 1],
  • :class:QE --- a detector quantum-efficiency curve in [0, 1].

The single physical quantity spectral mode computes is the effective quantum efficiency a source sees,

.. math::

\mathrm{QE}_\mathrm{eff} =
    \frac{\int S(\lambda)\,T(\lambda)\,\mathrm{QE}(\lambda)\,d\lambda}
         {\int S(\lambda)\,T(\lambda)\,d\lambda},

a photon-weighted average of :math:\mathrm{QE}(\lambda) over the band. It is a ratio, so it is invariant to the absolute normalisation of both the SED and the bandpass --- which is why spectral mode needs no absolute reference spectrum and leaves the magnitude-to-photon-rate conversion (governed by the band zero point) untouched. Only the photon-to-electron conversion is refined.

Everything here is pure NumPy and free of randomness.

Spectrum dataclass

A tabulated, non-negative curve value(wavelength_nm).

Values are linearly interpolated within the sampled range and treated as zero outside it. The wavelength axis is in nanometres and must be strictly increasing. This base class carries the shared sampling/integration machinery; :class:SED, :class:SpectralBandpass, and :class:QE add units and constructors.

Source code in src/getframes/spectral.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
@dataclass(frozen=True)
class Spectrum:
    """A tabulated, non-negative curve ``value(wavelength_nm)``.

    Values are linearly interpolated within the sampled range and treated as zero
    outside it. The wavelength axis is in nanometres and must be strictly
    increasing. This base class carries the shared sampling/integration machinery;
    :class:`SED`, :class:`SpectralBandpass`, and :class:`QE` add units and
    constructors.
    """

    wavelength_nm: NDArray[np.float64]
    value: NDArray[np.float64]

    def __post_init__(self) -> None:
        wl, val = _as_grid(self.wavelength_nm, self.value)
        if np.any(val < 0):
            raise ValueError("spectrum values must be non-negative.")
        object.__setattr__(self, "wavelength_nm", wl)
        object.__setattr__(self, "value", val)

    def __call__(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
        """Interpolate the curve at ``wavelength_nm`` (zero outside the sampled range)."""
        wl = np.asarray(wavelength_nm, dtype=np.float64)
        return np.interp(wl, self.wavelength_nm, self.value, left=0.0, right=0.0)

    @classmethod
    def from_file(
        cls,
        path: str,
        *,
        wavelength_to_nm: float = 1.0,
        delimiter: str | None = None,
        skiprows: int = 0,
        usecols: tuple[int, int] = (0, 1),
    ) -> Spectrum:
        """Load a two-column ``(wavelength, value)`` curve from a text file.

        Reads ``path`` with :func:`numpy.loadtxt`. The first column is scaled by
        ``wavelength_to_nm`` to nanometres (e.g. ``0.1`` for angstroms, ``1000`` for
        microns); the second is taken verbatim. Handy for measured filter, QE, or
        atmospheric-transmission curves --- combine several with :func:`product` or
        :meth:`SpectralBandpass.from_product`.
        """
        data = np.loadtxt(path, delimiter=delimiter, skiprows=skiprows, usecols=usecols)
        wl = np.asarray(data[:, 0], dtype=np.float64) * float(wavelength_to_nm)
        val = np.asarray(data[:, 1], dtype=np.float64)
        return cls(wl, val)

    def integrate(self) -> float:
        """Trapezoidal integral of the curve over wavelength (nm)."""
        return float(_trapezoid(self.value, self.wavelength_nm))

    @property
    def wavelength_min_nm(self) -> float:
        return float(self.wavelength_nm[0])

    @property
    def wavelength_max_nm(self) -> float:
        return float(self.wavelength_nm[-1])

__call__(wavelength_nm)

Interpolate the curve at wavelength_nm (zero outside the sampled range).

Source code in src/getframes/spectral.py
112
113
114
115
def __call__(self, wavelength_nm: ArrayLike) -> NDArray[np.float64]:
    """Interpolate the curve at ``wavelength_nm`` (zero outside the sampled range)."""
    wl = np.asarray(wavelength_nm, dtype=np.float64)
    return np.interp(wl, self.wavelength_nm, self.value, left=0.0, right=0.0)

from_file(path, *, wavelength_to_nm=1.0, delimiter=None, skiprows=0, usecols=(0, 1)) classmethod

Load a two-column (wavelength, value) curve from a text file.

Reads path with :func:numpy.loadtxt. The first column is scaled by wavelength_to_nm to nanometres (e.g. 0.1 for angstroms, 1000 for microns); the second is taken verbatim. Handy for measured filter, QE, or atmospheric-transmission curves --- combine several with :func:product or :meth:SpectralBandpass.from_product.

Source code in src/getframes/spectral.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
@classmethod
def from_file(
    cls,
    path: str,
    *,
    wavelength_to_nm: float = 1.0,
    delimiter: str | None = None,
    skiprows: int = 0,
    usecols: tuple[int, int] = (0, 1),
) -> Spectrum:
    """Load a two-column ``(wavelength, value)`` curve from a text file.

    Reads ``path`` with :func:`numpy.loadtxt`. The first column is scaled by
    ``wavelength_to_nm`` to nanometres (e.g. ``0.1`` for angstroms, ``1000`` for
    microns); the second is taken verbatim. Handy for measured filter, QE, or
    atmospheric-transmission curves --- combine several with :func:`product` or
    :meth:`SpectralBandpass.from_product`.
    """
    data = np.loadtxt(path, delimiter=delimiter, skiprows=skiprows, usecols=usecols)
    wl = np.asarray(data[:, 0], dtype=np.float64) * float(wavelength_to_nm)
    val = np.asarray(data[:, 1], dtype=np.float64)
    return cls(wl, val)

integrate()

Trapezoidal integral of the curve over wavelength (nm).

Source code in src/getframes/spectral.py
140
141
142
def integrate(self) -> float:
    """Trapezoidal integral of the curve over wavelength (nm)."""
    return float(_trapezoid(self.value, self.wavelength_nm))

SED dataclass

Bases: Spectrum

A source's spectral photon flux density.

Two flavours, distinguished by :attr:is_absolute:

  • Relative (the default; :meth:from_arrays and the parametric shapes). Only the shape matters: spectral mode uses it to colour-weight the quantum efficiency, a calculation invariant to overall scale (the source magnitude still sets the absolute photon rate).
  • Absolute (:meth:from_flux_density): values are a true photon flux density in photons/s/m^2/nm above the atmosphere. Such an SED can set the integrated photon rate directly --- pass it to a source as flux_sed and the telescope integrates it over the band (see :meth:getframes.scene.photometry.Bandpass.photon_flux_from_sed), instead of deriving the rate from a magnitude.
Source code in src/getframes/spectral.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
@dataclass(frozen=True)
class SED(Spectrum):
    """A source's spectral *photon* flux density.

    Two flavours, distinguished by :attr:`is_absolute`:

    * **Relative** (the default; :meth:`from_arrays` and the parametric shapes).
      Only the *shape* matters: spectral mode uses it to colour-weight the quantum
      efficiency, a calculation invariant to overall scale (the source magnitude
      still sets the absolute photon rate).
    * **Absolute** (:meth:`from_flux_density`): values are a true photon flux density
      in ``photons/s/m^2/nm`` above the atmosphere. Such an SED can *set the
      integrated photon rate* directly --- pass it to a source as ``flux_sed`` and
      the telescope integrates it over the band (see
      :meth:`getframes.scene.photometry.Bandpass.photon_flux_from_sed`), instead of
      deriving the rate from a magnitude.
    """

    is_absolute: bool = False

    @classmethod
    def from_arrays(cls, wavelength_nm: ArrayLike, photon_flux: ArrayLike) -> SED:
        """A *relative* SED sampled at ``wavelength_nm`` with photon flux density (shape only)."""
        return cls(_to_nm(wavelength_nm), _strip_units(photon_flux))

    @classmethod
    def from_flux_density(cls, wavelength_nm: ArrayLike, photon_flux_density: ArrayLike) -> SED:
        """An *absolute* SED: ``photon_flux_density`` in ``photons/s/m^2/nm``.

        Unlike :meth:`from_arrays`, the absolute scale is meaningful: integrated over
        a band it yields a photon rate, so a source carrying this as ``flux_sed`` has
        its brightness set by the spectrum itself (no magnitude needed). Wavelengths
        and flux may be plain arrays (nm and photons/s/m^2/nm) or ``astropy.units``
        quantities, which are converted.
        """
        return cls(_to_nm(wavelength_nm), _strip_units(photon_flux_density), is_absolute=True)

    @classmethod
    def flat(
        cls,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    ) -> SED:
        """A flat photon spectrum (equal photons per unit wavelength).

        The neutral default: with a flat SED the effective QE is simply the
        bandpass-weighted mean of ``QE(lambda)``.
        """
        wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
        return cls(wl, np.ones_like(wl))

    @classmethod
    def blackbody(
        cls,
        temperature_k: float,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
        n_samples: int = 256,
    ) -> SED:
        """A blackbody photon spectrum at ``temperature_k`` (relative units).

        Photon spectral radiance ``~ lambda**-4 / (exp(hc / lambda k T) - 1)`` --- the
        Planck law expressed per photon rather than per unit energy. Good for giving
        a star a colour (e.g. ``5800`` K for a sun-like source, ``3500`` K for a cool
        M dwarf, ``10000`` K for a hot blue star).
        """
        if temperature_k <= 0:
            raise ValueError("temperature_k must be positive.")
        wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples, dtype=np.float64)
        wl_m = wl_nm * 1e-9
        x = _H_PLANCK * _C_LIGHT / (wl_m * _K_BOLTZMANN * temperature_k)
        # Photon radiance density: ~ lambda^-4 / (exp(x) - 1). Constants drop out.
        photons = wl_m**-4 / np.expm1(x)
        return cls(wl_nm, photons / photons.max())

    @classmethod
    def power_law(
        cls,
        index: float,
        reference_wavelength_nm: float = 550.0,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
        n_samples: int = 64,
    ) -> SED:
        """A power-law photon spectrum ``(lambda / lambda_ref)**index`` (relative)."""
        wl = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples, dtype=np.float64)
        return cls(wl, (wl / reference_wavelength_nm) ** index)

from_arrays(wavelength_nm, photon_flux) classmethod

A relative SED sampled at wavelength_nm with photon flux density (shape only).

Source code in src/getframes/spectral.py
223
224
225
226
@classmethod
def from_arrays(cls, wavelength_nm: ArrayLike, photon_flux: ArrayLike) -> SED:
    """A *relative* SED sampled at ``wavelength_nm`` with photon flux density (shape only)."""
    return cls(_to_nm(wavelength_nm), _strip_units(photon_flux))

from_flux_density(wavelength_nm, photon_flux_density) classmethod

An absolute SED: photon_flux_density in photons/s/m^2/nm.

Unlike :meth:from_arrays, the absolute scale is meaningful: integrated over a band it yields a photon rate, so a source carrying this as flux_sed has its brightness set by the spectrum itself (no magnitude needed). Wavelengths and flux may be plain arrays (nm and photons/s/m^2/nm) or astropy.units quantities, which are converted.

Source code in src/getframes/spectral.py
228
229
230
231
232
233
234
235
236
237
238
@classmethod
def from_flux_density(cls, wavelength_nm: ArrayLike, photon_flux_density: ArrayLike) -> SED:
    """An *absolute* SED: ``photon_flux_density`` in ``photons/s/m^2/nm``.

    Unlike :meth:`from_arrays`, the absolute scale is meaningful: integrated over
    a band it yields a photon rate, so a source carrying this as ``flux_sed`` has
    its brightness set by the spectrum itself (no magnitude needed). Wavelengths
    and flux may be plain arrays (nm and photons/s/m^2/nm) or ``astropy.units``
    quantities, which are converted.
    """
    return cls(_to_nm(wavelength_nm), _strip_units(photon_flux_density), is_absolute=True)

flat(wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM) classmethod

A flat photon spectrum (equal photons per unit wavelength).

The neutral default: with a flat SED the effective QE is simply the bandpass-weighted mean of QE(lambda).

Source code in src/getframes/spectral.py
240
241
242
243
244
245
246
247
248
249
250
251
252
@classmethod
def flat(
    cls,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
) -> SED:
    """A flat photon spectrum (equal photons per unit wavelength).

    The neutral default: with a flat SED the effective QE is simply the
    bandpass-weighted mean of ``QE(lambda)``.
    """
    wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
    return cls(wl, np.ones_like(wl))

blackbody(temperature_k, wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM, n_samples=256) classmethod

A blackbody photon spectrum at temperature_k (relative units).

Photon spectral radiance ~ lambda**-4 / (exp(hc / lambda k T) - 1) --- the Planck law expressed per photon rather than per unit energy. Good for giving a star a colour (e.g. 5800 K for a sun-like source, 3500 K for a cool M dwarf, 10000 K for a hot blue star).

Source code in src/getframes/spectral.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
@classmethod
def blackbody(
    cls,
    temperature_k: float,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    n_samples: int = 256,
) -> SED:
    """A blackbody photon spectrum at ``temperature_k`` (relative units).

    Photon spectral radiance ``~ lambda**-4 / (exp(hc / lambda k T) - 1)`` --- the
    Planck law expressed per photon rather than per unit energy. Good for giving
    a star a colour (e.g. ``5800`` K for a sun-like source, ``3500`` K for a cool
    M dwarf, ``10000`` K for a hot blue star).
    """
    if temperature_k <= 0:
        raise ValueError("temperature_k must be positive.")
    wl_nm = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples, dtype=np.float64)
    wl_m = wl_nm * 1e-9
    x = _H_PLANCK * _C_LIGHT / (wl_m * _K_BOLTZMANN * temperature_k)
    # Photon radiance density: ~ lambda^-4 / (exp(x) - 1). Constants drop out.
    photons = wl_m**-4 / np.expm1(x)
    return cls(wl_nm, photons / photons.max())

power_law(index, reference_wavelength_nm=550.0, wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM, n_samples=64) classmethod

A power-law photon spectrum (lambda / lambda_ref)**index (relative).

Source code in src/getframes/spectral.py
278
279
280
281
282
283
284
285
286
287
288
289
@classmethod
def power_law(
    cls,
    index: float,
    reference_wavelength_nm: float = 550.0,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    n_samples: int = 64,
) -> SED:
    """A power-law photon spectrum ``(lambda / lambda_ref)**index`` (relative)."""
    wl = np.linspace(wavelength_min_nm, wavelength_max_nm, n_samples, dtype=np.float64)
    return cls(wl, (wl / reference_wavelength_nm) ** index)

QE dataclass

Bases: Spectrum

A detector quantum-efficiency curve, QE(lambda) in [0, 1].

Source code in src/getframes/spectral.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
class QE(Spectrum):
    """A detector quantum-efficiency curve, ``QE(lambda)`` in ``[0, 1]``."""

    def __post_init__(self) -> None:
        super().__post_init__()
        if np.any(self.value > 1.0):
            raise ValueError("QE values must be in [0, 1].")

    @classmethod
    def from_arrays(cls, wavelength_nm: ArrayLike, qe: ArrayLike) -> QE:
        """A QE curve sampled at ``wavelength_nm`` with values in ``[0, 1]``."""
        return cls(_to_nm(wavelength_nm), _strip_units(qe))

    @classmethod
    def constant(
        cls,
        value: float,
        wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
        wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
    ) -> QE:
        """A flat QE curve --- equivalent to the scalar ``quantum_efficiency``."""
        if not 0.0 <= value <= 1.0:
            raise ValueError("QE value must be in [0, 1].")
        wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
        return cls(wl, np.full_like(wl, value))

from_arrays(wavelength_nm, qe) classmethod

A QE curve sampled at wavelength_nm with values in [0, 1].

Source code in src/getframes/spectral.py
300
301
302
303
@classmethod
def from_arrays(cls, wavelength_nm: ArrayLike, qe: ArrayLike) -> QE:
    """A QE curve sampled at ``wavelength_nm`` with values in ``[0, 1]``."""
    return cls(_to_nm(wavelength_nm), _strip_units(qe))

constant(value, wavelength_min_nm=_DEFAULT_WL_MIN_NM, wavelength_max_nm=_DEFAULT_WL_MAX_NM) classmethod

A flat QE curve --- equivalent to the scalar quantum_efficiency.

Source code in src/getframes/spectral.py
305
306
307
308
309
310
311
312
313
314
315
316
@classmethod
def constant(
    cls,
    value: float,
    wavelength_min_nm: float = _DEFAULT_WL_MIN_NM,
    wavelength_max_nm: float = _DEFAULT_WL_MAX_NM,
) -> QE:
    """A flat QE curve --- equivalent to the scalar ``quantum_efficiency``."""
    if not 0.0 <= value <= 1.0:
        raise ValueError("QE value must be in [0, 1].")
    wl = np.array([wavelength_min_nm, wavelength_max_nm], dtype=np.float64)
    return cls(wl, np.full_like(wl, value))

SpectralBandpass dataclass

A filter/optics transmission response T(lambda) in [0, 1].

Carries the spectral shape of a band, used to colour-weight the effective QE. It does not replace a :class:~getframes.scene.photometry.Bandpass's scalar photon zero point (which still sets the magnitude-to-photon conversion); it refines the photon-to-electron step.

Source code in src/getframes/spectral.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
@dataclass(frozen=True)
class SpectralBandpass:
    """A filter/optics transmission response ``T(lambda)`` in ``[0, 1]``.

    Carries the spectral *shape* of a band, used to colour-weight the effective QE.
    It does not replace a :class:`~getframes.scene.photometry.Bandpass`'s scalar
    photon zero point (which still sets the magnitude-to-photon conversion); it
    refines the photon-to-electron step.
    """

    response: Spectrum

    @classmethod
    def from_arrays(cls, wavelength_nm: ArrayLike, throughput: ArrayLike) -> SpectralBandpass:
        """A response curve sampled at ``wavelength_nm`` with throughput in ``[0, 1]``."""
        spec = Spectrum(_to_nm(wavelength_nm), _strip_units(throughput))
        if np.any(spec.value > 1.0):
            raise ValueError("bandpass throughput must be in [0, 1].")
        return cls(spec)

    @classmethod
    def from_file(cls, path: str, **kwargs: Any) -> SpectralBandpass:
        """Load a two-column ``(wavelength, throughput)`` response from a text file.

        Thin wrapper over :meth:`Spectrum.from_file` (same ``wavelength_to_nm``,
        ``delimiter``, ``skiprows``, ``usecols`` options); throughput must be in
        ``[0, 1]``.
        """
        spec = Spectrum.from_file(path, **kwargs)
        if np.any(spec.value > 1.0):
            raise ValueError("bandpass throughput must be in [0, 1].")
        return cls(spec)

    @classmethod
    def from_product(cls, *items: SpectralBandpass | Spectrum) -> SpectralBandpass:
        """Fold several transmission curves into one combined band response.

        Each item is a :class:`SpectralBandpass` or a bare :class:`Spectrum` (e.g. a
        :class:`QE` curve or an atmospheric-transmission curve); their pointwise
        product over the common wavelength support becomes the new response. This is
        how a *real* filter x QE x atmosphere transmission product is assembled.
        """
        specs = [it.response if isinstance(it, SpectralBandpass) else it for it in items]
        combined = product(*specs)
        if np.any(combined.value > 1.0):
            raise ValueError("combined throughput exceeds 1; check the input curves.")
        return cls(combined)

    @classmethod
    def tophat(cls, center_nm: float, width_nm: float, peak: float = 1.0) -> SpectralBandpass:
        """A flat-topped band of full width ``width_nm`` centred on ``center_nm``.

        Soft (one-sample) shoulders keep the curve continuous for integration.
        """
        if width_nm <= 0:
            raise ValueError("width_nm must be positive.")
        if not 0.0 < peak <= 1.0:
            raise ValueError("peak must be in (0, 1].")
        lo = center_nm - 0.5 * width_nm
        hi = center_nm + 0.5 * width_nm
        edge = max(width_nm * 1e-3, 1e-6)
        wl = np.array([lo - edge, lo, hi, hi + edge], dtype=np.float64)
        val = np.array([0.0, peak, peak, 0.0], dtype=np.float64)
        return cls(Spectrum(wl, val))

    # Representative Johnson-Cousins effective wavelengths and widths (nm). These
    # are coarse tophat stand-ins for the real filter curves --- enough to give a
    # sensible colour term; supply measured curves via ``from_arrays`` for rigour.
    _JOHNSON_NM: ClassVar[dict[str, tuple[float, float]]] = {
        "U": (365.0, 66.0),
        "B": (445.0, 94.0),
        "V": (551.0, 88.0),
        "R": (658.0, 138.0),
        "I": (806.0, 149.0),
        # 2MASS near-infrared bands, same centres and widths the AB survey
        # table uses. The band shape is a property of the filter, not of the
        # magnitude system, so the two must not disagree about it.
        "J": (1235.0, 162.0),
        "H": (1662.0, 251.0),
        "KS": (2159.0, 262.0),
    }

    @classmethod
    def johnson(cls, band: str) -> SpectralBandpass:
        """A tophat approximation of a Vega-system band.

        One of Johnson-Cousins ``U B V R I`` or 2MASS ``J H Ks``.
        """
        key = band.strip().upper()
        if key not in cls._JOHNSON_NM:
            valid = ", ".join(cls._JOHNSON_NM)
            raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
        center, width = cls._JOHNSON_NM[key]
        return cls.tophat(center, width)

    @property
    def pivot_wavelength_nm(self) -> float:
        """The pivot wavelength: ``sqrt(int T dl / int T l^-2 dl)`` (nm)."""
        wl = self.response.wavelength_nm
        t = self.response.value
        num = float(_trapezoid(t, wl))
        den = float(_trapezoid(t / wl**2, wl))
        return math.sqrt(num / den)

    @property
    def mean_wavelength_nm(self) -> float:
        """The throughput-weighted mean wavelength (nm)."""
        wl = self.response.wavelength_nm
        t = self.response.value
        return float(_trapezoid(t * wl, wl)) / float(_trapezoid(t, wl))

pivot_wavelength_nm property

The pivot wavelength: sqrt(int T dl / int T l^-2 dl) (nm).

mean_wavelength_nm property

The throughput-weighted mean wavelength (nm).

from_arrays(wavelength_nm, throughput) classmethod

A response curve sampled at wavelength_nm with throughput in [0, 1].

Source code in src/getframes/spectral.py
331
332
333
334
335
336
337
@classmethod
def from_arrays(cls, wavelength_nm: ArrayLike, throughput: ArrayLike) -> SpectralBandpass:
    """A response curve sampled at ``wavelength_nm`` with throughput in ``[0, 1]``."""
    spec = Spectrum(_to_nm(wavelength_nm), _strip_units(throughput))
    if np.any(spec.value > 1.0):
        raise ValueError("bandpass throughput must be in [0, 1].")
    return cls(spec)

from_file(path, **kwargs) classmethod

Load a two-column (wavelength, throughput) response from a text file.

Thin wrapper over :meth:Spectrum.from_file (same wavelength_to_nm, delimiter, skiprows, usecols options); throughput must be in [0, 1].

Source code in src/getframes/spectral.py
339
340
341
342
343
344
345
346
347
348
349
350
@classmethod
def from_file(cls, path: str, **kwargs: Any) -> SpectralBandpass:
    """Load a two-column ``(wavelength, throughput)`` response from a text file.

    Thin wrapper over :meth:`Spectrum.from_file` (same ``wavelength_to_nm``,
    ``delimiter``, ``skiprows``, ``usecols`` options); throughput must be in
    ``[0, 1]``.
    """
    spec = Spectrum.from_file(path, **kwargs)
    if np.any(spec.value > 1.0):
        raise ValueError("bandpass throughput must be in [0, 1].")
    return cls(spec)

from_product(*items) classmethod

Fold several transmission curves into one combined band response.

Each item is a :class:SpectralBandpass or a bare :class:Spectrum (e.g. a :class:QE curve or an atmospheric-transmission curve); their pointwise product over the common wavelength support becomes the new response. This is how a real filter x QE x atmosphere transmission product is assembled.

Source code in src/getframes/spectral.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
@classmethod
def from_product(cls, *items: SpectralBandpass | Spectrum) -> SpectralBandpass:
    """Fold several transmission curves into one combined band response.

    Each item is a :class:`SpectralBandpass` or a bare :class:`Spectrum` (e.g. a
    :class:`QE` curve or an atmospheric-transmission curve); their pointwise
    product over the common wavelength support becomes the new response. This is
    how a *real* filter x QE x atmosphere transmission product is assembled.
    """
    specs = [it.response if isinstance(it, SpectralBandpass) else it for it in items]
    combined = product(*specs)
    if np.any(combined.value > 1.0):
        raise ValueError("combined throughput exceeds 1; check the input curves.")
    return cls(combined)

tophat(center_nm, width_nm, peak=1.0) classmethod

A flat-topped band of full width width_nm centred on center_nm.

Soft (one-sample) shoulders keep the curve continuous for integration.

Source code in src/getframes/spectral.py
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
@classmethod
def tophat(cls, center_nm: float, width_nm: float, peak: float = 1.0) -> SpectralBandpass:
    """A flat-topped band of full width ``width_nm`` centred on ``center_nm``.

    Soft (one-sample) shoulders keep the curve continuous for integration.
    """
    if width_nm <= 0:
        raise ValueError("width_nm must be positive.")
    if not 0.0 < peak <= 1.0:
        raise ValueError("peak must be in (0, 1].")
    lo = center_nm - 0.5 * width_nm
    hi = center_nm + 0.5 * width_nm
    edge = max(width_nm * 1e-3, 1e-6)
    wl = np.array([lo - edge, lo, hi, hi + edge], dtype=np.float64)
    val = np.array([0.0, peak, peak, 0.0], dtype=np.float64)
    return cls(Spectrum(wl, val))

johnson(band) classmethod

A tophat approximation of a Vega-system band.

One of Johnson-Cousins U B V R I or 2MASS J H Ks.

Source code in src/getframes/spectral.py
401
402
403
404
405
406
407
408
409
410
411
412
@classmethod
def johnson(cls, band: str) -> SpectralBandpass:
    """A tophat approximation of a Vega-system band.

    One of Johnson-Cousins ``U B V R I`` or 2MASS ``J H Ks``.
    """
    key = band.strip().upper()
    if key not in cls._JOHNSON_NM:
        valid = ", ".join(cls._JOHNSON_NM)
        raise ValueError(f"Unknown Johnson band {band!r}. Expected one of: {valid}.")
    center, width = cls._JOHNSON_NM[key]
    return cls.tophat(center, width)

overlap_integral(*spectra)

Integrate the pointwise product of several spectra over their common range.

Returns 0.0 when the spectra do not overlap (the product is zero there).

Source code in src/getframes/spectral.py
171
172
173
174
175
176
177
178
179
180
181
182
def overlap_integral(*spectra: Spectrum) -> float:
    """Integrate the pointwise product of several spectra over their common range.

    Returns ``0.0`` when the spectra do not overlap (the product is zero there).
    """
    grid = _common_grid(*spectra)
    if grid.size < 2:
        return 0.0
    values = np.ones_like(grid)
    for s in spectra:
        values = values * s(grid)
    return float(_trapezoid(values, grid))

product(*spectra)

Pointwise product of several spectra as a new :class:Spectrum.

The result is sampled on the union of the inputs' knots within their common wavelength support, where the product of piecewise-linear curves is exact --- outside that support at least one factor is zero. The natural way to fold a measured filter transmission, detector QE, and atmospheric transmission into a single response curve. Raises if the inputs do not overlap.

Source code in src/getframes/spectral.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def product(*spectra: Spectrum) -> Spectrum:
    """Pointwise product of several spectra as a new :class:`Spectrum`.

    The result is sampled on the union of the inputs' knots within their common
    wavelength support, where the product of piecewise-linear curves is exact ---
    outside that support at least one factor is zero. The natural way to fold a
    measured filter transmission, detector QE, and atmospheric transmission into a
    single response curve. Raises if the inputs do not overlap.
    """
    grid = _common_grid(*spectra)
    if grid.size < 2:
        raise ValueError("spectra do not overlap; their product is empty.")
    values = np.ones_like(grid)
    for s in spectra:
        values = values * s(grid)
    return Spectrum(grid, values)

effective_qe(qe, bandpass, sed=None)

Photon-weighted effective quantum efficiency a source sees through a band.

Computes int S T QE dl / int S T dl over the wavelength range common to the SED, bandpass, and QE curve. sed defaults to a flat photon spectrum, giving the bandpass-weighted mean QE. The result is a dimensionless number in [0, 1] and is invariant to the absolute scale of both S and T.

Source code in src/getframes/spectral.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
def effective_qe(qe: QE, bandpass: SpectralBandpass, sed: SED | None = None) -> float:
    """Photon-weighted effective quantum efficiency a source sees through a band.

    Computes ``int S T QE dl / int S T dl`` over the wavelength range common to the
    SED, bandpass, and QE curve. ``sed`` defaults to a flat photon spectrum, giving
    the bandpass-weighted mean QE. The result is a dimensionless number in
    ``[0, 1]`` and is invariant to the absolute scale of both ``S`` and ``T``.
    """
    source = sed if sed is not None else SED.flat()
    response = bandpass.response
    denom = overlap_integral(source, response)
    if denom <= 0:
        raise ValueError(
            "SED and bandpass do not overlap the QE curve; cannot compute effective QE."
        )
    numer = overlap_integral(source, response, qe)
    return numer / denom

Analysis helpers

getframes.analysis.apertures

Lightweight photometry helpers used by the examples and for quick analysis.

These are intentionally minimal (pure NumPy, no extra dependencies). For serious photometry on real pipelines, reach for photutils; these exist so the bundled examples stay self-contained and readable.

aperture_sum(image, center, r, *, annulus=None)

Background-subtracted sum within radius r of center = (x, y).

The background level is the median of a surrounding annulus (default: from r + 2 to r + 5 pixels), scaled to the number of aperture pixels. Pass annulus=(inner, outer) to control it, or annulus=(0, 0) to skip background subtraction.

Source code in src/getframes/analysis/apertures.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def aperture_sum(
    image: NDArray[np.floating[Any] | np.integer[Any]],
    center: tuple[float, float],
    r: float,
    *,
    annulus: tuple[float, float] | None = None,
) -> float:
    """Background-subtracted sum within radius ``r`` of ``center = (x, y)``.

    The background level is the median of a surrounding annulus (default: from
    ``r + 2`` to ``r + 5`` pixels), scaled to the number of aperture pixels. Pass
    ``annulus=(inner, outer)`` to control it, or ``annulus=(0, 0)`` to skip
    background subtraction.
    """
    data = np.asarray(image, dtype=np.float64)
    cx, cy = center
    dist2 = _radial_grid(data.shape, cx, cy)
    in_aperture = dist2 <= r**2
    total = float(data[in_aperture].sum())

    inner, outer = annulus if annulus is not None else (r + 2.0, r + 5.0)
    if outer > inner:
        ring = (dist2 > inner**2) & (dist2 <= outer**2)
        if np.any(ring):
            background = float(np.median(data[ring]))
            total -= background * float(in_aperture.sum())
    return total

centroid(image, *, center=None, r=None, background=None, threshold=None)

Intensity-weighted (thresholded) centroid (x, y) of image.

This is a calibrated centre-of-gravity estimator suitable for a real-time controller: subtract a background, subtract a noise-floor threshold, clip the remaining negative weights, optionally restrict to a window, then take first moments. With scalar arguments it reduces to the plain background-subtracted centroid.

Parameters:

Name Type Description Default
center tuple[float, float] | None

If both are given, only pixels within radius r of center are used (useful for isolating one spot). Otherwise the whole image is used.

None
r tuple[float, float] | None

If both are given, only pixels within radius r of center are used (useful for isolating one spot). Otherwise the whole image is used.

None
background float | NDArray[floating[Any]] | None

Level subtracted before weighting, so the pedestal doesn't bias the centroid. A scalar, or a per-pixel array (e.g. a master sky+dark frame of the same shape as image). Defaults to the image median, which works well for a small spot on a flat background.

None
threshold float | NDArray[floating[Any]] | None

Optional noise floor subtracted after the background and before clipping, so pixels that are only noise do not pull the centroid. A scalar, or a per-pixel array (e.g. k times a measured per-pixel noise sigma map). None (default) applies no threshold.

None

Returns:

Type Description
(x, y):

Sub-pixel centroid. Returns the geometric centre if there is no positive signal after background and threshold subtraction.

Source code in src/getframes/analysis/apertures.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def centroid(
    image: NDArray[np.floating[Any] | np.integer[Any]],
    *,
    center: tuple[float, float] | None = None,
    r: float | None = None,
    background: float | NDArray[np.floating[Any]] | None = None,
    threshold: float | NDArray[np.floating[Any]] | None = None,
) -> tuple[float, float]:
    """Intensity-weighted (thresholded) centroid ``(x, y)`` of ``image``.

    This is a calibrated centre-of-gravity estimator suitable for a real-time
    controller: subtract a background, subtract a noise-floor threshold, clip the
    remaining negative weights, optionally restrict to a window, then take first
    moments. With scalar arguments it reduces to the plain background-subtracted
    centroid.

    Parameters
    ----------
    center, r:
        If both are given, only pixels within radius ``r`` of ``center`` are used
        (useful for isolating one spot). Otherwise the whole image is used.
    background:
        Level subtracted before weighting, so the pedestal doesn't bias the
        centroid. A scalar, or a per-pixel array (e.g. a master sky+dark frame of
        the same shape as ``image``). Defaults to the image median, which works
        well for a small spot on a flat background.
    threshold:
        Optional noise floor subtracted after the background and before clipping,
        so pixels that are only noise do not pull the centroid. A scalar, or a
        per-pixel array (e.g. ``k`` times a measured per-pixel noise sigma map).
        ``None`` (default) applies no threshold.

    Returns
    -------
    (x, y):
        Sub-pixel centroid. Returns the geometric centre if there is no positive
        signal after background and threshold subtraction.
    """
    data = np.asarray(image, dtype=np.float64)
    bg: float | NDArray[np.float64] = (
        float(np.median(data)) if background is None else np.asarray(background, dtype=np.float64)
    )
    corrected = data - bg
    if threshold is not None:
        corrected = corrected - np.asarray(threshold, dtype=np.float64)
    weights = np.clip(corrected, 0.0, None)

    if center is not None and r is not None:
        mask = _radial_grid(data.shape, *center) <= r**2
        weights = weights * mask

    total = float(weights.sum())
    yy, xx = np.mgrid[0 : data.shape[0], 0 : data.shape[1]]
    if total <= 0:
        return (data.shape[1] - 1) / 2.0, (data.shape[0] - 1) / 2.0
    cx = float((weights * xx).sum() / total)
    cy = float((weights * yy).sum() / total)
    return cx, cy

matched_filter_centroid(image, template, *, background=None)

Centroid image by cross-correlating it with a reference template.

This is useful for compact, low-SNR spots such as Shack--Hartmann wavefront- sensor images. The returned (x, y) is the template's intensity centroid shifted by the peak of the full, linear cross-correlation. A three-point parabolic fit along each axis refines the integer correlation peak to sub-pixel precision.

Parameters:

Name Type Description Default
image NDArray[floating[Any] | integer[Any]]

Non-empty 2-D arrays. They need not have the same shape, although the template normally contains the expected spot at its reference position.

required
template NDArray[floating[Any] | integer[Any]]

Non-empty 2-D arrays. They need not have the same shape, although the template normally contains the expected spot at its reference position.

required
background float | None

Constant level subtracted from image before correlation. Defaults to the image median. The template mean is always removed, making the match insensitive to a constant pedestal.

None

Returns:

Type Description
(x, y):

Estimated absolute centroid in the image's pixel-coordinate system.

Notes

The method assumes approximately white pixel noise. For strongly non-uniform detector noise, pre-whiten the image and template before calling this helper.

Source code in src/getframes/analysis/apertures.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def matched_filter_centroid(
    image: NDArray[np.floating[Any] | np.integer[Any]],
    template: NDArray[np.floating[Any] | np.integer[Any]],
    *,
    background: float | None = None,
) -> tuple[float, float]:
    """Centroid ``image`` by cross-correlating it with a reference ``template``.

    This is useful for compact, low-SNR spots such as Shack--Hartmann wavefront-
    sensor images.  The returned ``(x, y)`` is the template's intensity centroid
    shifted by the peak of the full, linear cross-correlation.  A three-point
    parabolic fit along each axis refines the integer correlation peak to sub-pixel
    precision.

    Parameters
    ----------
    image, template:
        Non-empty 2-D arrays.  They need not have the same shape, although the
        template normally contains the expected spot at its reference position.
    background:
        Constant level subtracted from ``image`` before correlation.  Defaults to
        the image median.  The template mean is always removed, making the match
        insensitive to a constant pedestal.

    Returns
    -------
    (x, y):
        Estimated absolute centroid in the image's pixel-coordinate system.

    Notes
    -----
    The method assumes approximately white pixel noise.  For strongly non-uniform
    detector noise, pre-whiten the image and template before calling this helper.
    """
    data = np.asarray(image, dtype=np.float64)
    reference = np.asarray(template, dtype=np.float64)
    if data.ndim != 2 or reference.ndim != 2 or data.size == 0 or reference.size == 0:
        raise ValueError("image and template must be non-empty 2-D arrays.")
    if not (np.all(np.isfinite(data)) and np.all(np.isfinite(reference))):
        raise ValueError("image and template must contain only finite values.")
    if float(reference.sum()) <= 0:
        raise ValueError("template must have a positive sum.")

    bg = float(np.median(data)) if background is None else float(background)
    data = data - bg
    zero_mean_reference = reference - float(reference.mean())
    if not np.any(zero_mean_reference):
        raise ValueError("template must not be constant.")

    correlation = correlate(data, zero_mean_reference, mode="full", method="fft")
    peak_y, peak_x = np.unravel_index(int(np.argmax(correlation)), correlation.shape)

    def parabolic_offset(values: NDArray[np.float64], index: int) -> float:
        if index <= 0 or index >= values.size - 1:
            return 0.0
        left, middle, right = values[index - 1 : index + 2]
        denominator = left - 2.0 * middle + right
        if denominator == 0.0:
            return 0.0
        return float(np.clip(0.5 * (left - right) / denominator, -1.0, 1.0))

    sub_x = parabolic_offset(correlation[peak_y, :], int(peak_x))
    sub_y = parabolic_offset(correlation[:, peak_x], int(peak_y))
    shift_x = peak_x + sub_x - (reference.shape[1] - 1)
    shift_y = peak_y + sub_y - (reference.shape[0] - 1)
    template_x, template_y = centroid(reference, background=0.0)
    return float(template_x + shift_x), float(template_y + shift_y)

getframes.analysis.ptc

Photon transfer curve (PTC): characterise a camera from synthetic flats.

The PTC is the standard way to measure a detector's conversion gain. This module generates flat pairs at a range of light levels, builds the variance-vs-mean curve, and fits the gain --- turning the workflow in examples/06_photon_transfer_curve.py into a one-liner.

PTCResult dataclass

The outcome of :func:photon_transfer_curve.

Attributes:

Name Type Description
mean_adu, variance_adu2

The measured photon transfer curve: per-level mean signal and noise variance, both in ADU.

gain_e_per_adu float

Conversion gain fitted from the shot-noise-limited region (slope = 1/gain).

read_noise_e float

Read noise measured from a pair of bias frames.

full_well_adu float | None

Mean signal at which the variance peaks (onset of saturation), or None if the curve never rolls over within the sampled levels.

Source code in src/getframes/analysis/ptc.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass(frozen=True)
class PTCResult:
    """The outcome of :func:`photon_transfer_curve`.

    Attributes
    ----------
    mean_adu, variance_adu2:
        The measured photon transfer curve: per-level mean signal and noise
        variance, both in ADU.
    gain_e_per_adu:
        Conversion gain fitted from the shot-noise-limited region (slope = 1/gain).
    read_noise_e:
        Read noise measured from a pair of bias frames.
    full_well_adu:
        Mean signal at which the variance peaks (onset of saturation), or ``None``
        if the curve never rolls over within the sampled levels.
    """

    mean_adu: NDArray[np.float64]
    variance_adu2: NDArray[np.float64]
    gain_e_per_adu: float
    read_noise_e: float
    full_well_adu: float | None

photon_transfer_curve(camera, levels, exposure=1.0, *, temperature=None, seed=0)

Measure a photon transfer curve for camera over the given flux levels.

Parameters:

Name Type Description Default
camera Camera

The camera to characterise.

required
levels NDArray[float64]

Incident photon rates (photons/s/pixel) to sample, ascending. Span from a few electrons up past saturation to capture the full curve.

required
exposure float

Exposure time for each flat, in seconds.

1.0
temperature float | None

Sensor temperature; defaults to the camera's operating temperature.

None
seed int

Base seed; each flat uses a distinct derived seed for reproducibility.

0
Source code in src/getframes/analysis/ptc.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def photon_transfer_curve(
    camera: Camera,
    levels: NDArray[np.float64],
    exposure: float = 1.0,
    *,
    temperature: float | None = None,
    seed: int = 0,
) -> PTCResult:
    """Measure a photon transfer curve for ``camera`` over the given flux ``levels``.

    Parameters
    ----------
    camera:
        The camera to characterise.
    levels:
        Incident photon rates (photons/s/pixel) to sample, ascending. Span from a
        few electrons up past saturation to capture the full curve.
    exposure:
        Exposure time for each flat, in seconds.
    temperature:
        Sensor temperature; defaults to the camera's operating temperature.
    seed:
        Base seed; each flat uses a distinct derived seed for reproducibility.
    """
    levels = np.asarray(levels, dtype=np.float64)
    means = np.empty(levels.size)
    variances = np.empty(levels.size)
    for i, flux in enumerate(levels):
        # Two independent flats; differencing cancels fixed-pattern noise so the
        # variance reflects shot + read noise only.
        a = np.asarray(camera.flat_frame(flux, exposure, temperature, seed=seed + 2 * i), float)
        b = np.asarray(camera.flat_frame(flux, exposure, temperature, seed=seed + 2 * i + 1), float)
        means[i] = 0.5 * (a.mean() + b.mean())
        variances[i] = 0.5 * (a - b).var()

    gain = _fit_gain(camera, means, variances)

    # Read noise from two bias frames: var(b1 - b2) = 2 * read_noise^2.
    b1 = np.asarray(camera.bias_frame(temperature, seed=seed + 99991), float)
    b2 = np.asarray(camera.bias_frame(temperature, seed=seed + 99992), float)
    read_noise = float(np.sqrt(0.5 * (b1 - b2).var()) * gain)

    full_well = _full_well(means, variances)
    return PTCResult(means, variances, gain, read_noise, full_well)

getframes.analysis.characterize

Detector characterisation from frame stacks --- real or simulated.

:mod:~getframes.analysis.ptc characterises a simulated camera by driving it. This module works the other way round: hand it stacks of frames that already exist --- raw data off a real detector, or output from :class:~getframes.Camera --- and it measures the detector parameters back out. The result carries a :meth:DarkCharacterization.to_config so a real camera can be turned into a :class:~getframes.CameraConfig and then simulated.

The two entry points mirror the two standard bench measurements:

characterize_dark Dark stacks at several exposure times. Returns conversion gain, read noise (including its per-pixel distribution), dark current, bias offset and DSNU. characterize_flat Flat-field stacks at several illumination levels. Returns conversion gain, read noise, full well, PRNU and linearity.

Measuring gain from darks alone works because dark current is a Poisson process, so thermally generated charge is a perfectly good charge source for a photon transfer curve. For a dark frame::

mean_ADU(t) = bias + D*t/g
var_ADU(t)  = RN_ADU**2 + D*t/g**2

so the slope of variance against mean is 1/g and the dark rate D cancels. Fitting per pixel makes it immune to DSNU, and fitting a slope across exposures absorbs the bias pedestal and the read noise into the two intercepts. The assumption this rests on is that the dark charge is Poisson (Fano factor 1); :attr:DarkCharacterization.fano_factor reports the consistency check.

All inputs are in ADU; all returned electron quantities are in electrons.

StackStats dataclass

Per-pixel temporal statistics of one stack of frames, in ADU.

This is the raw material every characterisation is built from: for each pixel, its mean and variance through the stack. Both are full-resolution maps, so detector structure (DSNU, per-pixel read noise, hot pixels) is preserved rather than averaged away.

Attributes:

Name Type Description
mean_adu, variance_adu2

Per-pixel temporal mean (ADU) and unbiased variance (ADU^2), each shaped like one frame.

n_frames int

Number of frames combined.

exposure_s float | None

Exposure time of the stack in seconds, or None if unlabelled.

half_variance_adu2 tuple[NDArray[float64], NDArray[float64]] | None

Per-pixel variance of the even- and odd-indexed frames separately, when split=True was passed to :func:stack_statistics. Used by :attr:temporal_repeatability.

Source code in src/getframes/analysis/characterize.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@dataclass(frozen=True)
class StackStats:
    """Per-pixel temporal statistics of one stack of frames, in ADU.

    This is the raw material every characterisation is built from: for each
    pixel, its mean and variance *through the stack*. Both are full-resolution
    maps, so detector structure (DSNU, per-pixel read noise, hot pixels) is
    preserved rather than averaged away.

    Attributes
    ----------
    mean_adu, variance_adu2:
        Per-pixel temporal mean (ADU) and unbiased variance (ADU^2), each shaped
        like one frame.
    n_frames:
        Number of frames combined.
    exposure_s:
        Exposure time of the stack in seconds, or ``None`` if unlabelled.
    half_variance_adu2:
        Per-pixel variance of the even- and odd-indexed frames separately, when
        ``split=True`` was passed to :func:`stack_statistics`. Used by
        :attr:`temporal_repeatability`.
    """

    mean_adu: NDArray[np.float64]
    variance_adu2: NDArray[np.float64]
    n_frames: int
    exposure_s: float | None = None
    half_variance_adu2: tuple[NDArray[np.float64], NDArray[np.float64]] | None = None

    @property
    def shape(self) -> tuple[int, ...]:
        """Frame shape ``(height, width)``."""
        return self.mean_adu.shape

    @property
    def temporal_repeatability(self) -> float:
        """Split-half correlation of the per-pixel variance map, in ``[-1, 1]``.

        Splits the stack into even- and odd-indexed frames, computes each half's
        per-pixel temporal variance, and correlates the two maps across pixels.

        This separates *fixed* per-pixel noise structure from sampling scatter. A
        detector whose pixels genuinely differ in read noise --- every sCMOS ---
        gives a high correlation, because the same pixels are noisy in both
        halves. A detector with uniform noise gives ~0, because all that differs
        between halves is chi-squared sampling noise. Real back-illuminated sCMOS
        measures 0.89--0.94.

        The correlation is computed with the most extreme 1% of pixels excluded.
        A cosmic ray lands in one half only and inflates that pixel's variance by
        orders of magnitude, so on a long-exposure stack a handful of such pixels
        dominate the covariance and drive a plain Pearson correlation to zero:
        real 60 s Marana darks score 0.006 unclipped against 0.93 clipped. Use
        :meth:`repeatability` for explicit control.

        Requires ``split=True`` in :func:`stack_statistics`.
        """
        return self.repeatability()

    def repeatability(self, *, clip_percentile: float = 99.0) -> float:
        """:attr:`temporal_repeatability` with the outlier cut exposed.

        Parameters
        ----------
        clip_percentile:
            Pixels whose variance in *either* half exceeds this percentile are
            excluded before correlating. ``100`` disables clipping and gives the
            plain Pearson correlation.
        """
        if self.half_variance_adu2 is None:
            raise ValueError(
                "temporal_repeatability needs the split halves; "
                "call stack_statistics(..., split=True)."
            )
        a, b = self.half_variance_adu2
        if clip_percentile >= 100.0:
            return float(np.corrcoef(a.ravel(), b.ravel())[0, 1])
        ceiling = np.percentile(np.maximum(a, b), clip_percentile)
        keep = (a < ceiling) & (b < ceiling)
        if keep.sum() < 3:
            return float("nan")
        return float(np.corrcoef(a[keep], b[keep])[0, 1])

    @property
    def fixed_variance_fraction(self) -> float:
        """Fraction of the variance map's spatial spread that is *fixed* structure.

        The observed spatial variance of a variance map is the real pixel-to-pixel
        structure plus the chi-squared scatter of estimating a variance from a
        finite stack, ``2 * <v>**2 / (n - 1)``. Subtracting the latter leaves the
        fraction that is genuine detector structure, in ``[0, 1]``.
        """
        observed = float(self.variance_adu2.var())
        if observed <= 0:
            return 0.0
        sampling = 2.0 * float(np.mean(self.variance_adu2**2)) / max(self.n_frames - 1, 1)
        return float(np.clip((observed - sampling) / observed, 0.0, 1.0))

shape property

Frame shape (height, width).

temporal_repeatability property

Split-half correlation of the per-pixel variance map, in [-1, 1].

Splits the stack into even- and odd-indexed frames, computes each half's per-pixel temporal variance, and correlates the two maps across pixels.

This separates fixed per-pixel noise structure from sampling scatter. A detector whose pixels genuinely differ in read noise --- every sCMOS --- gives a high correlation, because the same pixels are noisy in both halves. A detector with uniform noise gives ~0, because all that differs between halves is chi-squared sampling noise. Real back-illuminated sCMOS measures 0.89--0.94.

The correlation is computed with the most extreme 1% of pixels excluded. A cosmic ray lands in one half only and inflates that pixel's variance by orders of magnitude, so on a long-exposure stack a handful of such pixels dominate the covariance and drive a plain Pearson correlation to zero: real 60 s Marana darks score 0.006 unclipped against 0.93 clipped. Use :meth:repeatability for explicit control.

Requires split=True in :func:stack_statistics.

fixed_variance_fraction property

Fraction of the variance map's spatial spread that is fixed structure.

The observed spatial variance of a variance map is the real pixel-to-pixel structure plus the chi-squared scatter of estimating a variance from a finite stack, 2 * <v>**2 / (n - 1). Subtracting the latter leaves the fraction that is genuine detector structure, in [0, 1].

repeatability(*, clip_percentile=99.0)

:attr:temporal_repeatability with the outlier cut exposed.

Parameters:

Name Type Description Default
clip_percentile float

Pixels whose variance in either half exceeds this percentile are excluded before correlating. 100 disables clipping and gives the plain Pearson correlation.

99.0
Source code in src/getframes/analysis/characterize.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def repeatability(self, *, clip_percentile: float = 99.0) -> float:
    """:attr:`temporal_repeatability` with the outlier cut exposed.

    Parameters
    ----------
    clip_percentile:
        Pixels whose variance in *either* half exceeds this percentile are
        excluded before correlating. ``100`` disables clipping and gives the
        plain Pearson correlation.
    """
    if self.half_variance_adu2 is None:
        raise ValueError(
            "temporal_repeatability needs the split halves; "
            "call stack_statistics(..., split=True)."
        )
    a, b = self.half_variance_adu2
    if clip_percentile >= 100.0:
        return float(np.corrcoef(a.ravel(), b.ravel())[0, 1])
    ceiling = np.percentile(np.maximum(a, b), clip_percentile)
    keep = (a < ceiling) & (b < ceiling)
    if keep.sum() < 3:
        return float("nan")
    return float(np.corrcoef(a[keep], b[keep])[0, 1])

DarkCharacterization dataclass

What a set of dark stacks says about a detector.

Scalars are the median over pixels; the *_map arrays give the per-pixel values behind them.

Attributes:

Name Type Description
gain_e_per_adu float

Conversion gain from the per-pixel dark photon transfer curve.

read_noise_e float

Median per-pixel read noise, taken from the shortest stack with its dark contribution removed. It therefore includes any exposure-independent common-mode term (frame-to-frame pedestal wander, for instance), which is what a bench measurement would also report. Supply a short enough exposure that read noise dominates it.

dark_current_e_per_s float

Median per-pixel dark current, from the slope of mean against exposure.

bias_offset_adu float

Median pedestal, from the intercept of mean against exposure.

dark_current_nonuniformity float

Robust relative spread (IQR/1.349 over the median) of the per-pixel dark current --- DSNU, comparable to :attr:~getframes.CameraConfig.dark_current_nonuniformity.

read_noise_nonuniformity float

Log-normal width implied by the read-noise inter-quartile range, comparable to :attr:~getframes.CameraConfig.read_noise_nonuniformity.

read_noise_rts_fraction float

Fraction of pixels whose read noise exceeds three times the median --- the random-telegraph-signal tail. Around 0.005 on real sCMOS, against ~1e-4 for a pure log-normal.

hot_pixel_fraction float

Fraction of pixels whose dark current exceeds ten times the median.

fano_factor float

Consistency check on the Poisson assumption the gain fit relies on: var_e / mean_e of the accumulated dark charge, which should be 1. A value far from 1 means the gain is not trustworthy.

exposures_s NDArray[float64]

The exposure times used, ascending.

read_noise_map_e, dark_current_map_e_per_s, bias_map_adu

Per-pixel maps behind the scalars above.

Source code in src/getframes/analysis/characterize.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
@dataclass(frozen=True)
class DarkCharacterization:
    """What a set of dark stacks says about a detector.

    Scalars are the median over pixels; the ``*_map`` arrays give the per-pixel
    values behind them.

    Attributes
    ----------
    gain_e_per_adu:
        Conversion gain from the per-pixel dark photon transfer curve.
    read_noise_e:
        Median per-pixel read noise, taken from the shortest stack with its dark
        contribution removed. It therefore includes any exposure-independent
        common-mode term (frame-to-frame pedestal wander, for instance), which is
        what a bench measurement would also report. Supply a short enough
        exposure that read noise dominates it.
    dark_current_e_per_s:
        Median per-pixel dark current, from the slope of mean against exposure.
    bias_offset_adu:
        Median pedestal, from the intercept of mean against exposure.
    dark_current_nonuniformity:
        Robust relative spread (IQR/1.349 over the median) of the per-pixel dark
        current --- DSNU, comparable to
        :attr:`~getframes.CameraConfig.dark_current_nonuniformity`.
    read_noise_nonuniformity:
        Log-normal width implied by the read-noise inter-quartile range,
        comparable to
        :attr:`~getframes.CameraConfig.read_noise_nonuniformity`.
    read_noise_rts_fraction:
        Fraction of pixels whose read noise exceeds three times the median --- the
        random-telegraph-signal tail. Around 0.005 on real sCMOS, against ~1e-4
        for a pure log-normal.
    hot_pixel_fraction:
        Fraction of pixels whose dark current exceeds ten times the median.
    fano_factor:
        Consistency check on the Poisson assumption the gain fit relies on:
        ``var_e / mean_e`` of the accumulated dark charge, which should be 1.
        A value far from 1 means the gain is not trustworthy.
    exposures_s:
        The exposure times used, ascending.
    read_noise_map_e, dark_current_map_e_per_s, bias_map_adu:
        Per-pixel maps behind the scalars above.
    """

    gain_e_per_adu: float
    read_noise_e: float
    dark_current_e_per_s: float
    bias_offset_adu: float
    dark_current_nonuniformity: float
    read_noise_nonuniformity: float
    read_noise_rts_fraction: float
    hot_pixel_fraction: float
    fano_factor: float
    exposures_s: NDArray[np.float64]
    read_noise_map_e: NDArray[np.float64]
    dark_current_map_e_per_s: NDArray[np.float64]
    bias_map_adu: NDArray[np.float64]

    def to_config(self, name: str, **overrides: Any) -> CameraConfig:
        """Build a :class:`~getframes.CameraConfig` from the measured parameters.

        Everything darks can measure is filled in: resolution, gain, bias, read
        noise (with its non-uniformity and RTS tail), dark current and DSNU.
        Parameters darks *cannot* see --- full well, bit depth, pixel pitch, QE ---
        take documented placeholder defaults that you should override.

        Parameters
        ----------
        name:
            Name for the resulting config.
        **overrides:
            Any :class:`~getframes.CameraConfig` field, applied last. Use this to
            supply ``pixel_size_um``, ``full_well_e``, ``bit_depth``,
            ``quantum_efficiency`` and the sensor type for your detector.

        Notes
        -----
        ``dark_current_ref_temp_c`` defaults to 20 C because the stacks carry no
        temperature. Set it to the temperature the darks were taken at, or the
        config's temperature scaling will be wrong.
        """
        from ..config import CameraConfig

        height, width = self.read_noise_map_e.shape
        fields: dict[str, Any] = {
            "name": name,
            "sensor_type": "SCMOS",
            "resolution": (int(height), int(width)),
            "pixel_size_um": 10.0,
            "quantum_efficiency": 1.0,
            "full_well_e": 50_000.0,
            "bit_depth": 16,
            "gain_e_per_adu": self.gain_e_per_adu,
            "bias_offset_adu": self.bias_offset_adu,
            "read_noise_e": self.read_noise_e,
            "read_noise_nonuniformity": self.read_noise_nonuniformity,
            "read_noise_rts_fraction": self.read_noise_rts_fraction,
            "dark_current_e_per_s": self.dark_current_e_per_s,
            "dark_current_nonuniformity": self.dark_current_nonuniformity,
            "hot_pixel_fraction": self.hot_pixel_fraction,
        }
        fields.update(overrides)
        return CameraConfig(**fields)

to_config(name, **overrides)

Build a :class:~getframes.CameraConfig from the measured parameters.

Everything darks can measure is filled in: resolution, gain, bias, read noise (with its non-uniformity and RTS tail), dark current and DSNU. Parameters darks cannot see --- full well, bit depth, pixel pitch, QE --- take documented placeholder defaults that you should override.

Parameters:

Name Type Description Default
name str

Name for the resulting config.

required
**overrides Any

Any :class:~getframes.CameraConfig field, applied last. Use this to supply pixel_size_um, full_well_e, bit_depth, quantum_efficiency and the sensor type for your detector.

{}
Notes

dark_current_ref_temp_c defaults to 20 C because the stacks carry no temperature. Set it to the temperature the darks were taken at, or the config's temperature scaling will be wrong.

Source code in src/getframes/analysis/characterize.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def to_config(self, name: str, **overrides: Any) -> CameraConfig:
    """Build a :class:`~getframes.CameraConfig` from the measured parameters.

    Everything darks can measure is filled in: resolution, gain, bias, read
    noise (with its non-uniformity and RTS tail), dark current and DSNU.
    Parameters darks *cannot* see --- full well, bit depth, pixel pitch, QE ---
    take documented placeholder defaults that you should override.

    Parameters
    ----------
    name:
        Name for the resulting config.
    **overrides:
        Any :class:`~getframes.CameraConfig` field, applied last. Use this to
        supply ``pixel_size_um``, ``full_well_e``, ``bit_depth``,
        ``quantum_efficiency`` and the sensor type for your detector.

    Notes
    -----
    ``dark_current_ref_temp_c`` defaults to 20 C because the stacks carry no
    temperature. Set it to the temperature the darks were taken at, or the
    config's temperature scaling will be wrong.
    """
    from ..config import CameraConfig

    height, width = self.read_noise_map_e.shape
    fields: dict[str, Any] = {
        "name": name,
        "sensor_type": "SCMOS",
        "resolution": (int(height), int(width)),
        "pixel_size_um": 10.0,
        "quantum_efficiency": 1.0,
        "full_well_e": 50_000.0,
        "bit_depth": 16,
        "gain_e_per_adu": self.gain_e_per_adu,
        "bias_offset_adu": self.bias_offset_adu,
        "read_noise_e": self.read_noise_e,
        "read_noise_nonuniformity": self.read_noise_nonuniformity,
        "read_noise_rts_fraction": self.read_noise_rts_fraction,
        "dark_current_e_per_s": self.dark_current_e_per_s,
        "dark_current_nonuniformity": self.dark_current_nonuniformity,
        "hot_pixel_fraction": self.hot_pixel_fraction,
    }
    fields.update(overrides)
    return CameraConfig(**fields)

FlatCharacterization dataclass

What a set of flat-field stacks says about a detector.

Attributes:

Name Type Description
gain_e_per_adu float

Conversion gain from the shot-noise-limited part of the photon transfer curve (slope of variance against mean is 1/gain).

read_noise_e float

Read noise from the faintest stack with its shot-noise term removed. Only as good as that level is faint; prefer :attr:DarkCharacterization.read_noise_e when you have darks, which is both more direct and gives you the per-pixel distribution.

full_well_adu, full_well_e

Mean level at which the temporal variance peaks, or None if the curve never rolls over within the sampled levels. This marks the onset of saturation and is a lower bound on true full well: the earliest-saturating pixels start clipping, and so pulling the variance down, before the array as a whole reaches its ceiling. Expect it to read low by roughly the PRNU, and more if the levels are sparsely sampled near the knee.

prnu float

Photo-response non-uniformity: the robust relative pixel-to-pixel spread of response, measured from the highest unsaturated level with the shot noise subtracted off.

nonlinearity float | None

Fractional departure of the mean-versus-exposure (or versus level) response from a straight line, as a fraction of full scale. None unless the stacks carry exposure times.

mean_adu, variance_adu2

The photon transfer curve itself: per-level mean signal above bias and temporal variance, both in ADU.

levels NDArray[float64]

The level labels supplied, ascending.

Source code in src/getframes/analysis/characterize.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
@dataclass(frozen=True)
class FlatCharacterization:
    """What a set of flat-field stacks says about a detector.

    Attributes
    ----------
    gain_e_per_adu:
        Conversion gain from the shot-noise-limited part of the photon transfer
        curve (slope of variance against mean is ``1/gain``).
    read_noise_e:
        Read noise from the faintest stack with its shot-noise term removed. Only
        as good as that level is faint; prefer
        :attr:`DarkCharacterization.read_noise_e` when you have darks, which is
        both more direct and gives you the per-pixel distribution.
    full_well_adu, full_well_e:
        Mean level at which the temporal variance peaks, or ``None`` if the curve
        never rolls over within the sampled levels. This marks the *onset* of
        saturation and is a lower bound on true full well: the earliest-saturating
        pixels start clipping, and so pulling the variance down, before the array
        as a whole reaches its ceiling. Expect it to read low by roughly the PRNU,
        and more if the levels are sparsely sampled near the knee.
    prnu:
        Photo-response non-uniformity: the robust relative pixel-to-pixel spread
        of response, measured from the highest unsaturated level with the shot
        noise subtracted off.
    nonlinearity:
        Fractional departure of the mean-versus-exposure (or versus level)
        response from a straight line, as a fraction of full scale. ``None``
        unless the stacks carry exposure times.
    mean_adu, variance_adu2:
        The photon transfer curve itself: per-level mean signal above bias and
        temporal variance, both in ADU.
    levels:
        The level labels supplied, ascending.
    """

    gain_e_per_adu: float
    read_noise_e: float
    full_well_adu: float | None
    full_well_e: float | None
    prnu: float
    nonlinearity: float | None
    mean_adu: NDArray[np.float64]
    variance_adu2: NDArray[np.float64]
    levels: NDArray[np.float64]

stack_statistics(frames, *, exposure_s=None, split=False)

Per-pixel temporal mean and variance of a stack of frames.

Frames are consumed one at a time through a Welford accumulator, so an iterator or generator over a stack far larger than memory works fine --- only a handful of frame-sized float64 arrays are ever held.

Parameters:

Name Type Description Default
frames Iterable[FrameLike]

Any iterable of 2-D frames: NumPy arrays, :class:~getframes.Frame objects, or a 3-D array (which iterates over its leading axis). A :meth:~getframes.Camera.dark_series generator works directly.

required
exposure_s float | None

Exposure time to label the stack with. Required by :func:characterize_dark when stacks are passed as a sequence.

None
split bool

Also accumulate the even- and odd-indexed frames separately, enabling :attr:StackStats.temporal_repeatability. Costs two more frame-sized accumulators.

False

Returns:

Type Description
StackStats

Per-pixel mean and variance in ADU.

Raises:

Type Description
ValueError

If fewer than two frames are supplied (variance is undefined), or if split=True and either half has fewer than two frames.

Source code in src/getframes/analysis/characterize.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def stack_statistics(
    frames: Iterable[FrameLike],
    *,
    exposure_s: float | None = None,
    split: bool = False,
) -> StackStats:
    """Per-pixel temporal mean and variance of a stack of frames.

    Frames are consumed one at a time through a Welford accumulator, so an
    iterator or generator over a stack far larger than memory works fine --- only
    a handful of frame-sized float64 arrays are ever held.

    Parameters
    ----------
    frames:
        Any iterable of 2-D frames: NumPy arrays, :class:`~getframes.Frame`
        objects, or a 3-D array (which iterates over its leading axis). A
        :meth:`~getframes.Camera.dark_series` generator works directly.
    exposure_s:
        Exposure time to label the stack with. Required by
        :func:`characterize_dark` when stacks are passed as a sequence.
    split:
        Also accumulate the even- and odd-indexed frames separately, enabling
        :attr:`StackStats.temporal_repeatability`. Costs two more frame-sized
        accumulators.

    Returns
    -------
    StackStats
        Per-pixel mean and variance in ADU.

    Raises
    ------
    ValueError
        If fewer than two frames are supplied (variance is undefined), or if
        ``split=True`` and either half has fewer than two frames.
    """
    n = 0
    mean: NDArray[np.float64] | None = None
    m2: NDArray[np.float64] | None = None
    halves: list[list[Any]] = [[0, None, None], [0, None, None]]

    for index, raw in enumerate(frames):
        frame = np.asarray(raw, dtype=np.float64)
        if mean is None or m2 is None:
            mean = np.zeros(frame.shape, dtype=np.float64)
            m2 = np.zeros(frame.shape, dtype=np.float64)
        elif frame.shape != mean.shape:
            raise ValueError(f"frame {index} has shape {frame.shape}, expected {mean.shape}.")
        n += 1
        delta = frame - mean
        mean += delta / n
        m2 += delta * (frame - mean)

        if split:
            half = halves[index % 2]
            if half[1] is None:
                half[1] = np.zeros(frame.shape, dtype=np.float64)
                half[2] = np.zeros(frame.shape, dtype=np.float64)
            half[0] += 1
            hdelta = frame - half[1]
            half[1] += hdelta / half[0]
            half[2] += hdelta * (frame - half[1])

    if mean is None or m2 is None or n < 2:
        raise ValueError(f"need at least 2 frames to measure a variance, got {n}.")

    half_var: tuple[NDArray[np.float64], NDArray[np.float64]] | None = None
    if split:
        if min(halves[0][0], halves[1][0]) < 2:
            raise ValueError("split=True needs at least 4 frames (2 per half).")
        half_var = (
            halves[0][2] / (halves[0][0] - 1),
            halves[1][2] / (halves[1][0] - 1),
        )
    return StackStats(mean, m2 / (n - 1), n, exposure_s, half_var)

characterize_dark(stacks)

Measure a detector from dark stacks at several exposure times.

Parameters:

Name Type Description Default
stacks Mapping[float, StackStats] | Sequence[StackStats]

Either a mapping of exposure_s -> StackStats, or a sequence of :class:StackStats that each carry their own exposure_s. At least two distinct exposures are needed; three or more is much better, and the longest should accumulate enough dark charge to be measurable above the read noise.

required

Returns:

Type Description
DarkCharacterization

Raises:

Type Description
ValueError

If fewer than two distinct exposures are supplied, if the stacks disagree on frame shape, or if any stack lacks an exposure time.

Notes

The gain comes from a per-pixel regression of temporal variance against temporal mean, whose slope is 1/gain regardless of that pixel's own dark current and read noise. Taking the median over pixels makes it robust to hot pixels and to the read-noise tail. See the module docstring for why darks suffice, and check :attr:DarkCharacterization.fano_factor before trusting the result.

Source code in src/getframes/analysis/characterize.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def characterize_dark(
    stacks: Mapping[float, StackStats] | Sequence[StackStats],
) -> DarkCharacterization:
    """Measure a detector from dark stacks at several exposure times.

    Parameters
    ----------
    stacks:
        Either a mapping of ``exposure_s -> StackStats``, or a sequence of
        :class:`StackStats` that each carry their own ``exposure_s``. At least
        two distinct exposures are needed; three or more is much better, and the
        longest should accumulate enough dark charge to be measurable above the
        read noise.

    Returns
    -------
    DarkCharacterization

    Raises
    ------
    ValueError
        If fewer than two distinct exposures are supplied, if the stacks disagree
        on frame shape, or if any stack lacks an exposure time.

    Notes
    -----
    The gain comes from a *per-pixel* regression of temporal variance against
    temporal mean, whose slope is ``1/gain`` regardless of that pixel's own dark
    current and read noise. Taking the median over pixels makes it robust to
    hot pixels and to the read-noise tail. See the module docstring for why
    darks suffice, and check :attr:`DarkCharacterization.fano_factor` before
    trusting the result.
    """
    ordered = _ordered_stacks(stacks)
    exposures = np.array([s.exposure_s for s in ordered], dtype=np.float64)
    means = np.stack([s.mean_adu for s in ordered])
    variances = np.stack([s.variance_adu2 for s in ordered])

    # Gain: per-pixel slope of variance against mean is exactly 1/gain.
    inverse_gain = _slope(means, variances)
    with np.errstate(divide="ignore", invalid="ignore"):
        gain_map = 1.0 / inverse_gain
    gain = float(np.nanmedian(gain_map))

    # Dark current and bias from the per-pixel mean against exposure.
    time_axis = exposures[:, None, None]
    dark_slope, bias_map = _slope_intercept(time_axis, means)
    dark_map = dark_slope * gain

    # Read noise from the *shortest* stack with its (small) dark term removed,
    # rather than from the variance regression extrapolated to zero exposure.
    # Both are unbiased in the median, but the extrapolation carries the fit
    # error of every pixel into the read-noise map and visibly inflates its
    # width: on a known camera it returned a log-normal width of 0.34 against a
    # true 0.25, where this form returns 0.26.
    #     var_ADU(t) = RN_ADU**2 + D*t/g**2   ->   RN_e = sqrt(g**2*var(t0) - D*t0)
    shortest = float(exposures[0])
    read_noise_map = np.sqrt(np.clip(gain**2 * variances[0] - dark_map * shortest, 0.0, None))

    dark_median = float(np.nanmedian(dark_map))
    read_median = float(np.nanmedian(read_noise_map))

    # Poisson consistency: the charge accumulated between the shortest and
    # longest exposure should have var_e == mean_e.
    delta_mean = float(np.nanmedian(means[-1] - means[0])) * gain
    delta_var = float(np.nanmedian(variances[-1] - variances[0])) * gain**2
    fano = float(delta_var / delta_mean) if delta_mean > 0 else float("nan")

    return DarkCharacterization(
        gain_e_per_adu=gain,
        read_noise_e=read_median,
        dark_current_e_per_s=dark_median,
        bias_offset_adu=float(np.nanmedian(bias_map)),
        dark_current_nonuniformity=_robust_relative_spread(dark_map),
        read_noise_nonuniformity=_lognormal_width(read_noise_map),
        read_noise_rts_fraction=float(np.nanmean(read_noise_map > 3.0 * read_median)),
        hot_pixel_fraction=float(np.nanmean(dark_map > 10.0 * dark_median)),
        fano_factor=fano,
        exposures_s=exposures,
        read_noise_map_e=read_noise_map,
        dark_current_map_e_per_s=dark_map,
        bias_map_adu=bias_map,
    )

characterize_flat(stacks, *, bias_adu=0.0, saturation_fraction=0.9)

Measure a detector from flat-field stacks at several illumination levels.

This is the classical photon transfer curve, computed from stacks you already have rather than by driving a simulated camera (for that, see :func:~getframes.analysis.photon_transfer_curve).

Parameters:

Name Type Description Default
stacks Mapping[float, StackStats] | Sequence[StackStats]

A mapping of level -> StackStats or a sequence of :class:StackStats carrying exposure_s. The "level" is just an ordering label --- an exposure time or a lamp setting. Sample from near zero up past saturation to capture the rollover.

required
bias_adu float

Bias pedestal to subtract from the mean levels before fitting. Take it from :attr:DarkCharacterization.bias_offset_adu, or from a bias stack.

0.0
saturation_fraction float

Fraction of the peak-variance level above which points are excluded from the gain fit, keeping it in the shot-noise-limited region.

0.9

Returns:

Type Description
FlatCharacterization
Notes

Because these are stacks, the variance used is the per-pixel temporal variance averaged over the array, which is already free of fixed-pattern (PRNU) noise --- no frame differencing is needed. PRNU is then measured separately from the spatial spread of the time-averaged flat.

Source code in src/getframes/analysis/characterize.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
def characterize_flat(
    stacks: Mapping[float, StackStats] | Sequence[StackStats],
    *,
    bias_adu: float = 0.0,
    saturation_fraction: float = 0.9,
) -> FlatCharacterization:
    """Measure a detector from flat-field stacks at several illumination levels.

    This is the classical photon transfer curve, computed from stacks you already
    have rather than by driving a simulated camera (for that, see
    :func:`~getframes.analysis.photon_transfer_curve`).

    Parameters
    ----------
    stacks:
        A mapping of ``level -> StackStats`` or a sequence of :class:`StackStats`
        carrying ``exposure_s``. The "level" is just an ordering label --- an
        exposure time or a lamp setting. Sample from near zero up past
        saturation to capture the rollover.
    bias_adu:
        Bias pedestal to subtract from the mean levels before fitting. Take it
        from :attr:`DarkCharacterization.bias_offset_adu`, or from a bias stack.
    saturation_fraction:
        Fraction of the peak-variance level above which points are excluded from
        the gain fit, keeping it in the shot-noise-limited region.

    Returns
    -------
    FlatCharacterization

    Notes
    -----
    Because these are *stacks*, the variance used is the per-pixel temporal
    variance averaged over the array, which is already free of fixed-pattern
    (PRNU) noise --- no frame differencing is needed. PRNU is then measured
    separately from the spatial spread of the time-averaged flat.
    """
    ordered = _ordered_stacks(stacks, require_exposure=False)
    levels = np.array(
        [s.exposure_s if s.exposure_s is not None else i for i, s in enumerate(ordered)],
        dtype=np.float64,
    )
    mean_adu = np.array([float(np.mean(s.mean_adu)) - bias_adu for s in ordered])
    variance_adu2 = np.array([float(np.median(s.variance_adu2)) for s in ordered])

    peak = int(np.argmax(variance_adu2))
    rolls_over = peak < variance_adu2.size - 1
    full_well_adu = float(mean_adu[peak]) if rolls_over else None

    ceiling = saturation_fraction * (mean_adu[peak] if rolls_over else mean_adu.max())
    usable = (mean_adu > 0.0) & (mean_adu <= ceiling)
    if usable.sum() < 2:
        usable = mean_adu > -np.inf
    slope, intercept = np.polyfit(mean_adu[usable], variance_adu2[usable], 1)
    gain = float(1.0 / slope)
    read_noise = _flat_read_noise(ordered[0], mean_adu[0], gain, intercept)

    # PRNU from the brightest unsaturated stack: total spatial variance minus the
    # shot-noise contribution, relative to the mean level.
    brightest = ordered[int(np.argmax(np.where(usable, mean_adu, -np.inf)))]
    prnu = _prnu(brightest, bias_adu, gain)

    return FlatCharacterization(
        gain_e_per_adu=gain,
        read_noise_e=read_noise,
        full_well_adu=full_well_adu,
        full_well_e=None if full_well_adu is None else full_well_adu * gain,
        prnu=prnu,
        nonlinearity=_nonlinearity(levels, mean_adu, usable),
        mean_adu=mean_adu,
        variance_adu2=variance_adu2,
        levels=levels,
    )

Datasets & scale

getframes.dataset

Scalable raw + ground-truth dataset generation (roadmap phase 1.6).

The library's reason to exist is paired data: a realistic raw frame and the noise-free signal it was drawn from. :func:pairs turns a camera and a stream of :class:~getframes.scene.scene.Scene objects into a reproducible sequence of {"raw": ADU, "truth": electrons} pairs — training data for denoising, deconvolution, or calibration networks — and streams it to disk in float32 without ever holding the whole set in memory.

:func:random_star_fields is a convenience generator of random star-field scenes to feed it, but any iterable of scenes (matching the camera's resolution) works.

import getframes as gf cam = gf.Camera.from_preset("andor_ikon_m934", precision="float32") scenes = gf.dataset.random_star_fields(n=4, shape=cam.resolution, seed=0) ds = gf.dataset.pairs(camera=cam, scenes=scenes, exposure=10.0, seed=1) pair = next(iter(ds)) sorted(pair) ['raw', 'truth']

RandomStarFields

A reproducible, re-iterable stream of random star-field :class:Scene objects.

Each scene is a field of uniformly placed point sources with magnitudes drawn uniformly from mag_range and an optional uniform sky. The number of stars per field is fixed (int) or drawn per field from a (low, high) range. The stream is deterministic for a given seed (each field gets its own derived seed) and can be iterated more than once.

Construct via :func:random_star_fields.

Parameters:

Name Type Description Default
n int

Number of scenes in the stream.

required
shape tuple[int, int]

Scene size (height, width); must match the camera it is observed with.

required
optics Telescope | None

The :class:~getframes.scene.optics.Telescope and :class:~getframes.scene.psf.PSF shared by every field. Sensible generic defaults are used when omitted.

None
psf Telescope | None

The :class:~getframes.scene.optics.Telescope and :class:~getframes.scene.psf.PSF shared by every field. Sensible generic defaults are used when omitted.

None
n_stars int | tuple[int, int]

Stars per field — a fixed count, or a (low, high) range sampled per field.

(20, 200)
mag_range tuple[float, float]

(bright, faint) magnitude bounds for the uniform brightness draw.

(16.0, 22.0)
sky_mag_arcsec2 float | None

Optional uniform sky surface brightness (mag/arcsec^2); None for no sky.

21.0
seed int | None

Base seed; field i uses a distinct derived seed so the stream repeats.

None
Source code in src/getframes/dataset.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
class RandomStarFields:
    """A reproducible, re-iterable stream of random star-field :class:`Scene` objects.

    Each scene is a field of uniformly placed point sources with magnitudes drawn
    uniformly from ``mag_range`` and an optional uniform sky. The number of stars per
    field is fixed (``int``) or drawn per field from a ``(low, high)`` range. The
    stream is deterministic for a given ``seed`` (each field gets its own derived
    seed) and can be iterated more than once.

    Construct via :func:`random_star_fields`.

    Parameters
    ----------
    n:
        Number of scenes in the stream.
    shape:
        Scene size ``(height, width)``; must match the camera it is observed with.
    optics, psf:
        The :class:`~getframes.scene.optics.Telescope` and
        :class:`~getframes.scene.psf.PSF` shared by every field. Sensible generic
        defaults are used when omitted.
    n_stars:
        Stars per field — a fixed count, or a ``(low, high)`` range sampled per field.
    mag_range:
        ``(bright, faint)`` magnitude bounds for the uniform brightness draw.
    sky_mag_arcsec2:
        Optional uniform sky surface brightness (mag/arcsec^2); ``None`` for no sky.
    seed:
        Base seed; field ``i`` uses a distinct derived seed so the stream repeats.
    """

    def __init__(
        self,
        n: int,
        shape: tuple[int, int],
        *,
        optics: Telescope | None = None,
        psf: PSF | None = None,
        n_stars: int | tuple[int, int] = (20, 200),
        mag_range: tuple[float, float] = (16.0, 22.0),
        sky_mag_arcsec2: float | None = 21.0,
        seed: int | None = None,
    ) -> None:
        if n < 0:
            raise ValueError("n must be non-negative.")
        if len(shape) != 2 or any(s <= 0 for s in shape):
            raise ValueError(f"shape must be two positive ints, got {shape!r}.")
        self.n = int(n)
        self.shape = (int(shape[0]), int(shape[1]))
        self.optics = optics if optics is not None else _default_optics()
        self.psf = psf if psf is not None else GaussianPSF(fwhm_arcsec=2.5)
        self.n_stars = n_stars
        self.mag_range = (float(mag_range[0]), float(mag_range[1]))
        self.sky_mag_arcsec2 = sky_mag_arcsec2
        self.seed = seed

    def __len__(self) -> int:
        return self.n

    def _field_count(self, rng: np.random.Generator) -> int:
        if isinstance(self.n_stars, tuple):
            low, high = self.n_stars
            return int(rng.integers(low, high + 1))
        return int(self.n_stars)

    def _scene(self, rng: np.random.Generator) -> Scene:
        height, width = self.shape
        k = self._field_count(rng)
        xs = rng.uniform(0.0, width - 1, size=k)
        ys = rng.uniform(0.0, height - 1, size=k)
        mags = rng.uniform(self.mag_range[0], self.mag_range[1], size=k)
        sources = [
            PointSource(x=float(x), y=float(y), magnitude=float(m)) for x, y, m in zip(xs, ys, mags)
        ]
        sky = None if self.sky_mag_arcsec2 is None else Sky(self.sky_mag_arcsec2)
        return Scene(shape=self.shape, optics=self.optics, psf=self.psf, sources=sources, sky=sky)

    def __iter__(self) -> Iterator[Scene]:
        seeds: Iterable[np.random.SeedSequence | None]
        if self.seed is None:
            seeds = [None] * self.n
        else:
            seeds = np.random.SeedSequence(self.seed).spawn(self.n)
        for ss in seeds:
            yield self._scene(np.random.default_rng(ss))

PairDataset

A lazy, reproducible sequence of raw + truth pairs (see :func:pairs).

Iterating yields {"raw": ADU, "truth": electrons} dicts, one per input scene, each cast to :attr:dtype. The stream is single-pass when its scenes are a one-shot iterator; pass a re-iterable scene source (e.g. :class:RandomStarFields) to iterate more than once. Materialise to disk with :meth:to_npz or into stacked arrays with :meth:to_arrays.

Source code in src/getframes/dataset.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
class PairDataset:
    """A lazy, reproducible sequence of raw + truth pairs (see :func:`pairs`).

    Iterating yields ``{"raw": ADU, "truth": electrons}`` dicts, one per input
    scene, each cast to :attr:`dtype`. The stream is single-pass when its scenes are
    a one-shot iterator; pass a re-iterable scene source (e.g.
    :class:`RandomStarFields`) to iterate more than once. Materialise to disk with
    :meth:`to_npz` or into stacked arrays with :meth:`to_arrays`.
    """

    def __init__(
        self,
        camera: Camera,
        scenes: Iterable[Scene],
        exposure: float,
        *,
        temperature: float | None = None,
        dtype: DTypeLike = np.float32,
        seed: int | None = None,
    ) -> None:
        self.camera = camera
        self.scenes = scenes
        self.exposure = float(exposure)
        self.temperature = temperature
        self.dtype = np.dtype(dtype)
        self.seed = seed

    def __len__(self) -> int:
        try:
            return len(self.scenes)  # type: ignore[arg-type]
        except TypeError as exc:  # pragma: no cover - depends on the scene source
            raise TypeError("This PairDataset's scene source has no length.") from exc

    def _frame_seed(self, index: int) -> int | None:
        if self.seed is None:
            return None
        ss = np.random.SeedSequence([int(self.seed), _DATASET_STREAM, index])
        return int(ss.generate_state(1)[0])

    def __iter__(self) -> Iterator[Pair]:
        for i, scene in enumerate(self.scenes):
            frame = self.camera.observe(
                scene,
                self.exposure,
                self.temperature,
                seed=self._frame_seed(i),
                include_truth=True,
            )
            assert frame.truth is not None  # include_truth=True
            yield {
                "raw": np.asarray(frame.data, dtype=self.dtype),
                "truth": np.asarray(frame.truth.mean_electrons, dtype=self.dtype),
            }

    def to_npz(self, directory: str, *, prefix: str = "pair", compress: bool = False) -> list[str]:
        """Write each pair to ``{directory}/{prefix}_{i:06d}.npz`` and return the paths.

        Each archive holds ``raw`` (ADU) and ``truth`` (electrons) arrays in
        :attr:`dtype`. Streams pair by pair, so the whole set is never resident in
        memory. ``compress`` uses :func:`numpy.savez_compressed`.
        """
        out = Path(directory)
        out.mkdir(parents=True, exist_ok=True)
        writer = np.savez_compressed if compress else np.savez
        paths: list[str] = []
        for i, pair in enumerate(self):
            path = out / f"{prefix}_{i:06d}.npz"
            writer(path, raw=pair["raw"], truth=pair["truth"])
            paths.append(str(path))
        return paths

    def to_arrays(self) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]:
        """Stack the whole dataset into ``(raw, truth)`` arrays of shape ``(N, H, W)``.

        Convenient for small sets; holds everything in memory, unlike :meth:`to_npz`.
        """
        raws: list[NDArray[np.floating[Any]]] = []
        truths: list[NDArray[np.floating[Any]]] = []
        for pair in self:
            raws.append(pair["raw"])
            truths.append(pair["truth"])
        if not raws:
            raise ValueError("Dataset is empty; nothing to stack.")
        return np.stack(raws, axis=0), np.stack(truths, axis=0)

to_npz(directory, *, prefix='pair', compress=False)

Write each pair to {directory}/{prefix}_{i:06d}.npz and return the paths.

Each archive holds raw (ADU) and truth (electrons) arrays in :attr:dtype. Streams pair by pair, so the whole set is never resident in memory. compress uses :func:numpy.savez_compressed.

Source code in src/getframes/dataset.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def to_npz(self, directory: str, *, prefix: str = "pair", compress: bool = False) -> list[str]:
    """Write each pair to ``{directory}/{prefix}_{i:06d}.npz`` and return the paths.

    Each archive holds ``raw`` (ADU) and ``truth`` (electrons) arrays in
    :attr:`dtype`. Streams pair by pair, so the whole set is never resident in
    memory. ``compress`` uses :func:`numpy.savez_compressed`.
    """
    out = Path(directory)
    out.mkdir(parents=True, exist_ok=True)
    writer = np.savez_compressed if compress else np.savez
    paths: list[str] = []
    for i, pair in enumerate(self):
        path = out / f"{prefix}_{i:06d}.npz"
        writer(path, raw=pair["raw"], truth=pair["truth"])
        paths.append(str(path))
    return paths

to_arrays()

Stack the whole dataset into (raw, truth) arrays of shape (N, H, W).

Convenient for small sets; holds everything in memory, unlike :meth:to_npz.

Source code in src/getframes/dataset.py
241
242
243
244
245
246
247
248
249
250
251
252
253
def to_arrays(self) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]:
    """Stack the whole dataset into ``(raw, truth)`` arrays of shape ``(N, H, W)``.

    Convenient for small sets; holds everything in memory, unlike :meth:`to_npz`.
    """
    raws: list[NDArray[np.floating[Any]]] = []
    truths: list[NDArray[np.floating[Any]]] = []
    for pair in self:
        raws.append(pair["raw"])
        truths.append(pair["truth"])
    if not raws:
        raise ValueError("Dataset is empty; nothing to stack.")
    return np.stack(raws, axis=0), np.stack(truths, axis=0)

random_star_fields(n, shape, *, optics=None, psf=None, n_stars=(20, 200), mag_range=(16.0, 22.0), sky_mag_arcsec2=21.0, seed=None)

Build a reproducible :class:RandomStarFields stream of n star-field scenes.

A convenience source of scenes for :func:pairs; see :class:RandomStarFields for the parameters.

Source code in src/getframes/dataset.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def random_star_fields(
    n: int,
    shape: tuple[int, int],
    *,
    optics: Telescope | None = None,
    psf: PSF | None = None,
    n_stars: int | tuple[int, int] = (20, 200),
    mag_range: tuple[float, float] = (16.0, 22.0),
    sky_mag_arcsec2: float | None = 21.0,
    seed: int | None = None,
) -> RandomStarFields:
    """Build a reproducible :class:`RandomStarFields` stream of ``n`` star-field scenes.

    A convenience source of scenes for :func:`pairs`; see :class:`RandomStarFields`
    for the parameters.
    """
    return RandomStarFields(
        n,
        shape,
        optics=optics,
        psf=psf,
        n_stars=n_stars,
        mag_range=mag_range,
        sky_mag_arcsec2=sky_mag_arcsec2,
        seed=seed,
    )

pairs(*, camera, scenes, exposure, temperature=None, dtype=np.float32, seed=None)

Build a :class:PairDataset of raw + truth pairs from a camera and scenes.

Parameters:

Name Type Description Default
camera Camera

The :class:~getframes.camera.Camera that observes each scene. Construct it with precision="float32" to render the signal chain in the fast path too.

required
scenes Iterable[Scene]

Any iterable of :class:~getframes.scene.scene.Scene matching the camera's resolution (e.g. :func:random_star_fields).

required
exposure float

Integration time in seconds for every frame.

required
temperature float | None

Sensor temperature (deg C); defaults to the camera's.

None
dtype DTypeLike

Storage dtype for the raw/truth arrays (float32 by default to halve on-disk size; the ADU are exact integers either way).

float32
seed int | None

Base seed; frame i draws a distinct derived seed, so the whole dataset is reproducible yet the frames are independent.

None
Source code in src/getframes/dataset.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def pairs(
    *,
    camera: Camera,
    scenes: Iterable[Scene],
    exposure: float,
    temperature: float | None = None,
    dtype: DTypeLike = np.float32,
    seed: int | None = None,
) -> PairDataset:
    """Build a :class:`PairDataset` of raw + truth pairs from a camera and scenes.

    Parameters
    ----------
    camera:
        The :class:`~getframes.camera.Camera` that observes each scene. Construct it
        with ``precision="float32"`` to render the signal chain in the fast path too.
    scenes:
        Any iterable of :class:`~getframes.scene.scene.Scene` matching the camera's
        resolution (e.g. :func:`random_star_fields`).
    exposure:
        Integration time in seconds for every frame.
    temperature:
        Sensor temperature (deg C); defaults to the camera's.
    dtype:
        Storage dtype for the ``raw``/``truth`` arrays (``float32`` by default to
        halve on-disk size; the ADU are exact integers either way).
    seed:
        Base seed; frame ``i`` draws a distinct derived seed, so the whole dataset is
        reproducible yet the frames are independent.
    """
    return PairDataset(camera, scenes, exposure, temperature=temperature, dtype=dtype, seed=seed)

Command line

getframes.cli

The getframes command-line interface (roadmap phase 1.6).

A thin wrapper that turns a TOML configuration file into frames or an ML dataset, so an experiment is a file you can share and run without writing Python. Three subcommands:

  • getframes presets — list the built-in camera presets.
  • getframes generate config.toml -o frame.fits — generate one frame (or a short series) of a given type (dark/bias/flat/light).
  • getframes dataset config.toml -o train/ — stream raw + truth pairs to disk.

See :func:main. Run getframes --help for the full usage.

build_parser()

Construct the getframes argument parser.

Source code in src/getframes/cli.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def build_parser() -> argparse.ArgumentParser:
    """Construct the ``getframes`` argument parser."""
    parser = argparse.ArgumentParser(
        prog="getframes",
        description="Generate physically realistic synthetic camera frames from a config file.",
    )
    parser.add_argument("--version", action="version", version=f"getframes {__version__}")
    sub = parser.add_subparsers(dest="command", required=True)

    p_presets = sub.add_parser("presets", help="List the built-in camera presets.")
    p_presets.set_defaults(func=_cmd_presets)

    p_gen = sub.add_parser("generate", help="Generate a frame (or series) from a config file.")
    p_gen.add_argument("config", help="Path to a TOML config file.")
    p_gen.add_argument(
        "-o", "--output", default=None, help="Output path (.fits/.npy/.npz); omit to print stats."
    )
    p_gen.set_defaults(func=_cmd_generate)

    p_ds = sub.add_parser("dataset", help="Generate a raw+truth dataset from a config file.")
    p_ds.add_argument("config", help="Path to a TOML config file.")
    p_ds.add_argument("-o", "--output", required=True, help="Output directory for the .npz pairs.")
    p_ds.set_defaults(func=_cmd_dataset)
    return parser

main(argv=None)

Entry point for the getframes command. Returns a process exit code.

Source code in src/getframes/cli.py
207
208
209
210
211
212
213
214
215
def main(argv: list[str] | None = None) -> int:
    """Entry point for the ``getframes`` command. Returns a process exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        exit_code: int = args.func(args)
    except (ValueError, KeyError, FileNotFoundError) as exc:
        parser.error(str(exc))
    return exit_code

Noise models

getframes.noise

Physical noise models that turn a :class:CameraConfig into pixel values.

The models here are deliberately small, composable, and well-documented so that the physics is auditable. Each function takes a configuration, exposure, and a seeded backend-native generator, and returns electrons or ADU on that backend.

Signal chain (:func:simulate_frame)
  1. Mean photo signal: (photon_rate + background) * t_exp * QE electrons, modulated per pixel by photo-response non-uniformity (PRNU).
  2. Mean dark signal: D(T) * t_exp electrons (temperature-scaled), modulated by dark-signal non-uniformity (DSNU) and hot pixels, plus detector glow (uniform, or edge-concentrated via detector_glow_edge_scale_px).
  3. Shot noise: the total electrons are Poisson-distributed about that mean.
  4. Clock-induced charge (EMCCD) adds a small Poisson term.
  5. Cosmic rays (single pixels or extended tracks).
  6. Charge-transport artifacts: blooming along saturated columns, CCD charge-transfer inefficiency (CTI), and inter-pixel capacitance (IPC).
  7. Detector nonlinearity (single-parameter or polynomial).
  8. EM register / avalanche multiplication with its stochastic excess noise.
  9. kTC/reset noise and read noise: Gaussian in electrons, at the output amplifier. The per-pixel read-noise RMS is a fixed sensor property (sCMOS), including an optional random-telegraph-signal (RTS) tail population.
  10. Conversion to ADU via (optionally per-amplifier) gain, plus the bias pedestal and any structured-bias pattern; dead pixels/columns read as defects.
  11. Saturation at full well / ADC range and quantisation to integers.

A dark frame is simply the special case photon_rate = 0.

FixedPatternMaps

Bases: NamedTuple

Device-resident, immutable detector structure cached by :class:Camera.

Source code in src/getframes/noise.py
74
75
76
77
78
79
80
81
82
83
84
class FixedPatternMaps(NamedTuple):
    """Device-resident, immutable detector structure cached by :class:`Camera`."""

    dark_multiplier: Any
    prnu_multiplier: Any
    amplifier_gain: Any
    amplifier_offset: Any
    bias_structure: Any
    defect_mask: Any | None
    read_noise_sigma: Any
    avalanche_gain_multiplier: Any

DetectorWorkspace

Reusable private scratch storage for repeated detector simulations.

A workspace is lazy: its arrays are allocated only when a compatible call to :func:simulate_frame or :meth:getframes.Camera.expose needs them. It may be reused sequentially, but not concurrently. Returned frame and truth arrays never alias workspace storage; only an explicit caller-owned out array is returned without a copy.

One workspace binds to the detector shape, working dtype, backend, and CUDA device of its first use. Construct a separate workspace for a different camera geometry or execution device.

Source code in src/getframes/noise.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
class DetectorWorkspace:
    """Reusable private scratch storage for repeated detector simulations.

    A workspace is lazy: its arrays are allocated only when a compatible call to
    :func:`simulate_frame` or :meth:`getframes.Camera.expose` needs them.  It may
    be reused sequentially, but not concurrently.  Returned frame and truth
    arrays never alias workspace storage; only an explicit caller-owned ``out``
    array is returned without a copy.

    One workspace binds to the detector shape, working dtype, backend, and CUDA
    device of its first use.  Construct a separate workspace for a different
    camera geometry or execution device.
    """

    def __init__(self) -> None:
        self._signature: tuple[str, int | None, tuple[int, int], str] | None = None
        self._buffers: dict[tuple[str, tuple[int, ...], str], Any] = {}
        self._lock = threading.Lock()

    @staticmethod
    def _device_index(backend: ArrayBackend) -> int | None:
        if backend.is_cpu:
            return None
        return int(backend.xp.cuda.runtime.getDevice())

    @contextmanager
    def _using(
        self,
        backend: ArrayBackend,
        shape: tuple[int, int],
        float_dtype: DTypeLike,
    ) -> Any:
        signature = (
            backend.device,
            self._device_index(backend),
            shape,
            np.dtype(float_dtype).str,
        )
        if not self._lock.acquire(blocking=False):
            raise RuntimeError("DetectorWorkspace cannot be used concurrently.")
        try:
            if self._signature is None:
                self._signature = signature
            elif self._signature != signature:
                raise ValueError(
                    "DetectorWorkspace is already bound to a different detector "
                    "shape, precision, backend, or CUDA device."
                )
            yield self
        finally:
            self._lock.release()

    def _buffer(
        self,
        name: str,
        backend: ArrayBackend,
        shape: tuple[int, ...],
        dtype: DTypeLike,
    ) -> Any:
        dtype_obj = np.dtype(dtype)
        key = (name, shape, dtype_obj.str)
        buffer = self._buffers.get(key)
        if buffer is None:
            buffer = backend.xp.empty(shape, dtype=dtype_obj)
            self._buffers[key] = buffer
        return buffer

SimulationResult

Bases: NamedTuple

The output of :func:simulate_frame: the digitised frame plus ground truth.

Source code in src/getframes/noise.py
1143
1144
1145
1146
1147
1148
1149
class SimulationResult(NamedTuple):
    """The output of :func:`simulate_frame`: the digitised frame plus ground truth."""

    adu: Any
    mean_photoelectrons: Any
    mean_dark_electrons: Any
    photon_rate: PhotonRate

charge_diffusion_kernel(fwhm_px, *, oversampling)

Return a flux-normalized lateral charge-diffusion kernel.

The detector diffusion profile is represented by a circular Gaussian whose full width at half maximum is fwhm_px native pixels. Each returned tap is the Gaussian probability integrated over one focal-plane sample cell, rather than a point sample, and the finite four-sigma support is renormalized to unit sum. The kernel is intended for an oversampled focal-plane irradiance before native detector pixels collect charge.

Parameters:

Name Type Description Default
fwhm_px float

Lateral diffusion FWHM in native detector pixels. Zero returns an identity 1 x 1 kernel.

required
oversampling int

Focal-plane samples per native detector pixel. A nonzero width must span at least one sample at FWHM so the configured detector property cannot silently collapse to a numerical no-op.

required

Returns:

Type Description
ndarray

Odd, square, symmetric float64 convolution kernel with unit sum.

Source code in src/getframes/noise.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def charge_diffusion_kernel(fwhm_px: float, *, oversampling: int) -> NDArray[np.float64]:
    """Return a flux-normalized lateral charge-diffusion kernel.

    The detector diffusion profile is represented by a circular Gaussian whose
    full width at half maximum is ``fwhm_px`` native pixels. Each returned tap is
    the Gaussian probability integrated over one focal-plane sample cell, rather
    than a point sample, and the finite four-sigma support is renormalized to unit
    sum. The kernel is intended for an oversampled focal-plane irradiance before
    native detector pixels collect charge.

    Parameters
    ----------
    fwhm_px:
        Lateral diffusion FWHM in native detector pixels. Zero returns an identity
        ``1 x 1`` kernel.
    oversampling:
        Focal-plane samples per native detector pixel. A nonzero width must span
        at least one sample at FWHM so the configured detector property cannot
        silently collapse to a numerical no-op.

    Returns
    -------
    numpy.ndarray
        Odd, square, symmetric ``float64`` convolution kernel with unit sum.
    """
    if not isinstance(oversampling, (int, np.integer)) or isinstance(oversampling, bool):
        raise ValueError("oversampling must be a positive integer.")
    samples_per_pixel = int(oversampling)
    if samples_per_pixel < 1:
        raise ValueError("oversampling must be a positive integer.")
    width = float(fwhm_px)
    if not math.isfinite(width) or width < 0:
        raise ValueError("fwhm_px must be finite and non-negative.")
    if width == 0.0:
        return np.ones((1, 1), dtype=np.float64)
    if width * samples_per_pixel < 1.0:
        required = math.ceil(1.0 / width)
        raise ValueError(
            f"charge diffusion FWHM {width:g} px requires at least {required} "
            "samples per native pixel"
        )

    from scipy.special import erf

    sigma_px = width / _GAUSSIAN_FWHM_PER_SIGMA
    radius = max(
        1,
        math.ceil(_CHARGE_DIFFUSION_TRUNCATE_SIGMA * sigma_px * samples_per_pixel + 0.5),
    )
    centers_px = np.arange(-radius, radius + 1, dtype=np.float64) / samples_per_pixel
    half_cell_px = 0.5 / samples_per_pixel
    scale = math.sqrt(2.0) * sigma_px
    weights: NDArray[np.float64] = np.asarray(
        0.5 * (erf((centers_px + half_cell_px) / scale) - erf((centers_px - half_cell_px) / scale)),
        dtype=np.float64,
    )
    kernel: NDArray[np.float64] = np.multiply.outer(weights, weights)
    kernel /= kernel.sum()
    return kernel

apply_charge_diffusion(values, fwhm_px, *, oversampling, backend=None)

Diffuse an oversampled irradiance map before pixel-area integration.

values is a two-dimensional irradiance or photon-rate map, or a batch of such maps, sampled at oversampling cells per native detector pixel. The returned map has the same shape and dtype. A zero width leaves values untouched. Charge that diffuses off the supplied map is lost at its edge.

Use this before summing focal-plane samples into native pixels. It accepts CPU NumPy and optional GPU CuPy arrays; the public kernel itself remains a portable NumPy array for callers that use another convolution implementation.

Parameters:

Name Type Description Default
values Any

Two-dimensional irradiance or photon-rate map, or a leading batch of maps, on the oversampled focal-plane grid.

required
fwhm_px float

Gaussian lateral charge-diffusion FWHM in native detector pixels.

required
oversampling int

Number of focal-plane grid samples per native detector pixel.

required
backend ArrayBackend | None

Array backend containing values. Defaults to NumPy.

None

Returns:

Type Description
array

Diffused array on the same backend, with the input shape and dtype.

Source code in src/getframes/noise.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def apply_charge_diffusion(
    values: Any,
    fwhm_px: float,
    *,
    oversampling: int,
    backend: ArrayBackend | None = None,
) -> Any:
    """Diffuse an oversampled irradiance map before pixel-area integration.

    ``values`` is a two-dimensional irradiance or photon-rate map, or a batch of
    such maps, sampled at ``oversampling`` cells per native detector pixel. The
    returned map has the same shape and dtype. A zero width leaves ``values``
    untouched. Charge that diffuses off the supplied map is lost at its edge.

    Use this before summing focal-plane samples into native pixels. It accepts
    CPU NumPy and optional GPU CuPy arrays; the public kernel itself remains a
    portable NumPy array for callers that use another convolution implementation.

    Parameters
    ----------
    values:
        Two-dimensional irradiance or photon-rate map, or a leading batch of
        maps, on the oversampled focal-plane grid.
    fwhm_px:
        Gaussian lateral charge-diffusion FWHM in native detector pixels.
    oversampling:
        Number of focal-plane grid samples per native detector pixel.
    backend:
        Array backend containing ``values``. Defaults to NumPy.

    Returns
    -------
    array
        Diffused array on the same backend, with the input shape and dtype.
    """
    if values.ndim not in (2, 3):
        raise ValueError("charge diffusion expects a 2-D map or a batch of 2-D maps.")
    if not np.issubdtype(values.dtype, np.floating):
        raise TypeError("charge diffusion expects a floating-point irradiance map.")
    kernel_host = charge_diffusion_kernel(fwhm_px, oversampling=oversampling)
    if fwhm_px == 0:
        return values
    resolved = backend or get_backend()
    kernel = resolved.asarray(kernel_host, dtype=values.dtype)
    if values.ndim == 3:
        kernel = kernel[None, ...]
    convolved = resolved.convolve(values, kernel)
    return convolved.astype(values.dtype, copy=False)

fixed_pattern_maps(config, *, backend=None, float_dtype=DEFAULT_FLOAT_DTYPE)

Build all repeatable per-pixel detector maps once on the selected device.

Source code in src/getframes/noise.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
def fixed_pattern_maps(
    config: CameraConfig,
    *,
    backend: ArrayBackend | None = None,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
) -> FixedPatternMaps:
    """Build all repeatable per-pixel detector maps once on the selected device."""
    resolved = backend or get_backend()
    xp = resolved.xp
    shape = config.resolution
    needs_dark_map = config.dark_current_nonuniformity > 0 or config.hot_pixel_fraction > 0
    dark: Any = xp.ones(shape, dtype=float_dtype) if needs_dark_map else 1.0
    if config.dark_current_nonuniformity > 0:
        sigma = config.dark_current_nonuniformity
        rng = _fixed_pattern_rng(config, _FPN_STREAM_DSNU, resolved)
        dark *= rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=shape)
    if config.hot_pixel_fraction > 0:
        rng = _fixed_pattern_rng(config, _FPN_STREAM_HOT, resolved)
        hot_mask = rng.random(shape) < config.hot_pixel_fraction
        dark[hot_mask] *= config.hot_pixel_factor

    prnu: Any = xp.ones(shape, dtype=float_dtype) if config.prnu > 0 else 1.0
    if config.prnu > 0:
        sigma = config.prnu
        rng = _fixed_pattern_rng(config, _FPN_STREAM_PRNU, resolved)
        prnu *= rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=shape)

    avalanche_gain: Any = 1.0
    if config.avalanche_gain_nonuniformity > 0 and config.em_gain > 1:
        sigma = config.avalanche_gain_nonuniformity * np.log(config.em_gain)
        rng = _fixed_pattern_rng(config, _FPN_STREAM_AVALANCHE_GAIN, resolved)
        avalanche_gain = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=shape)

    gain, offset = _amplifier_maps(config, resolved, float_dtype=float_dtype)
    return FixedPatternMaps(
        dark,
        prnu,
        gain,
        offset,
        _bias_structure_map(config, resolved, float_dtype=float_dtype),
        _defect_mask(config, resolved),
        _read_noise_sigma_map(config, resolved, float_dtype=float_dtype),
        avalanche_gain,
    )

dark_signal_map(config, exposure_s, temperature_c, float_dtype=DEFAULT_FLOAT_DTYPE, *, backend=None, fixed_patterns=None)

Per-pixel mean dark signal in electrons, including fixed-pattern structure.

This is the noise-free expectation per pixel; shot noise is applied separately. The fixed-pattern structure (DSNU and hot pixels) is deterministic for a given sensor (keyed on :attr:~getframes.config.CameraConfig.fixed_pattern_seed), so it repeats across frames and can be calibrated out with a master dark. A uniform detector-glow term (detector_glow_e_per_s) is added on top, also exposure-scaled and dark-removable.

float_dtype selects the working precision (float64 exact default, or float32 for the memory-light fast path).

Source code in src/getframes/noise.py
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
def dark_signal_map(
    config: CameraConfig,
    exposure_s: float,
    temperature_c: float,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
    *,
    backend: ArrayBackend | None = None,
    fixed_patterns: FixedPatternMaps | None = None,
) -> Any:
    """Per-pixel *mean* dark signal in electrons, including fixed-pattern structure.

    This is the noise-free expectation per pixel; shot noise is applied separately.
    The fixed-pattern structure (DSNU and hot pixels) is deterministic for a given
    sensor (keyed on :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so
    it repeats across frames and can be calibrated out with a master dark. A uniform
    detector-glow term (``detector_glow_e_per_s``) is added on top, also
    exposure-scaled and dark-removable.

    ``float_dtype`` selects the working precision (``float64`` exact default, or
    ``float32`` for the memory-light fast path).
    """
    resolved = backend or get_backend()
    xp = resolved.xp
    height, width = config.resolution
    mean_dark = config.dark_current_at(temperature_c) * exposure_s
    signal = xp.full((height, width), mean_dark, dtype=float_dtype)

    # Dark-signal non-uniformity: log-normal so the per-pixel gain stays positive
    # with unit mean. Drawn from the fixed-pattern stream (same every frame).
    if fixed_patterns is not None and mean_dark > 0:
        signal *= fixed_patterns.dark_multiplier
    elif config.dark_current_nonuniformity > 0 and mean_dark > 0:
        sigma = config.dark_current_nonuniformity
        rng = _fixed_pattern_rng(config, _FPN_STREAM_DSNU, resolved)
        dsnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=signal.shape)
        signal *= dsnu

    # Hot pixels: a sparse, *fixed* population with strongly elevated dark current.
    if fixed_patterns is None and config.hot_pixel_fraction > 0 and mean_dark > 0:
        rng = _fixed_pattern_rng(config, _FPN_STREAM_HOT, resolved)
        hot_mask = rng.random(signal.shape) < config.hot_pixel_fraction
        signal[hot_mask] *= config.hot_pixel_factor

    # Detector glow: a self-emission term that scales with exposure (and so is
    # removed by an exposure-matched master dark). Added after DSNU/hot pixels,
    # which describe the dark *current*, not the glow. In place to preserve dtype.
    if config.detector_glow_e_per_s > 0 and exposure_s > 0:
        if config.detector_glow_edge_scale_px > 0:
            signal += _glow_profile(config, resolved, float_dtype) * exposure_s
        else:
            signal += config.detector_glow_e_per_s * exposure_s

    return signal

photo_signal_map(config, photon_rate, exposure_s, background_photon_rate, quantum_efficiency=None, float_dtype=DEFAULT_FLOAT_DTYPE, *, backend=None, fixed_patterns=None, out=None)

Per-pixel mean photo-generated signal in electrons (noise-free).

Converts an incident photon rate (photons/s/pixel, plus an additive background) to photoelectrons via the quantum efficiency, then imprints a fixed multiplicative PRNU pattern. photon_rate may be a scalar (uniform illumination) or a 2-D array matching the sensor resolution.

The PRNU pattern is deterministic for a given sensor (keyed on :attr:~getframes.config.CameraConfig.fixed_pattern_seed), so it repeats across frames and is removable with a master flat.

quantum_efficiency overrides config.quantum_efficiency when given. The spectral path uses this with a pre-multiplied (already-photoelectron) map and quantum_efficiency = 1.0. float_dtype selects the working precision (float64 default, or float32 for the memory-light fast path).

Source code in src/getframes/noise.py
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
def photo_signal_map(
    config: CameraConfig,
    photon_rate: PhotonRate,
    exposure_s: float,
    background_photon_rate: PhotonRate,
    quantum_efficiency: float | None = None,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
    *,
    backend: ArrayBackend | None = None,
    fixed_patterns: FixedPatternMaps | None = None,
    out: Any | None = None,
) -> Any:
    """Per-pixel *mean* photo-generated signal in electrons (noise-free).

    Converts an incident photon rate (photons/s/pixel, plus an additive
    background) to photoelectrons via the quantum efficiency, then imprints a
    fixed multiplicative PRNU pattern. ``photon_rate`` may be a scalar (uniform
    illumination) or a 2-D array matching the sensor resolution.

    The PRNU pattern is deterministic for a given sensor (keyed on
    :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so it repeats across
    frames and is removable with a master flat.

    ``quantum_efficiency`` overrides ``config.quantum_efficiency`` when given. The
    spectral path uses this with a pre-multiplied (already-photoelectron) map and
    ``quantum_efficiency = 1.0``. ``float_dtype`` selects the working precision
    (``float64`` default, or ``float32`` for the memory-light fast path).
    """
    resolved = backend or get_backend()
    xp = resolved.xp
    height, width = config.resolution
    qe = config.quantum_efficiency if quantum_efficiency is None else quantum_efficiency
    rate = resolved.asarray(photon_rate, dtype=float_dtype)
    background = resolved.asarray(background_photon_rate, dtype=float_dtype)
    if rate.ndim not in (0, 2) or background.ndim not in (0, 2):
        raise ValueError("photon_rate/background must be a scalar or a 2-D array.")

    # Write broadcast addition and scaling into one owned output buffer.  Keeping
    # this mutable avoids two full-frame temporaries on both NumPy and CuPy.
    if out is None:
        mean_photo = xp.empty((height, width), dtype=float_dtype)
    else:
        if not isinstance(out, xp.ndarray):
            raise TypeError(f"out must be a {xp.__name__}.ndarray on the detector backend.")
        if tuple(out.shape) != (height, width):
            raise ValueError(
                f"out shape {tuple(out.shape)} does not match detector shape {(height, width)}."
            )
        if out.dtype != np.dtype(float_dtype):
            raise TypeError(f"out dtype must be {np.dtype(float_dtype)}, got {out.dtype}.")
        mean_photo = out
    try:
        xp.add(rate, background, out=mean_photo)
    except ValueError as exc:
        raise ValueError(
            f"photon_rate/background must broadcast to detector shape {(height, width)}."
        ) from exc
    mean_photo *= exposure_s * qe

    # Photo-response non-uniformity: a fixed log-normal multiplier with unit mean,
    # applied only where there is light. Drawn from the fixed-pattern stream so it is
    # the same pattern in every frame (a master flat can remove it).
    if fixed_patterns is not None and config.prnu > 0:
        mean_photo *= fixed_patterns.prnu_multiplier
    elif config.prnu > 0 and bool(resolved.scalar(xp.any(mean_photo > 0))):
        sigma = config.prnu
        rng = _fixed_pattern_rng(config, _FPN_STREAM_PRNU, resolved)
        prnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=mean_photo.shape)
        mean_photo *= prnu

    return mean_photo

apply_gain_stage(electrons, gain, excess_noise_factor, rng, *, backend=None)

Apply a stochastic multiplication stage (EM register or APD avalanche).

A single model covers both EMCCDs and avalanche photodiodes, parameterised by the mean gain G and the excess noise factor F. For n input electrons the multiplied output is drawn from a Gamma distribution:

.. math::

\text{out} \sim \mathrm{Gamma}(\text{shape}=n\alpha,\ \text{scale}=\theta),
\quad \alpha = \frac{1}{F^2 - 1}, \quad \theta = G\,(F^2 - 1).

Then :math:E[\text{out}] = nG and, with Poisson input of mean :math:\mu, the total output variance is :math:G^2 F^2 \mu --- i.e. the model reproduces the requested excess noise factor exactly. Special cases:

  • F = sqrt(2) gives alpha = 1 --- the classic EMCCD Gamma(n, G) model.
  • F -> 1 is noiseless multiplication (deterministic n * G).

Pixels with zero input electrons produce zero output.

Source code in src/getframes/noise.py
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
def apply_gain_stage(
    electrons: NDArray[np.float64],
    gain: float,
    excess_noise_factor: float,
    rng: Any,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    r"""Apply a stochastic multiplication stage (EM register or APD avalanche).

    A single model covers both EMCCDs and avalanche photodiodes, parameterised by
    the mean gain ``G`` and the excess noise factor ``F``. For ``n`` input
    electrons the multiplied output is drawn from a Gamma distribution:

    .. math::

        \text{out} \sim \mathrm{Gamma}(\text{shape}=n\alpha,\ \text{scale}=\theta),
        \quad \alpha = \frac{1}{F^2 - 1}, \quad \theta = G\,(F^2 - 1).

    Then :math:`E[\text{out}] = nG` and, with Poisson input of mean :math:`\mu`, the
    total output variance is :math:`G^2 F^2 \mu` --- i.e. the model reproduces the
    requested excess noise factor exactly. Special cases:

    * ``F = sqrt(2)`` gives ``alpha = 1`` --- the classic EMCCD ``Gamma(n, G)`` model.
    * ``F -> 1`` is noiseless multiplication (deterministic ``n * G``).

    Pixels with zero input electrons produce zero output.
    """
    if gain <= 1.0:
        return electrons
    if excess_noise_factor <= 1.0:
        return electrons * gain  # noiseless multiplication

    f2 = excess_noise_factor**2
    alpha = 1.0 / (f2 - 1.0)
    theta = gain * (f2 - 1.0)
    # Both NumPy and CuPy define Gamma(shape=0) as exactly zero.  Sampling the
    # full shape therefore preserves the model while avoiding a mask, gather,
    # scatter, and (on GPU) a synchronizing ``any`` reduction.
    return rng.gamma(shape=electrons * alpha, scale=theta).astype(electrons.dtype, copy=False)

apply_em_gain(electrons, em_gain, rng, *, backend=None)

Backwards-compatible EMCCD multiplication (F = sqrt(2) gain stage).

Thin wrapper over :func:apply_gain_stage; prefer that for new code.

Source code in src/getframes/noise.py
615
616
617
618
619
620
621
622
623
624
625
626
def apply_em_gain(
    electrons: NDArray[np.float64],
    em_gain: float,
    rng: Any,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Backwards-compatible EMCCD multiplication (``F = sqrt(2)`` gain stage).

    Thin wrapper over :func:`apply_gain_stage`; prefer that for new code.
    """
    return apply_gain_stage(electrons, em_gain, np.sqrt(2.0), rng, backend=backend)

apply_nonlinearity(electrons, config, *, backend=None)

Bend the charge response near full well (detector nonlinearity).

Two models, both deterministic (no randomness):

  • Polynomial (when config.nonlinearity_coeffs is set): with u = q / full_well and coefficients (c1, c2, ...), the response multiplier is 1 + c1 u + c2 u**2 + ..., so an arbitrary measured curve or look-up can be reproduced.
  • Single-parameter (the default): q -> q * (1 - nonlinearity * q / full_well), a smooth, monotonic compression so a pixel near full well reads slightly low.

The polynomial model takes precedence when both are configured.

Source code in src/getframes/noise.py
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
def apply_nonlinearity(
    electrons: NDArray[np.float64],
    config: CameraConfig,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Bend the charge response near full well (detector nonlinearity).

    Two models, both deterministic (no randomness):

    * **Polynomial** (when ``config.nonlinearity_coeffs`` is set): with
      ``u = q / full_well`` and coefficients ``(c1, c2, ...)``, the response
      multiplier is ``1 + c1 u + c2 u**2 + ...``, so an arbitrary measured curve or
      look-up can be reproduced.
    * **Single-parameter** (the default): ``q -> q * (1 - nonlinearity * q /
      full_well)``, a smooth, monotonic compression so a pixel near full well reads
      slightly low.

    The polynomial model takes precedence when both are configured.
    """
    xp = (backend or get_backend()).xp
    if config.nonlinearity_coeffs is not None:
        u = xp.clip(electrons, 0.0, None) / config.full_well_e
        factor = xp.ones_like(u)
        for power, coeff in enumerate(config.nonlinearity_coeffs, start=1):
            factor = factor + coeff * u**power
        bent: NDArray[np.float64] = electrons * xp.clip(factor, 0.0, None)
        return bent
    if config.nonlinearity <= 0:
        return electrons
    factor = 1.0 - config.nonlinearity * xp.clip(electrons, 0.0, None) / config.full_well_e
    return electrons * xp.clip(factor, 0.0, None)

apply_blooming(electrons, full_well_e, *, backend=None)

Bleed charge above full well along columns (CCD blooming).

Charge exceeding full_well_e in a pixel floods symmetrically into the vacant pixels of the same column (axis=0): half the excess sweeps toward higher rows and half toward lower rows, each filling successive pixels up to full well until the charge is absorbed or runs off the array edge. Deterministic and charge-conserving except for charge that bleeds off the top/bottom edge.

Source code in src/getframes/noise.py
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def apply_blooming(
    electrons: NDArray[np.float64],
    full_well_e: float,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Bleed charge above full well along columns (CCD blooming).

    Charge exceeding ``full_well_e`` in a pixel floods symmetrically into the
    vacant pixels of the same column (``axis=0``): half the excess sweeps toward
    higher rows and half toward lower rows, each filling successive pixels up to
    full well until the charge is absorbed or runs off the array edge. Deterministic
    and charge-conserving except for charge that bleeds off the top/bottom edge.
    """
    resolved = backend or get_backend()
    xp = resolved.xp
    out = xp.array(electrons, copy=True)
    excess = xp.clip(out - full_well_e, 0.0, None)
    if not bool(resolved.scalar(xp.any(excess))):
        return out
    n_rows, width = out.shape
    out = xp.minimum(out, full_well_e)
    # Split the overflow and flood it outward, each direction in a single sweep with
    # a per-column carry; a vacant pixel can only ever be filled up to full well, so
    # charge never flows back into an already-saturated pixel (no oscillation).
    down_share = 0.5 * excess
    up_share = excess - down_share
    for source, rows in ((down_share, range(n_rows)), (up_share, range(n_rows - 1, -1, -1))):
        carry = xp.zeros(width, dtype=out.dtype)
        for r in rows:
            incoming = carry + source[r]
            room = full_well_e - out[r]
            fill = xp.minimum(incoming, room)
            out[r] += fill
            carry = incoming - fill
        # Any charge still carried past the edge bleeds off the array.
    return out

apply_cti(electrons, cti, *, backend=None)

Smear charge by charge-transfer inefficiency (CTI) during readout.

A first-order, charge-conserving model: the readout register is row 0, so a pixel r rows away undergoes r transfers and defers a fraction cti * r of its charge into the trailing pixel one row farther from the register (axis=0), producing the characteristic CTI tail. Charge deferred past the final row is lost into overscan. Deterministic.

Source code in src/getframes/noise.py
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
def apply_cti(
    electrons: NDArray[np.float64],
    cti: float,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Smear charge by charge-transfer inefficiency (CTI) during readout.

    A first-order, charge-conserving model: the readout register is row 0, so a
    pixel ``r`` rows away undergoes ``r`` transfers and defers a fraction
    ``cti * r`` of its charge into the trailing pixel one row farther from the
    register (``axis=0``), producing the characteristic CTI tail. Charge deferred
    past the final row is lost into overscan. Deterministic.
    """
    if cti <= 0:
        return electrons
    xp = (backend or get_backend()).xp
    out = xp.array(electrons, copy=True)
    n_rows = out.shape[0]
    transfers = xp.arange(n_rows, dtype=np.float64).reshape(n_rows, 1)
    deferred = xp.minimum(cti * transfers * out, out)
    out -= deferred
    out[1:] += deferred[:-1]
    return out

apply_ipc(electrons, coupling, *, backend=None)

Couple a fraction of each pixel into its four neighbours (inter-pixel capacitance).

Convolves with the charge-conserving 3x3 kernel whose centre is 1 - 4*coupling and whose four edge-adjacent taps are coupling each (corners zero). Models the capacitive crosstalk of CMOS / IR hybrid arrays. Charge coupling past the array boundary is lost. Deterministic.

Source code in src/getframes/noise.py
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
def apply_ipc(
    electrons: NDArray[np.float64],
    coupling: float,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Couple a fraction of each pixel into its four neighbours (inter-pixel capacitance).

    Convolves with the charge-conserving 3x3 kernel whose centre is
    ``1 - 4*coupling`` and whose four edge-adjacent taps are ``coupling`` each
    (corners zero). Models the capacitive crosstalk of CMOS / IR hybrid arrays.
    Charge coupling past the array boundary is lost. Deterministic.
    """
    if coupling <= 0:
        return electrons
    resolved = backend or get_backend()
    kernel = resolved.xp.array(
        [[0.0, coupling, 0.0], [coupling, 1.0 - 4.0 * coupling, coupling], [0.0, coupling, 0.0]],
        dtype=np.float64,
    )
    convolved = resolved.convolve(electrons, kernel)
    # Preserve the input dtype (the float32 fast path) — convolve upcasts to float64.
    result: NDArray[np.float64] = convolved.astype(electrons.dtype, copy=False)
    return result

add_cosmic_rays(electrons, config, exposure_s, rng, *, backend=None)

Deposit cosmic-ray charge bursts into random pixels.

The number of hits is Poisson with mean rate * area * exposure; each hit carries a broad charge burst of order ten thousand electrons. When config.cosmic_ray_track_length_px is zero the charge lands in a single pixel; when positive, each hit draws an exponential track length and a random in-plane direction (a glancing muon) and spreads its charge evenly along the track --- the extended morphology a real rejection pipeline must handle.

Source code in src/getframes/noise.py
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
def add_cosmic_rays(
    electrons: NDArray[np.float64],
    config: CameraConfig,
    exposure_s: float,
    rng: Any,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Deposit cosmic-ray charge bursts into random pixels.

    The number of hits is Poisson with mean ``rate * area * exposure``; each hit
    carries a broad charge burst of order ten thousand electrons. When
    ``config.cosmic_ray_track_length_px`` is zero the charge lands in a single
    pixel; when positive, each hit draws an exponential track length and a random
    in-plane direction (a glancing muon) and spreads its charge evenly along the
    track --- the extended morphology a real rejection pipeline must handle.
    """
    resolved = backend or get_backend()
    xp = resolved.xp
    height, width = electrons.shape
    pixel_cm = config.pixel_size_um * 1e-4
    area_cm2 = height * width * pixel_cm**2
    expected = config.cosmic_ray_rate_per_cm2_s * area_cm2 * exposure_s
    n_hits = int(resolved.scalar(rng.poisson(expected)))
    if n_hits == 0:
        return electrons
    ys = rng.integers(0, height, n_hits)
    xs = rng.integers(0, width, n_hits)
    # Charge per hit: a broad distribution centred on ~10,000 e-.
    charges = rng.gamma(shape=2.0, scale=5000.0, size=n_hits)

    if config.cosmic_ray_track_length_px <= 0:
        xp.add.at(electrons, (ys, xs), charges)
        return electrons

    lengths = rng.exponential(config.cosmic_ray_track_length_px, size=n_hits)
    angles = rng.uniform(0.0, 2.0 * np.pi, size=n_hits)
    for x0, y0, charge, length, angle in zip(xs, ys, charges, lengths, angles):
        n_steps = max(1, round(resolved.scalar(length)))
        steps = xp.arange(n_steps)
        tx = xp.clip(xp.round(x0 + xp.cos(angle) * steps).astype(int), 0, width - 1)
        ty = xp.clip(xp.round(y0 + xp.sin(angle) * steps).astype(int), 0, height - 1)
        xp.add.at(electrons, (ty, tx), charge / n_steps)
    return electrons

digitize(electrons, config, rng, *, backend=None, fixed_patterns=None, reset_noise_e=None, correlated_read_noise_e=None, common_mode_adu=None, avalanche_input_noise_e=None, out=None, _output_slices=None, _out_validated=False)

Add read/reset noise, convert electrons to ADU, then saturate and quantise.

Read noise is referenced to the sensor output amplifier. When read_noise_nonuniformity is set (sCMOS), each pixel gets its own read-noise RMS drawn from a log-normal distribution about read_noise_e. Hybrid arrays can additionally carry fixed interleaved-channel and edge noise scales.

Detector-depth structure is folded in here: dead pixels/columns collect no charge; kTC/reset noise adds a per-pixel Gaussian; amplifier/channel layouts apply fixed gain, offset, and noise differences; and structured/edge bias rides on the flat pedestal. Nondestructive ramps may inject their shared reset draw, their shared correlated read-noise draw, and correlated common-mode pedestal explicitly.

Source code in src/getframes/noise.py
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def digitize(
    electrons: NDArray[np.float64],
    config: CameraConfig,
    rng: Any,
    *,
    backend: ArrayBackend | None = None,
    fixed_patterns: FixedPatternMaps | None = None,
    reset_noise_e: Any | None = None,
    correlated_read_noise_e: Any | None = None,
    common_mode_adu: Any | None = None,
    avalanche_input_noise_e: float | None = None,
    out: Any | None = None,
    _output_slices: tuple[slice, slice] | None = None,
    _out_validated: bool = False,
) -> Any:
    """Add read/reset noise, convert electrons to ADU, then saturate and quantise.

    Read noise is referenced to the sensor output amplifier. When
    ``read_noise_nonuniformity`` is set (sCMOS), each pixel gets its own read-noise
    RMS drawn from a log-normal distribution about ``read_noise_e``. Hybrid arrays
    can additionally carry fixed interleaved-channel and edge noise scales.

    Detector-depth structure is folded in here: dead pixels/columns collect no
    charge; kTC/reset noise adds a per-pixel Gaussian; amplifier/channel layouts
    apply fixed gain, offset, and noise differences; and structured/edge bias rides
    on the flat pedestal. Nondestructive ramps may inject their shared reset draw,
    their shared correlated read-noise draw, and correlated common-mode pedestal
    explicitly.
    """
    resolved = backend or get_backend()
    xp = resolved.xp
    # ``electrons`` is a private realised-frame buffer at this point. Reusing it
    # for readout avoids several detector-sized temporaries in the hot path.
    signal = electrons
    readout_full_well_e = (
        config.output_full_well_e if config.output_full_well_e is not None else config.full_well_e
    )
    xp.clip(signal, 0.0, readout_full_well_e, out=signal)

    # Dead pixels/columns: a fixed defect map that collects no charge (they still
    # carry read/reset noise and the bias pedestal, so they read as dark defects).
    defects = (
        fixed_patterns.defect_mask if fixed_patterns is not None else _defect_mask(config, resolved)
    )
    if defects is not None:
        signal[defects] = 0.0

    def normal_noise(sigma: Any) -> Any:
        if resolved.is_cpu and signal.dtype == np.dtype(np.float32):
            draw = rng.standard_normal(signal.shape, dtype=np.float32)
            draw *= sigma
            return draw
        return rng.normal(0.0, sigma, size=signal.shape)

    # An ordinary exposure draws a fresh kTC uncertainty. A nondestructive ramp
    # passes one cached draw back on every read so reset noise remains common to
    # the ramp and cancels under correlated double sampling.
    if reset_noise_e is None:
        if config.reset_noise_e > 0:
            signal += normal_noise(config.reset_noise_e)
    else:
        signal += reset_noise_e

    # Some eAPD stacks carry an additional per-read component that scales with
    # avalanche multiplication rather than remaining fixed at the output
    # amplifier. Keep it separate so ``read_noise_e`` retains its usual output
    # reference and high-gain data can identify the two terms independently.
    avalanche_noise = (
        config.avalanche_input_noise_e
        if avalanche_input_noise_e is None
        else avalanche_input_noise_e
    )
    if avalanche_noise > 0:
        gain_scale = config.em_gain * (
            config.em_gain / config.avalanche_input_noise_reference_gain
        ) ** (config.avalanche_input_noise_gain_exponent - 1.0)
        signal += normal_noise(avalanche_noise * gain_scale)

    # Read noise in electrons, added at the amplifier. The per-pixel RMS is a
    # fixed property of the sensor (see :func:`_read_noise_sigma_map`), so only the
    # Gaussian draw itself is per-frame.
    #
    # ``read_noise_correlated_fraction`` splits that draw in two. The correlated
    # part is passed in by a nondestructive ramp, which holds one draw for the
    # whole ramp so that differencing two reads removes it; the independent part
    # is redrawn every read and survives the difference. An ordinary exposure has
    # nothing to correlate against and draws the full RMS.
    if config.read_noise_e > 0:
        sigma_map = (
            fixed_patterns.read_noise_sigma
            if fixed_patterns is not None
            else _read_noise_sigma_map(config, resolved, float_dtype=signal.dtype)
        )
        correlated_weight = (
            config.read_noise_correlated_fraction if correlated_read_noise_e is not None else 0.0
        )
        if correlated_weight > 0.0 and correlated_read_noise_e is not None:
            signal += correlated_read_noise_e
            signal += normal_noise(sigma_map * np.sqrt(1.0 - correlated_weight))
        else:
            signal += normal_noise(sigma_map)

    if fixed_patterns is None:
        gain_map, amp_offset = _amplifier_maps(config, resolved, float_dtype=signal.dtype)
        bias_structure = _bias_structure_map(config, resolved, float_dtype=signal.dtype)
    else:
        gain_map = fixed_patterns.amplifier_gain
        amp_offset = fixed_patterns.amplifier_offset
        bias_structure = fixed_patterns.bias_structure
    signal /= gain_map
    signal += config.bias_offset_adu
    signal += amp_offset
    signal += bias_structure
    if common_mode_adu is None:
        if config.readout_common_mode_noise_adu > 0:
            signal += rng.normal(0.0, config.readout_common_mode_noise_adu)
    else:
        signal += common_mode_adu
    xp.rint(signal, out=signal)
    xp.clip(signal, 0, config.max_adu, out=signal)
    if out is None:
        return signal.astype(np.uint32)
    source = signal if _output_slices is None else signal[_output_slices]
    signal_shape = (int(source.shape[0]), int(source.shape[1]))
    if not _out_validated:
        _validate_output_buffer(out, resolved, signal_shape)
    out[...] = source
    return out

frame_electrons(config, mean_electrons, rng, exposure_s=0.0, *, backend=None, fixed_patterns=None)

Apply shot noise, CIC, cosmic rays, nonlinearity, and any gain stage.

Takes the noise-free expected electrons per pixel and returns a realised electron frame prior to read noise and digitisation. exposure_s is needed only to scale the cosmic-ray rate. The working dtype follows mean_electrons (float64 exact, or float32 for the memory-light fast path).

Source code in src/getframes/noise.py
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
def frame_electrons(
    config: CameraConfig,
    mean_electrons: NDArray[np.float64],
    rng: Any,
    exposure_s: float = 0.0,
    *,
    backend: ArrayBackend | None = None,
    fixed_patterns: FixedPatternMaps | None = None,
) -> Any:
    """Apply shot noise, CIC, cosmic rays, nonlinearity, and any gain stage.

    Takes the noise-free expected electrons per pixel and returns a realised
    electron frame prior to read noise and digitisation. ``exposure_s`` is needed
    only to scale the cosmic-ray rate. The working dtype follows ``mean_electrons``
    (``float64`` exact, or ``float32`` for the memory-light fast path).
    """
    resolved = backend or get_backend()
    electrons = rng.poisson(mean_electrons).astype(mean_electrons.dtype)

    if config.clock_induced_charge_e > 0:
        electrons += rng.poisson(config.clock_induced_charge_e, size=electrons.shape)

    if config.cosmic_ray_rate_per_cm2_s > 0 and exposure_s > 0:
        electrons = add_cosmic_rays(electrons, config, exposure_s, rng, backend=resolved)

    if config.blooming:
        electrons = apply_blooming(electrons, config.full_well_e, backend=resolved)
    elif config.has_gain_stage:
        # The image-area well fills before charge enters an EM/avalanche register.
        # Keeping this boundary ahead of the gain stage prevents input full well
        # from being mistaken for an amplified-output ceiling.
        resolved.xp.clip(electrons, 0.0, config.full_well_e, out=electrons)

    if config.cti > 0:
        electrons = apply_cti(electrons, config.cti, backend=resolved)

    if config.ipc_coupling > 0:
        electrons = apply_ipc(electrons, config.ipc_coupling, backend=resolved)

    if config.nonlinearity > 0 or config.nonlinearity_coeffs is not None:
        electrons = apply_nonlinearity(electrons, config, backend=resolved)

    if config.has_gain_stage:
        electrons = apply_gain_stage(
            electrons,
            config.em_gain,
            config.gain_excess_noise_factor,
            rng,
            backend=resolved,
        )
        gain_multiplier = (
            fixed_patterns.avalanche_gain_multiplier
            if fixed_patterns is not None
            else fixed_pattern_maps(
                config,
                backend=resolved,
                float_dtype=electrons.dtype,
            ).avalanche_gain_multiplier
        )
        electrons *= gain_multiplier

    return electrons

block_sum(array, factor)

Sum an array into factor x factor super-pixel blocks (both dims divisible).

Source code in src/getframes/noise.py
1216
1217
1218
1219
1220
1221
1222
1223
1224
def block_sum(array: Any, factor: int) -> Any:
    """Sum an array into ``factor x factor`` super-pixel blocks (both dims divisible)."""
    if factor == 1:
        return array
    height, width = array.shape
    binned: NDArray[Any] = array.reshape(height // factor, factor, width // factor, factor).sum(
        axis=(1, 3)
    )
    return binned

simulate_frame(config, photon_rate, exposure_s, *, temperature_c, background_photon_rate=0.0, quantum_efficiency=None, extra_electrons=0.0, binning=1, binning_mode='digital', rng=None, seed=None, float_dtype=DEFAULT_FLOAT_DTYPE, backend=None, fixed_patterns=None, _dark_signal=None, workspace=None, out=None, _workspace_claimed=False, _preserve_truth=True, _output_slices=None, _out_validated=False)

Simulate one frame end-to-end, returning ADU and the noise-free truth.

Parameters:

Name Type Description Default
config CameraConfig

The detector configuration.

required
photon_rate PhotonRate

Incident photon rate in photons/s/pixel, as a scalar (uniform) or a 2-D array. Use 0.0 for a dark/bias frame.

required
exposure_s float

Integration time in seconds (0 for a bias frame).

required
temperature_c float

Sensor temperature in degrees Celsius.

required
background_photon_rate PhotonRate

Additive background (sky/thermal) photon rate in photons/s/pixel.

0.0
quantum_efficiency float | None

Overrides config.quantum_efficiency for the photon-to-electron step (used by spectral mode with a pre-converted electron map and 1.0).

None
extra_electrons PhotonRate

Additive noise-free signal already in electrons (scalar or 2-D array), injected before shot noise and the gain stage. Used to carry latent charge from image persistence across the frames of an observation; it is real charge in the well, so it picks up shot noise and any EM/avalanche gain.

0.0
binning int

Combine binning x binning pixels into each output pixel (1 = no binning). config.resolution is the native sensor grid and must be divisible by binning; the returned frame is resolution // binning.

1
binning_mode str

How the binning combines charge relative to the read amplifier. "digital" (post-read / software binning, the default) reads every native pixel with its own read noise and then sums the digitised values, so binned read noise grows as binning (in quadrature over the binning**2 pixels). "on_chip" (pre-read / charge-domain / hardware binning) sums the collected charge before the amplifier, so a single read noise is applied to each super-pixel (the CCD/charge-domain advantage). Both sum the signal identically; they differ only in how read noise accumulates.

'digital'
rng Any | None

Provide an existing generator, or a seed to build a fresh one.

None
seed Any | None

Provide an existing generator, or a seed to build a fresh one.

None
float_dtype DTypeLike

Working floating-point precision of the per-pixel arrays: float64 (the exact default) or float32 for the memory-light fast path used for large detectors and bulk dataset generation. The digitised ADU stay integer regardless; only the floating-point signal chain and the truth arrays change.

DEFAULT_FLOAT_DTYPE
workspace DetectorWorkspace | None

Optional reusable :class:DetectorWorkspace. Scratch arrays are private and never escape in the returned result. A workspace is sequential-use only and binds to this detector geometry/device on first use.

None
out Any | None

Optional C-contiguous, writable backend-native uint32 destination for the digitised frame. The returned adu is this exact array. The caller owns its lifetime and must not reuse it while a consumer still needs the frame.

None
Source code in src/getframes/noise.py
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
def simulate_frame(
    config: CameraConfig,
    photon_rate: PhotonRate,
    exposure_s: float,
    *,
    temperature_c: float,
    background_photon_rate: PhotonRate = 0.0,
    quantum_efficiency: float | None = None,
    extra_electrons: PhotonRate = 0.0,
    binning: int = 1,
    binning_mode: str = "digital",
    rng: Any | None = None,
    seed: int | None = None,
    float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
    backend: ArrayBackend | None = None,
    fixed_patterns: FixedPatternMaps | None = None,
    _dark_signal: Any | None = None,
    workspace: DetectorWorkspace | None = None,
    out: Any | None = None,
    _workspace_claimed: bool = False,
    _preserve_truth: bool = True,
    _output_slices: tuple[slice, slice] | None = None,
    _out_validated: bool = False,
) -> SimulationResult:
    """Simulate one frame end-to-end, returning ADU and the noise-free truth.

    Parameters
    ----------
    config:
        The detector configuration.
    photon_rate:
        Incident photon rate in photons/s/pixel, as a scalar (uniform) or a 2-D
        array. Use ``0.0`` for a dark/bias frame.
    exposure_s:
        Integration time in seconds (``0`` for a bias frame).
    temperature_c:
        Sensor temperature in degrees Celsius.
    background_photon_rate:
        Additive background (sky/thermal) photon rate in photons/s/pixel.
    quantum_efficiency:
        Overrides ``config.quantum_efficiency`` for the photon-to-electron step
        (used by spectral mode with a pre-converted electron map and ``1.0``).
    extra_electrons:
        Additive noise-free signal already in electrons (scalar or 2-D array),
        injected before shot noise and the gain stage. Used to carry latent charge
        from image persistence across the frames of an observation; it is real
        charge in the well, so it picks up shot noise and any EM/avalanche gain.
    binning:
        Combine ``binning x binning`` pixels into each output pixel (``1`` = no
        binning). ``config.resolution`` is the native sensor grid and must be
        divisible by ``binning``; the returned frame is ``resolution // binning``.
    binning_mode:
        How the binning combines charge relative to the read amplifier. ``"digital"``
        (post-read / software binning, the default) reads every native pixel with its
        own read noise and then sums the digitised values, so binned read noise grows
        as ``binning`` (in quadrature over the ``binning**2`` pixels). ``"on_chip"``
        (pre-read / charge-domain / hardware binning) sums the collected charge
        *before* the amplifier, so a single read noise is applied to each super-pixel
        (the CCD/charge-domain advantage). Both sum the signal identically; they
        differ only in how read noise accumulates.
    rng, seed:
        Provide an existing generator, or a seed to build a fresh one.
    float_dtype:
        Working floating-point precision of the per-pixel arrays: ``float64`` (the
        exact default) or ``float32`` for the memory-light fast path used for large
        detectors and bulk dataset generation. The digitised ADU stay integer
        regardless; only the floating-point signal chain and the truth arrays change.
    workspace:
        Optional reusable :class:`DetectorWorkspace`. Scratch arrays are private
        and never escape in the returned result. A workspace is sequential-use
        only and binds to this detector geometry/device on first use.
    out:
        Optional C-contiguous, writable backend-native ``uint32`` destination for
        the digitised frame. The returned ``adu`` is this exact array. The caller
        owns its lifetime and must not reuse it while a consumer still needs the
        frame.
    """
    resolved = backend or get_backend()
    if exposure_s < 0:
        raise ValueError("exposure_s must be non-negative.")
    if binning < 1:
        raise ValueError("binning must be a positive integer.")
    if binning_mode not in ("digital", "on_chip"):
        raise ValueError("binning_mode must be 'digital' or 'on_chip'.")
    if rng is None:
        rng = resolved.default_rng(seed)

    if workspace is not None and not _workspace_claimed:
        with workspace._using(resolved, config.resolution, float_dtype):
            return simulate_frame(
                config,
                photon_rate,
                exposure_s,
                temperature_c=temperature_c,
                background_photon_rate=background_photon_rate,
                quantum_efficiency=quantum_efficiency,
                extra_electrons=extra_electrons,
                binning=binning,
                binning_mode=binning_mode,
                rng=rng,
                seed=seed,
                float_dtype=float_dtype,
                backend=resolved,
                fixed_patterns=fixed_patterns,
                _dark_signal=_dark_signal,
                workspace=workspace,
                out=out,
                _workspace_claimed=True,
                _preserve_truth=_preserve_truth,
                _output_slices=_output_slices,
                _out_validated=_out_validated,
            )

    output_shape = (config.resolution[0] // binning, config.resolution[1] // binning)
    if out is not None and not _out_validated:
        if _output_slices is None:
            expected_output_shape = output_shape
        else:
            rows, columns = _output_slices
            expected_output_shape = (
                len(range(*rows.indices(output_shape[0]))),
                len(range(*columns.indices(output_shape[1]))),
            )
        _validate_output_buffer(out, resolved, expected_output_shape)
        _out_validated = True
    elif out is None and _output_slices is not None:
        raise ValueError("internal output slices require an explicit out buffer")

    photo_out = None
    if workspace is not None and not _preserve_truth:
        photo_out = workspace._buffer("mean_photo", resolved, config.resolution, float_dtype)
    mean_photo = photo_signal_map(
        config,
        photon_rate,
        exposure_s,
        background_photon_rate,
        quantum_efficiency,
        float_dtype,
        backend=resolved,
        fixed_patterns=fixed_patterns,
        out=photo_out,
    )
    mean_dark = (
        dark_signal_map(
            config,
            exposure_s,
            temperature_c,
            float_dtype,
            backend=resolved,
            fixed_patterns=fixed_patterns,
        )
        if _dark_signal is None
        else _dark_signal
    )
    if workspace is None:
        mean_total = mean_photo + mean_dark
    elif not _preserve_truth:
        # The photo expectation is private scratch when truth is disabled, so it
        # can become the total in place with no allocation or extra copy kernel.
        mean_total = mean_photo
        mean_total += mean_dark
    else:
        mean_total = workspace._buffer("mean_total", resolved, config.resolution, float_dtype)
        resolved.xp.add(mean_photo, mean_dark, out=mean_total)
    mean_total += resolved.asarray(extra_electrons, dtype=float_dtype)

    if binning > 1:
        height, width = config.resolution
        if height % binning or width % binning:
            raise ValueError(
                f"resolution {config.resolution} is not divisible by binning {binning}."
            )

    if binning == 1:
        electrons = frame_electrons(
            config,
            mean_total,
            rng,
            exposure_s,
            backend=resolved,
            fixed_patterns=fixed_patterns,
        )
        adu = digitize(
            electrons,
            config,
            rng,
            backend=resolved,
            fixed_patterns=fixed_patterns,
            out=out,
            _output_slices=_output_slices,
            _out_validated=_out_validated,
        )
    elif binning_mode == "on_chip":
        # Charge is summed before the amplifier: read out each super-pixel once, so a
        # single read noise applies. The summing well holds ~binning**2 more charge.
        binned_shape = (config.resolution[0] // binning, config.resolution[1] // binning)
        all_boundaries = (
            *config.amplifier_boundaries_y_px,
            *config.amplifier_boundaries_x_px,
        )
        if any(boundary % binning for boundary in all_boundaries):
            raise ValueError("on-chip binning must divide every explicit amplifier boundary.")
        binned_config = config.replace(
            resolution=binned_shape,
            roi=None,
            full_well_e=config.full_well_e * binning * binning,
            amplifier_boundaries_y_px=tuple(
                boundary // binning for boundary in config.amplifier_boundaries_y_px
            ),
            amplifier_boundaries_x_px=tuple(
                boundary // binning for boundary in config.amplifier_boundaries_x_px
            ),
        )
        electrons = frame_electrons(
            binned_config, block_sum(mean_total, binning), rng, exposure_s, backend=resolved
        )
        # On-chip binning changes the pixel grid, so native cached maps do not apply.
        adu = digitize(
            electrons,
            binned_config,
            rng,
            backend=resolved,
            out=out,
            _output_slices=_output_slices,
            _out_validated=_out_validated,
        )
    else:
        # Digital / post-read: read every native pixel (its own read noise), then sum
        # the digitised values, so read noise adds in quadrature over binning**2 pixels.
        electrons = frame_electrons(
            config,
            mean_total,
            rng,
            exposure_s,
            backend=resolved,
            fixed_patterns=fixed_patterns,
        )
        native_adu = digitize(
            electrons, config, rng, backend=resolved, fixed_patterns=fixed_patterns
        )
        adu = block_sum(native_adu.astype(np.uint64), binning).astype(np.uint32)
        if out is not None:
            resolved.xp.copyto(out, adu)
            adu = out

    return SimulationResult(
        adu, block_sum(mean_photo, binning), block_sum(mean_dark, binning), photon_rate
    )

generate_dark_frame(config, exposure_s, temperature_c, rng=None, seed=None, *, backend=None, fixed_patterns=None, _dark_signal=None)

End-to-end dark frame in ADU (the photon_rate = 0 case of simulate_frame).

Source code in src/getframes/noise.py
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
def generate_dark_frame(
    config: CameraConfig,
    exposure_s: float,
    temperature_c: float,
    rng: Any | None = None,
    seed: int | None = None,
    *,
    backend: ArrayBackend | None = None,
    fixed_patterns: FixedPatternMaps | None = None,
    _dark_signal: Any | None = None,
) -> Any:
    """End-to-end dark frame in ADU (the ``photon_rate = 0`` case of ``simulate_frame``)."""
    return simulate_frame(
        config,
        0.0,
        exposure_s,
        temperature_c=temperature_c,
        rng=rng,
        seed=seed,
        backend=backend,
        fixed_patterns=fixed_patterns,
        _dark_signal=_dark_signal,
    ).adu

dark_frame_electrons(config, exposure_s, temperature_c, rng, *, backend=None)

Electron-domain dark frame prior to digitisation (kept for convenience).

Source code in src/getframes/noise.py
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
def dark_frame_electrons(
    config: CameraConfig,
    exposure_s: float,
    temperature_c: float,
    rng: Any,
    *,
    backend: ArrayBackend | None = None,
) -> Any:
    """Electron-domain dark frame prior to digitisation (kept for convenience)."""
    resolved = backend or get_backend()
    mean_dark = dark_signal_map(config, exposure_s, temperature_c, backend=resolved)
    return frame_electrons(config, mean_dark, rng, exposure_s, backend=resolved)