Commit c6dcef7d authored by yuedong0607's avatar yuedong0607
Browse files

refactor sky_background module

parent 35600e7a
Loading
Loading
Loading
Loading
+30 −6
Original line number Diff line number Diff line
@@ -29,10 +29,11 @@ The built-in compositions currently pin:
- `legacy-v1`: definition set `legacy/1.0.0`;
- `next-v1`: definition set `next/1.0.0` with 31 science slots (`00` through
  `30`) and the existing FGS definitions;
- `legacy-v1` pins reference set `1.0.0` for each category;
- `next-v1` pins detector responses, filter transmissions, and system
  throughputs `2.0.0`, while unchanged reference categories remain pinned to
  `1.0.0`.
- `legacy-v1` pins its detector, bandpass, band-scalar optics, and shared
  sky-background references `1.0.0`;
- `next-v1` pins detector responses, filter transmissions, system throughputs,
  and optics references `2.0.0`, while the shared sky-background and unchanged
  reference categories remain pinned to `1.0.0`.

Released versions should be treated as immutable. Publish changed definitions
or reference files under a new version, then add or update a composition
@@ -108,6 +109,28 @@ configs = instrument.materialized_detector_reference_paths(
)
```

### Spectral response contracts

Imaging files in `system_throughput` are complete total-system responses. They
include telescope optics, filter transmission, and detector QE. Consumers
should use the semantic APIs rather than multiplying component files:

```python
system = instrument.system_response("i", detector_id="07")
optics = instrument.optics_response()
order = instrument.slitless_response("GI", "1")
```

`system` is applied directly to imaging spectra. `optics` is an optics-only
curve and is applied to slitless calculations only when `optics` is absent from
the slitless response's `included_components`. Legacy per-band optics scalars
are interpolated at system-derived pivot wavelengths by `optics_response()`, so
callers receive the same spectral interface for every release. The legacy
slitless sensitivity already includes optics and therefore does not consume
that interpolated diagnostic curve. `system_pivot_wavelength()` and
`system_response_support()` provide derived diagnostics without introducing
separate hard-coded band constants.

The simulation selects a built-in composition by default. A custom
`release_directory` points to an instrument-data root containing
`releases/`, `definition_sets/`, and `reference_sets/`:
@@ -125,9 +148,10 @@ instrument_data:
Low-level `reference_path()` and `materialized_reference_path()` APIs remain
available for explicit user overrides. Main simulation code should use the
purpose-based accessors `global_reference_path()`, `detector_reference_path()`,
`materialized_global_reference_path()`,
`materialized_detector_reference_paths()`, `filter_reference_path()`, and
`slitless_throughput_path()`. This keeps physical filenames and category choices
inside the versioned definition set rather than `observation_sim`.
`slitless_throughput_path()`. This keeps physical filenames and category
choices inside the versioned definition set rather than `observation_sim`.

## Validation and schemas

+2 −0
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ from .repository import (
    InstrumentRepository,
    ResolvedChannel,
    ResolvedDetector,
    SpectralResponse,
    get_builtin_repository,
)
from .schema import emit_schemas
@@ -39,6 +40,7 @@ __all__ = [
    "ResolvedChannel",
    "ResolvedDetector",
    "ResponseProfileDefinition",
    "SpectralResponse",
    "ShutterTiming",
    "emit_schemas",
    "get_builtin_repository",
+29 −0
Original line number Diff line number Diff line
@@ -8,11 +8,38 @@ global_references:
  telescope_efficiency:
    group: optics
    file: mirror_ccdnote.txt
    response_sampling: band_scalar
    value_unit: dimensionless
  default_sky_spectrum:
    group: sky_background
    file: sky_emiss_hubble_50_50_A.dat
    wavelength_unit: angstrom
    value_unit: photon / (s m2 arcsec2 angstrom)
  bright_star_catalog:
    group: sky_background
    file: bright_star_runtime_g10.fits
  bright_star_sed_library:
    group: sky_background
    file: bright_star_seds_g10.hdf5
  straylight_pst:
    group: sky_background
    file: PST
  earth_albedo_map:
    group: sky_background
    file: R
  earthshine_spectrum:
    group: sky_background
    file: earthshine_spectrum.dat
    wavelength_unit: angstrom
    value_unit: relative / angstrom
  zodiacal_map:
    group: sky_background
    file: Zodiacal
  zodiacal_spectrum:
    group: sky_background
    file: zodiacal_spectrum.dat
    wavelength_unit: angstrom
    value_unit: erg / (s cm2 arcsec2 angstrom)

detector_defaults:
  field_distortion:
@@ -125,6 +152,8 @@ filter_references:
    throughput: {group: system_throughput, file: fgs_throughput.txt, wavelength_unit: angstrom}
    subbands: {group: filter_transmission, file: fgs_sub.list, wavelength_unit: angstrom}

slitless_included_components: [optics, grating, detector_qe]

slitless_throughputs:
  GU:
    "-2": {group: slitless_spectroscopy, file: GU.Throughput.-2st.fits, wavelength_unit: angstrom}
+31 −1
Original line number Diff line number Diff line
@@ -7,12 +7,40 @@ global_references:
    file: wfc-cr-attachpixel.dat
  telescope_efficiency:
    group: optics
    file: mirror_ccdnote.txt
    file: mirrors_total.txt
    response_sampling: spectral
    wavelength_unit: nm
    value_unit: dimensionless
  default_sky_spectrum:
    group: sky_background
    file: sky_emiss_hubble_50_50_A.dat
    wavelength_unit: angstrom
    value_unit: photon / (s m2 arcsec2 angstrom)
  bright_star_catalog:
    group: sky_background
    file: bright_star_runtime_g10.fits
  bright_star_sed_library:
    group: sky_background
    file: bright_star_seds_g10.hdf5
  straylight_pst:
    group: sky_background
    file: PST
  earth_albedo_map:
    group: sky_background
    file: R
  earthshine_spectrum:
    group: sky_background
    file: earthshine_spectrum.dat
    wavelength_unit: angstrom
    value_unit: relative / angstrom
  zodiacal_map:
    group: sky_background
    file: Zodiacal
  zodiacal_spectrum:
    group: sky_background
    file: zodiacal_spectrum.dat
    wavelength_unit: angstrom
    value_unit: erg / (s cm2 arcsec2 angstrom)

detector_defaults:
  field_distortion:
@@ -147,6 +175,8 @@ response_profiles:
  wi1: {filter: wi, throughput: {group: system_throughput, file: WI1_throughput.txt, wavelength_unit: nm}, includes_detector_qe: true}
  wi2: {filter: wi, throughput: {group: system_throughput, file: WI2_throughput.txt, wavelength_unit: nm}, includes_detector_qe: true}

slitless_included_components: [grating, detector_qe]

slitless_throughputs:
  GU:
    "-2": {group: slitless_spectroscopy, file: GU.Throughput.-2st.fits, wavelength_unit: angstrom}
+76 −18
Original line number Diff line number Diff line
@@ -16,6 +16,8 @@ Version = Annotated[
        )
    ),
]


def _validate_relative_path(value: str) -> str:
    path = PurePosixPath(value)
    if not value or path.is_absolute() or ".." in path.parts:
@@ -367,8 +369,7 @@ class FilterCatalog(StrictModel):
            _validate_path_segment(filter_id)
            if definition.display_name.lower() != filter_id:
                raise ValueError(
                    "filter display names must match their canonical keys "
                    "ignoring case"
                    "filter display names must match their canonical keys ignoring case"
                )
        display_names = [value.display_name.lower() for value in self.filters.values()]
        legacy_ids = [value.legacy_id for value in self.filters.values()]
@@ -395,6 +396,7 @@ ReferenceGroupName = Literal[
class ReferenceTarget(StrictModel):
    group: ReferenceGroupName
    file: str
    response_sampling: Literal["spectral", "band_scalar"] | None = None
    wavelength_unit: Literal["nm", "angstrom"] | None = None
    value_unit: str | None = Field(default=None, min_length=1)

@@ -425,7 +427,32 @@ class ResponseProfileDefinition(StrictModel):
    filter: str
    throughput: ReferenceTarget
    transmission: ReferenceTarget | None = None
    includes_detector_qe: bool = False
    response_kind: Literal["total_system"] = "total_system"
    included_components: tuple[Literal["optics", "filter", "detector_qe"], ...] = (
        "optics",
        "filter",
        "detector_qe",
    )
    includes_detector_qe: bool = True

    @field_validator("included_components", mode="before")
    @classmethod
    def freeze_included_components(cls, value):
        return tuple(value)

    @model_validator(mode="after")
    def validate_total_system_components(self) -> "ResponseProfileDefinition":
        required = {"optics", "filter", "detector_qe"}
        if set(self.included_components) != required:
            raise ValueError(
                "total-system response profiles must include optics, filter, "
                "and detector_qe"
            )
        if not self.includes_detector_qe:
            raise ValueError(
                "total-system response profiles must set includes_detector_qe=true"
            )
        return self


class ReferenceAssignmentsCatalog(StrictModel):
@@ -437,17 +464,50 @@ class ReferenceAssignmentsCatalog(StrictModel):
    response_profiles: dict[str, ResponseProfileDefinition] = Field(
        default_factory=dict
    )
    slitless_included_components: tuple[
        Literal["optics", "grating", "detector_qe"], ...
    ] = ("grating", "detector_qe")
    slitless_throughputs: dict[str, dict[str, ReferenceTarget]]

    @field_validator("slitless_included_components", mode="before")
    @classmethod
    def freeze_slitless_components(cls, value):
        return tuple(value)

    @model_validator(mode="after")
    def validate_spectral_wavelength_units(self) -> "ReferenceAssignmentsCatalog":
        spectral_targets: list[tuple[str, ReferenceTarget]] = []
        sky_target = self.global_references.get("default_sky_spectrum")
        if sky_target is not None:
            spectral_targets.append(("global default_sky_spectrum", sky_target))
        for role in (
            "default_sky_spectrum",
            "earthshine_spectrum",
            "zodiacal_spectrum",
        ):
            sky_target = self.global_references.get(role)
            if sky_target is None:
                continue
            spectral_targets.append((f"global {role}", sky_target))
            if sky_target.value_unit is None:
                raise ValueError(f"global {role} is missing its value unit")
        telescope_target = self.global_references.get("telescope_efficiency")
        if telescope_target is not None:
            sampling = telescope_target.response_sampling
            if sampling is None:
                raise ValueError(
                    "global telescope_efficiency is missing response_sampling"
                )
            if telescope_target.value_unit is None:
                raise ValueError(
                    "global telescope_efficiency is missing its value unit"
                )
            if sampling == "spectral":
                spectral_targets.append((
                    "global telescope_efficiency",
                    telescope_target,
                ))
            elif telescope_target.wavelength_unit is not None:
                raise ValueError(
                    "global default_sky_spectrum is missing its value unit"
                    "band-scalar telescope_efficiency must not declare a "
                    "wavelength unit"
                )
        for filter_id, assignments in self.filter_references.items():
            for role in ("transmission", "throughput", "subbands"):
@@ -455,16 +515,15 @@ class ReferenceAssignmentsCatalog(StrictModel):
                if target is not None:
                    spectral_targets.append((f"filter {filter_id} {role}", target))
        for profile_id, profile in self.response_profiles.items():
            spectral_targets.append(
                (f"response profile {profile_id} throughput", profile.throughput)
            )
            spectral_targets.append((
                f"response profile {profile_id} throughput",
                profile.throughput,
            ))
            if profile.transmission is not None:
                spectral_targets.append(
                    (
                spectral_targets.append((
                    f"response profile {profile_id} transmission",
                    profile.transmission,
                    )
                )
                ))
        for grating, order_map in self.slitless_throughputs.items():
            spectral_targets.extend(
                (f"slitless throughput {grating} order {order}", target)
@@ -478,8 +537,7 @@ class ReferenceAssignmentsCatalog(StrictModel):
        ]
        if missing_units:
            raise ValueError(
                "spectral references are missing wavelength units: "
                f"{missing_units}"
                f"spectral references are missing wavelength units: {missing_units}"
            )
        return self

Loading