s2_msi_raw_generator package#
Sentinel-2 MSI reverse E2ES — degrade L1A/L1B radiance back to synthetic Synthetic L0 RAW.
The reverse / forward-instrument conjugate of msi-processor. The chain is radiometric-only
(14-step chain, L1A/L1B in per-detector geometry → Synthetic L0 RAW DN); an L1C-entry + geometry-reverse
module was cancelled — L1A/L1B is already in detector geometry, so there is nothing to
de-orthorectify (Issue #17). See docs/atbd/atbd.md.
Submodules#
s2_msi_raw_generator.adf module#
Per-band ADFs for the reverse chain — published data where it exists.
Provenance of each component:
All values are used verbatim fromS2 sources — nothing is fitted. The raw model is the
official L1 ATBD equation X = A·G·L + D (S2-PDGS-MPC-ATBD-L1 §4.1.1). Provenance per component:
- gain (A) —
Band.cal_gain, the absolute calibration A in S1 (DN=A·L), derived from the reference noise α,β + SNR@Lref so the chain reproduces SNR@Lref. The product’s
physical_gainis incoherent with α,β on this synthetic dataset (it mis-scales low- radiance bands by up to ~10×), so it is kept only for L0 metadata / the round-trip bridge.
- gain (A) —
- PSF — REAL official ESA per-band, per-unit PSF matrices (SentiWiki S2{A,B,C}_PSF.zip,
data/psf/); 33×33 oversampling-5 matrices integrated to the detector grid. B10 has no published PSF (water-vapour band) → identity kernel. See
real_psf_kernel.
spectral — REAL per-unit centre/bandwidth/equivalent wavelength (SRF doc, in sensor.py).
- noise α,β — REAL noise model
σ=√(α²+β·DN)(S2-RUT) with α, β straight from the L1A product metadata (sensor.NOISE_ALPHA/NOISE_BETA), used verbatim. With the cal_gain DN scale the chain reproduces the SNR@Lref.
- noise α,β — REAL noise model
- dark (D), PRNU (G) — REAL per-pixel values from the operational S2A GIPP R2EQOG (dark COEFF_D
≈440–522 LSB; relative-response gains cubic A/B/C / bilinear A1/A2/Zs), parsed by s2_msi_raw_generator.gipp and built via
BandADF.from_gipp. Fallbacks: the Feb-2023 DQR dark (sensor.DARK_PEDESTAL_LSB + Band.dark_dsnu) andBandADF.from_product(L1B-derived PRNU);synthesizeseeds representative values when no GIPP is supplied. Onboard-eq gain uses the measured stability (0.05 % 1σ, no offset; sensor.EQ_GAIN_STD).
- class s2_msi_raw_generator.adf.BandADF(band, noise_a, noise_b, psf, prnu_gain, dark_dn, eq_gain, eq_offset, prnu_is_real=False, source='synthetic')#
Bases:
objectPer-band ADF set for the reverse chain (per detector of width
n_det).psfand the band’s spectral/gain values are (published). The per-detectorprnu_gain/dark_dn/eq_*arrays are either product-derived (from_product) or seeded representative values (synthesize) when the operational GIPP is not supplied.- dark_dn: ndarray#
- eq_gain: ndarray#
- eq_offset: ndarray#
- classmethod from_gipp(b, detector, gippset, *, active_width=None)#
Build a
BandADFfor one detector from the REAL operational GIPP (R2EQOG): per-pixel dark signalDand relative-response (PRNU) gain (C cubic / A1 bilinear).gippsetis as2_msi_raw_generator.gipp.GippSet. Whenactive_widthis given and differs from the GIPP across-track size, the blind columns (from BLINDP) are stripped so the arrays align to the active product width. PSF and noise stay (SentiWiki PSF, product noise model).- Return type:
- classmethod from_product(b, *, prnu_gain, dark_dn, eq_gain=None, eq_offset=None)#
Build a
BandADFfrom REAL per-detector PRNU/dark arrays (e.g. derived from the matched L0↔L1A products byscripts/derive_prnu_dark.py). PSF and noise stay ESA-sourced.- Return type:
- noise_a: float#
- noise_b: float#
- prnu_gain: ndarray#
- prnu_is_real: bool = False#
- psf: ndarray#
- source: str = 'synthetic'#
- s2_msi_raw_generator.adf.fit_noise_coeffs(b, read_fraction=0.1)#
Fallback fit of
(a, b)ofσ² = a + b·DNto SNR@Lref — used only if the product noise model is unavailable. Prefernoise_coeffs()(the α, β).Splits the total variance at Lref (
σ_ref² = (DN_ref/SNR)²) into a read/dark floora = read_fraction·σ_ref²and a shot termb = (1-read_fraction)·σ_ref²/DN_ref, so thatσ(DN_ref) = σ_refexactly (reproduces SNR@Lref) witha, b > 0for every band.- Return type:
tuple[float,float]
- s2_msi_raw_generator.adf.gaussian_psf(mtf_nyquist=0.25, radius=4)#
Normalized 2-D separable Gaussian PSF whose discrete MTF at Nyquist ==
mtf_nyquist.The continuous formula
MTF(f)=exp(-2π²σ²f²)overestimates attenuation for a sub-pixel, truncated, re-normalized kernel, soσis calibrated numerically (bisection) to hit the discrete target. Kernel sums to 1 (DC gain 1, radiometry-preserving).- Return type:
ndarray
- s2_msi_raw_generator.adf.load_oversampled_psf(band, unit='S2A')#
Load the 33×33 oversampled PSF matrix for
band/unit;Noneif none published.B10 (water-vapour band) has no published PSF — returns
None. Result is cached and read-only.- Return type:
ndarray|None
- s2_msi_raw_generator.adf.noise_coeffs(b)#
REAL noise model coefficients
(a, b)forσ = √(a + b·DN)from the L1A product.The official S2-RUT model (Gorroño & Gascon) is
Noise(DN) = √(α² + β·DN), with α, β straight from the product’s quality_indicators_info/…/noise_model (sensor.NOISE_ALPHA/NOISE_BETA). Returnsa = α²andb = βso the chain’sσ=√(a+b·DN)is exactly the RUT model; it reproduces the spec SNR@Lref. No fitting.- Return type:
tuple[float,float]
- s2_msi_raw_generator.adf.real_psf_kernel(band, unit='S2A', oversampling=5)#
Real detector-grid PSF kernel for
band/unit(Σ=1, DC gain 1).The published PSF is oversampled ×``oversampling``; integrate each
oversampling``×``oversamplingblock (centred on the matrix centre) to the detector-pixel grid. B10 → identity (no re-blur).- Return type:
ndarray
- s2_msi_raw_generator.adf.synthesize(b, n_det, *, seed=0, prnu_std=0.005)#
Build a
BandADFfor bandbovern_detdetector columns.PSF (ESA, per-unit), spectral, gain and the noise model (α, β from the product) are all ESA-sourced. The per-detector PRNU/dark/equalization arrays are seeded representative values — use
BandADF.from_gipp()with the (publicly fetchable) operational GIPP for the reference per-pixel coefficients, orBandADF.from_product()for product-derived arrays; this synthetic fallback exists for GIPP-less environments only. (The early “credentialed GIPP” blocker #36 is resolved — see ATBD §8.)- Return type:
s2_msi_raw_generator.adf_writer module#
Serialize derived calibration coefficients to EOPF-CPM Auxiliary Data Files (the “cal-DB”).
Option Y of the generator ⇄ msi-processor coupling: the Synthetic Raw Data Generator derives
realistic radiometric calibration coefficients and writes them as versioned EOPF zarr ADFs that the
downstream processor (the L1PP blocks inside msi-processor) consumes directly as its
nuc / dark / radiometric Auxiliary Data Files. Nothing here re-implements the processor;
it only produces the ADF set the processor already expects (single shared sensor-model ADF — the
e2es-coupling-decision ADR).
ADF schema — matches msi_processor’s AuxiliaryDataFile.data_ptr mappings (zarr v2):
nuc.zarr /gain/<band>, /offset/<band> float32 (detector,) PRNU correction g_d, o_d dark.zarr /dark_offset/<band> float32 scalar per-band dark k radiometric.zarr /gain/<band>, /offset/<band> float32 scalar DN→radiance G, O spectral.zarr /esun/<band> float32 scalar ESUN (toa reflectance) noise.zarr /alpha/<band>, /beta/<band> float32 scalar σ=√(α²+β·DN) (E2ES-side)
Convention. The NUC gain/offset follow the processor’s own two-point form
(msi_processor.computing.radiometric.core.estimate_nuc): from a synthetic dark frame D and a
diffuser flat F (both (line, detector)), g_d = (μ_F−μ_D)/(F̄_d−D̄_d),
o_d = μ_F − g_d·F̄_d, plus the per-band dark k = μ_D that apply_nuc subtracts. The absolute
radiometric.gain is derived from the diffuser (l_diff/(μ_F−μ_D) ≈ 1/cal_gain,
offset = 0) and is self-consistent with the NUC, so the processor’s apply_nuc then
dn_to_radiance recover A·L then L. The coefficients are derived (not the truth ADF) —
the round-trip is therefore non-tautological (inverse-crime cure).
Scalars are stored as 0-d arrays (float(...)-able, as the processor reads them). The cal-DB is
per-band (one across-track (detector,) vector per band), matching the processor’s per-band ADF.
- class s2_msi_raw_generator.adf_writer.BandCal(band, nuc_gain, nuc_offset, dark_offset, radio_gain, radio_offset=0.0, esun=0.0, noise_alpha=0.0, noise_beta=0.0)#
Bases:
objectPer-band calibration coefficients in the
msi-processorADF convention.- band: str#
- dark_offset: float#
- esun: float = 0.0#
- noise_alpha: float = 0.0#
- noise_beta: float = 0.0#
- nuc_gain: ndarray#
- nuc_offset: ndarray#
- radio_gain: float#
- radio_offset: float = 0.0#
- s2_msi_raw_generator.adf_writer.nuc_two_point(dark_frame, flat_frame)#
Per-detector NUC
(gain, offset, dark_offset)in the processor’s two-point convention.Reproduces
msi_processor.computing.radiometric.core.estimate_nucexactly (dark + flat →g_d = (μ_F−μ_D)/(F̄_d−D̄_d),o_d = μ_F − g_d·F̄_d) plus the per-band darkk = μ_Dthatapply_nucsubtracts.dark_frame/flat_frameare(line, detector). Degenerate detectors (F̄_d = D̄_d) yield a non-finite gain — left as-is; the processor flags them viadetect_bad_pixels.- Return type:
tuple[ndarray,ndarray,float]
- s2_msi_raw_generator.adf_writer.write_calibration_db(out_dir, cals, *, unit='S2A', source='derived (CSM diffuser + dark calibration)', include_spectral=True, include_noise=True)#
Write the EOPF zarr ADF set (
nuc/dark/radiometric[+spectral+noise]).The raw calibration acquisitions behind these coefficients are not ADF side-files: the pipeline’s calibration mode packages them as downlink Synthetic L0 products (
S02MSIDCAdark,S02MSISCAsun-diffuser) — see the ICD.Returns the list of written
.zarrpaths. APROVENANCE.mdis written beside them.- Return type:
list[Path]
s2_msi_raw_generator.caldb module#
Build the calibration database (EOPF ADF set) for the downstream L1PP processor.
Derives the radiometric calibration coefficients for all 13 bands via the two-reference
(CSM sun-diffuser + dark) inverse-crime cure — in the msi-processor NUC convention — and writes
them as EOPF zarr ADFs (nuc / dark / radiometric [+ noise]) with a PROVENANCE.md.
This is Option Y of the generator ⇄ processor coupling: the generator produces the ADF; the
processor keeps calibration internal. See s2_msi_raw_generator.adf_writer.
Driven by the build-caldb phase of scripts/run_pipeline.py.
- s2_msi_raw_generator.caldb.build(out_dir, *, unit='S2A', n_det=400, seed=0, include_noise=True)#
Derive all 13 bands and write the cal-DB to
out_dir. Returns the written ADF paths.- Return type:
list[Path]
- s2_msi_raw_generator.caldb.derive_band_cal(band_name, *, unit='S2A', n_det=400, n_frames=256, seed=0)#
Derive one band’s cal-DB coefficients from synthetic dark + diffuser acquisitions.
Impresses the truth ADF to synthesise a dark and a sun-diffuser (flat) L0 acquisition, then derives the NUC
(gain, offset)in the processor’s two-point convention plus the per-band dark and the absoluteradiometric.gain = 1/cal_gain. Coefficients are derived, not the truth.- Return type:
- s2_msi_raw_generator.caldb.derive_from_acquisitions(band_name, dark, flat, *, l_diff=None, unit='S2A')#
Derive one band’s cal-DB coefficients from given dark + flat-field acquisitions.
The two acquisitions may come from
derive_band_cal()’s internal synthesis or, in the pipeline’s calibration mode, from the packaged calibration-campaign Synthetic L0 products (darkS02MSIDCA, sun-diffuserS02MSISCA) — same numbers either way.- Return type:
s2_msi_raw_generator.calibration module#
S2 in-flight radiometric calibration sub-set — synthetic CSM sun-diffuser + dark → derived ADF.
A two-reference radiometric calibration in Sentinel-2’s reflective domain: the high-signal reference is the on-board CSM sun-diffuser (a full-field, full-pupil Lambertian diffuser giving a near-uniform bright image), and the zero reference is a dark acquisition (CSM shutter closed / nighttime ocean). Per the public L1 ATBD §4.1.1.2.2:
D(j) = ⟨X_dark(i, j)⟩_i (dark from a dark acquisition) g(j) = A · ⟨L_diff⟩_i / ⟨X_diff(i, j) − D(j)⟩_i (relative response from the diffuser) with ⟨g(j)⟩_j = 1 ⇒ fixes the absolute calibration coefficient A.
This module (a) generates the synthetic dark + diffuser L0 acquisitions by impressing the true
ADF through the reverse chain, then (b) derives D, g and A back from them — the
estimated calibration a downstream processor would actually use, instead of the truth ADF. Closing
this loop (impress truth → estimate → use the estimate) is the E2ES inverse-crime cure: residuals
then reflect calibration uncertainty, not a tautology.
- s2_msi_raw_generator.calibration.DIFFUSER_LEVEL_FACTOR = 1.5#
Diffuser radiance level as a multiple of Lref — bright but clip-free for every band (worst case B08 stays ≈2510 DN pre-quantization; S14 clips silently above 4095).
- class s2_msi_raw_generator.calibration.DerivedCalibration(band, dark, relative_response, abs_coeff, l_diff)#
Bases:
objectCalibration coefficients estimated from the synthetic diffuser + dark acquisitions.
- abs_coeff: float#
- band: str#
- dark: ndarray#
- l_diff: float#
- relative_response: ndarray#
- s2_msi_raw_generator.calibration.calibrate(adf, l_diff=None, *, n_dark=256, n_diffuser=256, seed=0)#
Run the full S2 calibration sub-set on the true
adf: synthesise the dark + diffuser acquisitions, then derive the estimatedD,g,A.l_diffdefaults to a bright diffuser radiance (≈1.5·Lref), staying within the dynamic range.- Return type:
- s2_msi_raw_generator.calibration.derive_dark(dark_acq)#
Estimate the per-detector dark
D(j) = ⟨X_dark⟩_i(average over lines reduces noise).- Return type:
ndarray
- s2_msi_raw_generator.calibration.derive_relative_response(diffuser_acq, dark_est, l_diff)#
Estimate the relative response
g(j)(⟨g⟩ = 1) and the absolute coefficientA.g(j) = A·L_diff / ⟨X_diff(i,j) − D(j)⟩_ithen normalised so⟨g(j)⟩_j = 1(L1 ATBD).- Return type:
tuple[ndarray,float]
- s2_msi_raw_generator.calibration.estimated_adf(adf, cal)#
Build a new
BandADFusing the estimated dark + relative response (not the truth).PSF and the noise model stay as-is; only the per-detector dark/PRNU are replaced by the diffuser/dark-derived estimates — the coefficients a processor would actually apply.
- Return type:
- s2_msi_raw_generator.calibration.synth_dark_acquisition(adf, n_lines, rng)#
Synthetic dark L0 acquisition (zero scene radiance → dark pedestal + sensor noise).
- Return type:
ndarray
- s2_msi_raw_generator.calibration.synth_diffuser_acquisition(adf, l_diff, n_lines, rng)#
Synthetic sun-diffuser L0 acquisition (uniform full-field radiance
l_diff→ raw DN).The diffuser is a flat field, so the radiance frame is uniform; the reverse chain impresses the true relative response (PRNU), dark and noise — exactly what the on-board CSM diffuser measures.
- Return type:
ndarray
s2_msi_raw_generator.ccsds122 module#
CCSDS 122.0-B image-data compression — lossless subset (integer DWT + bit-plane coder).
Sentinel-2 compresses MSI video data onboard with the proprietary MRCPB wavelet scheme (bit-plane coding, “similar to JPEG 2000”); a CCSDS-compression ASIC is the documented alternative option (eoPortal, S2 CoReCi). This module implements that alternative — CCSDS 122.0-B — in its lossless profile, pure numpy, so the generator’s L0 ISP payloads carry genuinely wavelet-compressed image data and the ground segment (L1A side) can restore the exact DN.
Implemented per the Blue Book structure#
Integer DWT 9/7-M (CCSDS 122.0-B §3.3): three 2-D decomposition levels, lifting form with the specified
floor(·+1/2)rounding and whole-sample symmetric extension; exactly invertible on integers.Block/family structure (§4.1): one 8×8 pixel area → 1 DC + 63 AC coefficients in three families (parent, 2×2 children, 4×4 grandchildren per family), raster scan; gaggles of 16 blocks; segments of
segment_blocksblocks.Segment headers (§4.2): the Part-1A/3/4 semantic content (start/end flags, segment count,
BitDepthDC/BitDepthAC, segment size, DWT type, pixel bit depth, image dimensions) is carried in an explicit little-endian layout (seeparse_segment_headers); the stream is fully self-describing.DC coefficient coding (§4.3): per-segment DPCM against a raw reference sample, zigzag signed→nonnegative mapping, per-gaggle Rice coding with brute-force parameter selection and an uncoded escape option.
Per-block BitDepthAC (§4.4): same DPCM + per-gaggle Rice machinery.
Documented divergences (kept deliberately small, listed in the ICD/ATBD)#
§4.5.3 word mapping / variable-length codes are not implemented. AC bit-plane coding keeps the stage semantics and scan order (significance → sign → refinement, plane-sequential from
BitDepthAC−1down to 0, block-major within a segment) but the per-stage bits are packed raw (numpy.packbits) instead of entropy-coded words. Consequence: bit-exact lossless with the full segment/gaggle/block/plane structure, but the stream is not interoperable with reference CCSDS-122 decoders (the matching decoder lives in this module) and ratios run below a full BPE.Header field packing uses explicit byte-aligned little-endian fields (with section byte-lengths) rather than the Blue Book bit layout; the content mirrors Parts 1A/3/4.
Sections are byte-aligned; the Blue Book packs them contiguously.
The API is frame-oriented: compress_frame() → (payload bytes, CompressionStats)
and decompress_frame() → the exact uint16 DN frame. Segments default to one
block row (8 image lines) so packetization downstream maps segments to CCSDS space-packet
groups with line-accurate datation.
- class s2_msi_raw_generator.ccsds122.CompressionStats(raw_bytes, compressed_bytes, n_segments, n_blocks, pixel_bit_depth, dc_bytes, bitdepth_ac_bytes, ac_bytes, header_bytes)#
Bases:
objectByte accounting of one compressed frame.
- ac_bytes: int#
- bitdepth_ac_bytes: int#
- property bits_per_pixel: float#
Compressed bits per pixel.
- compressed_bytes: int#
- dc_bytes: int#
- header_bytes: int#
- n_blocks: int#
- n_segments: int#
- pixel_bit_depth: int#
- property ratio: float#
Raw ÷ compressed size (packed-sample raw,
pixel_bit_depthbits/px).
- raw_bytes: int#
- s2_msi_raw_generator.ccsds122.GAGGLE_BLOCKS = 16#
Blocks per gaggle (CCSDS 122.0-B §4.1).
- s2_msi_raw_generator.ccsds122.MAGIC = b'C122LSv1'#
Frame-stream magic (8 bytes) + container version.
- s2_msi_raw_generator.ccsds122.compress_frame(dn, *, pixel_bit_depth=12, segment_blocks=None)#
Losslessly compress a 2-D DN frame →
(payload, stats).segment_blocksdefaults to one block row (width // 8), aligning each segment with 8 image lines. The payload is fully self-describing (see module docstring).- Return type:
tuple[bytes,CompressionStats]
- s2_msi_raw_generator.ccsds122.decompress_frame(payload)#
Exact inverse of
compress_frame()→uint16frame.- Return type:
ndarray
- s2_msi_raw_generator.ccsds122.dwt97m_forward(frame, levels=3)#
3-level (default) 2-D integer 9/7-M DWT → subband dict
LL<n>, HL<l>, LH<l>, HH<l>.Rows are transformed first, then columns; the recursion descends into
LL. Input dimensions must be divisible by2**levels.- Return type:
dict[str,ndarray]
- s2_msi_raw_generator.ccsds122.dwt97m_inverse(bands, levels=3)#
Exact inverse of
dwt97m_forward().- Return type:
ndarray
- s2_msi_raw_generator.ccsds122.parse_segment_headers(payload)#
Header inventory of a compressed stream (frame fields + per-segment Part-1A content).
- Return type:
dict
- s2_msi_raw_generator.ccsds122.segment_byte_bounds(payload)#
Byte offsets of packetization groups:
[0, seg1_off, …].The frame header is folded into the first group, so joining the groups reproduces the exact stream — the property
decompress_frame()needs after packet reassembly.- Return type:
list[int]
s2_msi_raw_generator.datation module#
GPS/OBT line datation for the L0 ISP — OBT/GPS/TAI line timing (ADF_DATAT model).
Replaces the placeholder t0 = 0 CUC time with a Sentinel-2 on-board time derived from a
acquisition epoch. The MSI line-datation model (ADF_DATAT) is
t(line, band) = epoch + line · line_period + time_shift(band)
expressed here on the GPS timescale (the S2 on-board clock is GPS-derived; the 32-bit CUC coarse
field holds the GPS second-of-epoch). UTC and TAI are offered for the product metadata
(orbit_ephemeris carries TAI/UTC/UT1). msi-processor does not consume the CUC time for L1B, but
EOPF EOQC’s Datation_Sync / Time_Correlation checks read the datation metadata.
- class s2_msi_raw_generator.datation.Datation(epoch_utc='2024-04-03T10:24:15Z', line_period_s=0.0015658736, time_shift_s=<factory>)#
Bases:
objectLine-datation model for one acquisition (the ADF_DATAT parameters we need for the Synthetic L0).
- band_time_stamp()#
Per-band first-line GPS time stamp for the Synthetic L0
other_metadata(band number → {unit, value}).- Return type:
dict[str,dict]
- property epoch: datetime#
- epoch_utc: str = '2024-04-03T10:24:15Z'#
- property gps_epoch_s: float#
GPS second-of-epoch of the first line (the CUC/OBT
t0).
- line_period_s: float = 0.0015658736#
- line_time_gps(line, band=None)#
GPS seconds of
line(optionally with the per-band along-tracktime_shift).- Return type:
float
- line_time_utc(line, band=None)#
- Return type:
datetime
- span_utc(n_lines)#
(start, end)UTC ISO for the acquisition (first → last line).- Return type:
tuple[str,str]
- time_shift_s: dict[str, float]#
- s2_msi_raw_generator.datation.utc_to_gps_seconds(utc)#
Seconds on the GPS timescale since the GPS epoch (1980-01-06T00:00:00).
- Return type:
float
s2_msi_raw_generator.env module#
Environment configuration — single source of truth from .env.
All pipeline scripts load the repository .env at startup. Required path variables must be
set; there are no silent fallbacks to ~/data-store or baked-in defaults.
- s2_msi_raw_generator.env.adf_eopf_dir()#
- Return type:
Path
- s2_msi_raw_generator.env.aux_dir()#
- Return type:
Path
- s2_msi_raw_generator.env.ensure_repo_on_path()#
Allow
python scripts/run_pipeline.pywithout a priorpip install -e.- Return type:
None
- s2_msi_raw_generator.env.env_int(name, default)#
- Return type:
int
- s2_msi_raw_generator.env.find_adf_eopf(adf_type)#
First
S0*_ADF_<adf_type>_*.jsonunder{S2_AUX_DIR}/adf-eopf.- Return type:
str|None
- s2_msi_raw_generator.env.find_framing_table()#
First
framing_lines_*.jsonunder{S2_AUX_DIR}/framing.- Return type:
Path|None
- s2_msi_raw_generator.env.framing_dir()#
- Return type:
Path
- s2_msi_raw_generator.env.gipp_dir()#
- Return type:
Path
- s2_msi_raw_generator.env.init_env(*, require=False)#
Load
.envand optionally enforce required path variables.- Return type:
None
- s2_msi_raw_generator.env.l0_input()#
- Return type:
Path
- s2_msi_raw_generator.env.l1b_input()#
- Return type:
Path
- s2_msi_raw_generator.env.load_dotenv(path=None)#
Load
KEY=VALUElines from.envintoos.environ(does not override existing).- Return type:
None
- s2_msi_raw_generator.env.output_dir()#
- Return type:
Path
- s2_msi_raw_generator.env.require_paths()#
Exit if any mandatory path variable is unset.
- Return type:
None
- s2_msi_raw_generator.env.store_paths()#
Pipeline store layout under
OUTPUT_DIR.- Return type:
dict[str,Path]
s2_msi_raw_generator.forward_radiometric_atbd module#
Original implementation of the Sentinel-2 on-ground radiometric model (public L1 ATBD §4.1.1).
Given the per-pixel GIPP coefficients (s2_msi_raw_generator.gipp.DetectorEq), this provides the forward
radiometric correction (raw L1A → corrected L1B) and its exact inverse (the reverse-E2ES impress
step). The model is the published one:
Z = X − D (dark-signal subtraction; D = COEFF_D)
Y = G(Z) with G = A·Z³+B·Z²+C·Z (VNIR cubic relative-response)
or A1·Z | A2·Z+(A1−A2)·Zs (SWIR bilinear, knee at Z = Zs)
Everything is written from the public ATBD equations; no processor source is used. Coefficients are
per across-track pixel (act) and broadcast over image lines.
- s2_msi_raw_generator.forward_radiometric_atbd.column_fpn(frame, smooth=31)#
High-frequency across-track fixed-pattern-noise metric: std of (column means − running mean).
A PRNU-striped image has higher column FPN before equalization than after.
- Return type:
float
- s2_msi_raw_generator.forward_radiometric_atbd.forward_correct(x_raw, eq)#
Forward radiometric correction L1A→L1B:
Y = G(X − D)(subtract dark, equalize).- Return type:
ndarray
- s2_msi_raw_generator.forward_radiometric_atbd.forward_equalize(z, eq)#
Apply the relative-response model
Y = G(Z)(dark-subtracted signal Z → equalized Y).- Return type:
ndarray
- s2_msi_raw_generator.forward_radiometric_atbd.inverse_equalize(y, eq, iters=6)#
Invert
G: equalized Y → dark-subtracted signal Z (impress the per-pixel relative response).- Return type:
ndarray
- s2_msi_raw_generator.forward_radiometric_atbd.reapply_onboard_eq(z_signal, reob2)#
S12 — re-apply the on-board bilinear equalization non-linearity (undo forward step 1).
The forward
inverse_equalizationun-does the on-board compandingZ = Y/a1 | (Y−(a1−a2)zs)/a2(X = Z + d); its E2ES re-application on the dark-subtracted signalz(downlink- consistent) isy = a1·zforz ≤ zselsea2·z + (a1−a2)·zs(REOB2a1,a2,zs). The REOB2 darkd(raw-detector domain, ≈455 DN) cancels the raw-detector dark and is not re-added here — the downlink L0 pedestal is the separatel0_dark_levelterm. For S2Ba1≈1.005, a2≈0.995so this is a sub-percent gain with a knee atz ≈ zs.- Return type:
ndarray
- s2_msi_raw_generator.forward_radiometric_atbd.restage_swir_lines(img, shifts, kernel=None, method='shift')#
S8 — re-introduce the SWIR staggered readout (reverse of the forward re-arrangement).
shifts[c] ∈ {−1, 0, +1}flags which across-track columns move and the direction.method"shift"(B11/B12) rolls the flagged column by ±1 whole line;"interp"(B10) applies the ±1/3-line sub-pixel shift as a 3-tapkernelconvolution (kernelfor +, reversed for −; lossy — the forward convolution is not exactly invertible). Along-track roll commutes with the per-column radiometric ops, so the exact placement in the chain is not critical.shiftsis centre-aligned to the frame width (SWIR blind columns sit at the edges).- Return type:
ndarray
- s2_msi_raw_generator.forward_radiometric_atbd.reverse_impress(y, eq)#
Reverse (E2ES) of
forward_correct(): corrected L1B → raw L1AX = G⁻¹(Y) + D.- Return type:
ndarray
- s2_msi_raw_generator.forward_radiometric_atbd.reverse_l1b_to_l0(l1b_dn, eq, *, radio_offset_l1b, l0_dark_level, unbin_factor=1, dn_max=4095, swir_shift=None, defective_cols=None, onboard_eq=None, nodata=0.0)#
E2ES reverse of the L0→L1B radiometric chain: S2 L1B digital counts → Synthetic L0 raw uint16 DN.
The SentiWiki / EOPF forward chain (ON steps) is
on-board-eq⁻¹(REOB2) → dark → blind → crosstalk(RCRCO) → relative-response(REQOG) → SWIR-rearr(RSWIR) → defective(RDEPI) → restoration[OFF] → binning → +RADIO_ADD_OFFSET. This inverts it in the downlink DN domain (where both S2 L1B and L0 live), undoing the steps in reverse order:z = G⁻¹(L1B + radio_offset_l1b) # undo offset (S4) + impress relative response (S7) z = a·z (on-board-eq non-linearity) # S12 — undo REOB2 (a1≈a2≈1, sub-percent; optional) raw = z + l0_dark_level · (D/⟨D⟩) # S11 — L0-domain dark pedestal; DSNU shape from COEFF_D raw = repeat(raw, ×unbin_factor) # S5 — un-bin (60 m); replication (sub-pixel irrecoverable) raw = restage_swir_lines(raw, swir_shift) # S8 — re-introduce the staggered SWIR readout raw[:, defective_cols] = nodata # S10 — re-stamp the RDEPI defective columns
Dark is the downlink-domain
l0_dark_level(≈50 DN), not the raw-detectoreq.dark(COEFF_D ≈440 DN, a different domain — its ≈455 DN cancels the REOB2d, so REOB2 contributes only itsa1/a2non-linearity here);eq.darksupplies only the unitless DSNU column shape.Skipped — deliberately: MTF restoration/deconvolution and de-noising (forward step 8) are
feature_flag_with_deconvolution/denoising = Falsein the operational payload (SentiWiki: “restoration disabled by default — instrument MTF already high”), so the L1B still carries the full instrument PSF and its noise realisation; re-blurring (S6 PSF) or re-noising (S13) would double-count and is not applied. Crosstalk (S9, RCRCO) is applied at the phase level (needs the neighbouring same-resolution bands; ≈0 for S2A/B).Optional full-chain inputs (all
None→ the validated radiometric-only reverse):swir_shift=(shifts, kernel, method)fromread_rswir_eopf();defective_cols= RDEPIsingularity_columns;onboard_eq= REOB2 coefficients fromread_reob2_eopf(). Validated against the 2024-04-08 S2B PPB pair (all 13 bands): median ≤~5 %, active-region column FPN matches, CCSDS-122/ISP round-trip bit-exact; S8 brings the SWIR (B11/B12) images into spatial agreement.- Return type:
ndarray
s2_msi_raw_generator.gipp module#
Reader for Sentinel-2 operational GIPP calibration data.
Production layout: band-organised JSON under S2_GIPP_DIR (aux/gipp-json/{Bxx}/S2B_ADF_*.json),
each file wrapping the documented GS2_* schema (XML-to-JSON conversion). Unit tests may still
use flat XML fixtures (*GIP_*.xml).
The parser turns GIPP into per-band / per-detector / per-pixel numpy arrays the reverse chain needs:
R2EQOG — the on-ground equalization file: per-pixel dark signal
Dand the relative-response (PRNU) gains — VNIR cubic(A, B, C)(Y = A·Z³+B·Z²+C·Z) or SWIR bilinear(A1, A2, Zs)(Y = A1·ZforZ≤ZselseA2·Z+(A1−A2)·Zs). Per the public Sentinel-2 L1 ATBD §4.1.1,C(VNIR) /A1(SWIR) is the dominant per-pixel relative gain.R2DEPI — defective (saturated + blind) pixel columns per band/detector.
BLINDP — blind-pixel column lists per band/detector/side.
R2PARA — per-band equalization/offset flags + radiometric offsets (−100 L1B / −1000 L1C).
R2CRCO / R2BINN — crosstalk matrix (≈0 for S2A) / 60 m binning kernel.
Band order follows sensor.BANDS (band_id 0…12 → B01…B08, B8A, B09…B12).
- class s2_msi_raw_generator.gipp.BandEq(band, tdi, detectors, source='GIPP R2EQOG')#
Bases:
object- band: str#
- detectors: dict[int, DetectorEq]#
- property npix: int#
- source: str = 'GIPP R2EQOG'#
- tdi: bool#
- class s2_msi_raw_generator.gipp.DetectorEq(model, dark, coeffs)#
Bases:
objectPer-detector R2EQOG coefficients (each array is per across-track pixel, length NB_OF_PIXELS).
- coeffs: dict[str, ndarray]#
- dark: ndarray#
- model: str#
- property rel_gain: ndarray#
C for VNIR, A1 for SWIR.
- Type:
Dominant per-pixel relative-response (PRNU) gain
- class s2_msi_raw_generator.gipp.GippSet(gipp_dir, equalization=<factory>, defective=<factory>, blind=<factory>, params=None, crosstalk=<factory>, crosstalk_matrix=None, rswir_adf=None, reob2_adf=None)#
Bases:
objectAll GIPP calibration data needed by the reverse chain, parsed from
gipp_dir.The full-reverse extras (
rswir_adf= SWIR shift map,reob2_adf= on-board equalization) are EOPF ADF json paths read lazily throughswir_shift()/onboard_eq()— those ADFs are large (REOB2 ≈ 125 MB) and only a handful of (band, detector) pairs are ever needed.- blind: dict[str, dict[int, ndarray]]#
- crosstalk: dict[str, ndarray]#
- crosstalk_matrix: ndarray | None = None#
- defective: dict[str, dict[int, ndarray]]#
- gipp_dir: str#
- onboard_eq(band, detector)#
On-board-equalization coefficients (REOB2) for a (band, detector), or
None.- Return type:
dict[str,ndarray] |None
- params: RadioParams | None = None#
- reob2_adf: str | None = None#
- rswir_adf: str | None = None#
- swir_shift(band, detector)#
SWIR staggered-readout shift map for a (band, detector), or
Noneif unavailable.- Return type:
tuple[ndarray,ndarray,str] |None
- class s2_msi_raw_generator.gipp.RadioParams(radiance_offset_l1b, reflectance_offset_l1c, equalization_flag, offset_flag, dsnu_flag)#
Bases:
object- dsnu_flag: dict[str, bool]#
- equalization_flag: dict[str, bool]#
- offset_flag: dict[str, bool]#
- radiance_offset_l1b: dict[str, int]#
- reflectance_offset_l1c: dict[str, int]#
- s2_msi_raw_generator.gipp.load_gipp_set(gipp_dir, bands=('B01', 'B02', 'B03', 'B04', 'B05', 'B06', 'B07', 'B08', 'B8A', 'B09', 'B10', 'B11', 'B12'), eqog_adf=None, rswir_adf=None, reob2_adf=None, rcrco_adf=None)#
Load the full GIPP set from
gipp_dir(JSONgipp-jsonor XML test fixtures).Equalization comes from
gipp_dir(ADF_REQOGper band). Optionaleqog_adfforces EOPFdata_varsREQOG (V&V only — not used by the production pipeline).rswir_adf/reob2_adf/rcrco_adfenable the full reverse chain (S8/S9/S12) fromadf-eopf.- Return type:
- s2_msi_raw_generator.gipp.parse_eqog_adf_epoch(adf_path)#
Parse EOPF ADF filename (
S2A_ADF_REQOG_<start>_<stop>_<creation>.json, or the PSFD platform spellingS02B_ADF_…) → its applicability-start / validity-stop / creation epochs (UTC ISO). Empty dict if it doesn’t match.platformis normalized toS2x.- Return type:
dict
- s2_msi_raw_generator.gipp.read_blindp(gipp_dir)#
Blind-pixel 0-based column indices (valid ∪ non-valid, both sides), per band → detector.
- Return type:
dict[str,dict[int,ndarray]]
- s2_msi_raw_generator.gipp.read_blindp_json(gipp_dir)#
- Return type:
dict[str,dict[int,ndarray]]
- s2_msi_raw_generator.gipp.read_r2crco(gipp_dir)#
Per-band crosstalk row (OPTICAL+ELECTRICAL summed). ≈0 for S2A.
- Return type:
dict[str,ndarray]
- s2_msi_raw_generator.gipp.read_r2crco_json(gipp_dir)#
- Return type:
dict[str,ndarray]
- s2_msi_raw_generator.gipp.read_r2depi(gipp_dir)#
Defective (saturated ∪ blind) 0-based across-track column indices, per band → detector.
- Return type:
dict[str,dict[int,ndarray]]
- s2_msi_raw_generator.gipp.read_r2depi_json(gipp_dir)#
- Return type:
dict[str,dict[int,ndarray]]
- s2_msi_raw_generator.gipp.read_r2eqog_band(gipp_dir, band)#
Parse per-band equalization (JSON
gipp-jsonor XML fixture layout).- Return type:
- s2_msi_raw_generator.gipp.read_r2eqog_eopf(adf_json_path, band)#
Parse EOPF
ADF_REQOG(S2[AB]_ADF_REQOG_…json) → the sameBandEqas the XMLread_r2eqog_band().The EOPF ADF stores, per band
<b>(lower-case;b8afor B8A) over 12 detectors: VNIR cubic<b>/coeff_{a,b,c}(→A,B,C) or SWIR bilinear<b>/coeff_{a1,a2,zs}(→A1,A2,Zs), plus<b>/coeff_d= dark signal.coeff_dmay carry along-track sub-lines ((det, n_line, act)); these are collapsed to their mean, matching the XMLCOEFF_Dconvention in_parse_coeff_d().- Return type:
- s2_msi_raw_generator.gipp.read_r2eqog_json(gipp_dir, band)#
Parse per-band
ADF_REQOGJSON →BandEq.- Return type:
- s2_msi_raw_generator.gipp.read_r2para(gipp_dir)#
- Return type:
- s2_msi_raw_generator.gipp.read_r2para_json(gipp_dir)#
- Return type:
- s2_msi_raw_generator.gipp.read_rcrco_eopf(adf_json_path)#
EOPF
ADF_RCRCO→ the combined crosstalk matrixdtalk[k, l](optical + electrical).13×13overband_k(impacted) ×band_l(impacting), insensor.BANDSorder. The forward correction isX_corr_k = X_k − Σ_l dtalk[k,l]·X_l; the E2ES re-application adds it back. For S2A/B optical is exactly 0 and electrical peaks at ≈0.004 — a near-no-op kept for completeness.- Return type:
ndarray
- s2_msi_raw_generator.gipp.read_reob2_eopf(adf_json_path, band, detector, table='new_table')#
EOPF
ADF_REOB2→ per-pixel on-board-equalization coefficients for oneband/detector.The forward L1B step 1 (
inverse_equalization) undoes the on-board bilinear equalization:Z = where(Y ≤ a1·zs, Y/a1, (Y − (a1−a2)·zs)/a2); X = Z + d
so the E2ES re-application (raw X → downlink Y) is
Y = where(Z ≤ zs, a1·Z, a2·Z + (a1−a2)·zs)withZ = X − d(seereapply_onboard_eq()). Returns{a1, a2, zs, d}each(act,)from<band>/<table>/coeff_*(default the operationalnew_table). For S2Ba1≈1.005, a2≈0.995(near-unity slope) andd≈455(the raw-detector dark, i.e. the R2EQOG COEFF_D domain).- Return type:
dict[str,ndarray]
- s2_msi_raw_generator.gipp.read_rswir_eopf(adf_json_path, band, detector)#
EOPF
ADF_RSWIR→ the SWIR staggered-readout shift map for oneband/detector.Returns
(shifts, kernel, method):shifts— per across-track column integer flag in{−1, 0, +1}(swir_band_list/ swir_band/detectorat[band, detector, :]). The sign marks which columns move and the direction; the magnitude is set bymethod(whole line vs sub-pixel).kernel— theinterpolation_filter/coefs3-tap sub-pixel filter (only used for B10).method—"shift"(B11/B12: ±1 whole-line roll) or"interp"(B10: ±1/3 line viakernelconvolution).
Only B10/B11/B12 have an entry;
bandmust be one of those (elseKeyError).- Return type:
tuple[ndarray,ndarray,str]
- s2_msi_raw_generator.gipp.temporal_validity(adf_epoch, acquisition_utc, warn_years=1.0)#
Check an ADF’s temporal applicability to an acquisition.
within_validityuses the ADF’s declared[applicability_start, valid_stop]window — but note the2100stop is an open-ended placeholder, not proof of applicability. So the operational signal isgap_years=|acquisition − applicability_start|: the satellite’s radiometric state drifts (monthly-refreshed R2EQOG/R2ABCA), and a stale ADF is flagged (warn) when the gap exceedswarn_yearsor the acquisition falls outside the declared window.- Return type:
dict
s2_msi_raw_generator.import_l0 module#
Import a public distribution L0 image product as a PDI-style L1A input.
The bridge does not synthesize data: it copies one public detector’s per-band DN arrays into the
measurements/DD01/Bxx/l1a_raw_image layout consumed by the pipeline, preserving acquisition
identity in root STAC metadata and recording per-band provenance.
- s2_msi_raw_generator.import_l0.convert(public_l0_path, out_dir, *, detector=1, bands=None, jobs=1)#
Convert one public L0 detector into a PDI-style L1A Zarr directory.
jobsis accepted for pipeline symmetry; writes stay serial so memory remains bounded by one band array at a time.- Return type:
dict
- s2_msi_raw_generator.import_l0.read_public_l0_identity(public_l0_path)#
Read acquisition identity from a public L0 Zarr/Zip product.
- Return type:
dict
- s2_msi_raw_generator.import_l0.write_l1a_product(out_path, frames, *, datation=None, platform='Sentinel-2B', orbit=None, footprint=None, source_l1b=None, provenance=None)#
Write reconstructed raw-DN frames as EOPF L1A product (multi-detector).
This is the reverse chain’s materialised intermediate (
reverse-l1bphase): the S2 L1B → Synthetic L0 reconstruction stops at the raw-count L1A domain and persists it before the separate L1A→Synthetic L0 packaging (CCSDS/ISP) step.framesmaps(detector 1–12, band "B03")to auint16(line, column)raw-DN array; each is written tomeasurements/DD{dd}/{BAND}/l1a_raw_image(the layoutread_l1a_raw()consumes) and re-read to assert a bit-identical round-trip. Writes stay serial (one array at a time) — parallel writes corrupt the measurements arrays on the NFS-backed data-store.Root attrs carry an L1A STAC identity (
processing:level=L1A,product:type=S02MSIL1A) plus the reverse provenance. Returns a summary dict (output path, per-frame shape/crc32).- Return type:
dict
s2_msi_raw_generator.inventory module#
Data-store inventory and consistency reporting.
The scanner is intentionally metadata-only: it walks the store layout, reads Zarr attributes from
directories or .zarr.zip archives with stdlib JSON/zipfile support, and classifies products
without loading image arrays.
- s2_msi_raw_generator.inventory.assign_groups(records)#
Annotate records with acquisition grouping and comparability hints.
- Return type:
None
- s2_msi_raw_generator.inventory.consistency_findings(records, store)#
Build the consistency finding list described in the plan.
- Return type:
list[dict]
- s2_msi_raw_generator.inventory.parse_name_lenient(name)#
Parse PSFD names first, then known public/PSD-like names.
- Return type:
dict
- s2_msi_raw_generator.inventory.read_zarr_identity(path)#
Read best-effort acquisition identity from a Zarr directory or zip archive.
- Return type:
dict
- s2_msi_raw_generator.inventory.render_markdown(records, findings)#
- Return type:
str
- s2_msi_raw_generator.inventory.scan_store(store)#
Return inventory records for the known data-store layout.
- Return type:
list[dict]
- s2_msi_raw_generator.inventory.write_outputs(store)#
Scan
storeand writeINVENTORY.mdplusreport/inventory.json.- Return type:
dict
s2_msi_raw_generator.io module#
Lightweight reader for Sentinel-2 EOPF L1A/L1B Zarr products (no full eopf dependency).
Reads the per-detector / per-band radiance arrays straight from the Zarr store with zarr.
The full EOPF CPM (eopf==2.8.1, optional extra) is only needed to write spec-compliant Synthetic L0
EOProducts (Increment 2) and for the round-trip against the pinned msi-processor.
- s2_msi_raw_generator.io.read_l1a_raw(path, detector, band, *, lines=None, dtype=<class 'numpy.float64'>)#
Read one detector/band raw-DN array from EOPF L1A product.
L1A uses
measurements/DD{dd}/B{band}/l1a_raw_image(uppercaseDDnn/Bxx, image namel1a_raw_image) — the raw instrument counts (with dark + PRNU still present), unlike the L1A/L1B radiance readerread_l1b_band()(lowercasedDD/bXX/img).dtypedefaults to float64 (the round-trip V&V convention); passnp.uint16to read full frames without the 4× float64 memory spike, orNonefor the stored dtype.- Return type:
ndarray
- s2_msi_raw_generator.io.read_l1b_band(path, detector, band, *, lines=None)#
Read one detector/band radiance array from an L1A/L1B product.
- Parameters:
path (
str)detector (
int)band (
str)lines (
slice|None)
- Return type:
ndarray
s2_msi_raw_generator.isp module#
S15 — CCSDS Instrument Source Packet (ISP) generation + SAD telemetry.
Implements the ATBD §5.S15 step: package the Synthetic L0 detector/band frames into CCSDS Space
Packets (CCSDS 133.0-B primary header + a CUC secondary time header), and generate Satellite
Ancillary Data (SAD) packets per APID. Timestamps derive from sensor.LINE_PERIOD_MS.
Layout (ATBD Annex A.9):
measurements/d{DD}/b{BB}/{isp, isp_offsets, packet_data_length}— CCSDS space packets whose payloads carry the CCSDS-122-compressed image data (moduleccsds122; ICD-IF-ISP).conditions/anc_data/s{APID}/isp+packet_data_length— SAD/housekeeping telemetry.
- s2_msi_raw_generator.isp.DEFAULT_MAX_PAYLOAD = 8192#
Default maximum packet-data-field payload (octets, excl. the CUC secondary header).
- s2_msi_raw_generator.isp.apid_for(detector, band_index, base=1024)#
Deterministic 11-bit APID for a (detector, band) science stream.
- Return type:
int
- s2_msi_raw_generator.isp.build_primary_header(apid, seq_count, data_len, *, packet_type=0, sec_hdr_flag=1, seq_flags=3, version=0)#
CCSDS 133.0-B 6-octet primary header (big-endian).
data_lenis the CCSDS Packet Data Length field = (octets in the packet data field) − 1.- Return type:
bytes
- s2_msi_raw_generator.isp.build_sad_packets(apid, n_packets, *, t0_seconds=0.0, period_s, payload=b'\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00')#
Satellite Ancillary Data (housekeeping) packets for one APID.
Returns
(isp, packet_data_length):ispis uint8(n_packets, header+payload),packet_data_lengthis uint16(n_packets,).- Return type:
tuple[ndarray,ndarray]
- s2_msi_raw_generator.isp.cuc_time(t_seconds)#
6-octet CCSDS Unsegmented time Code: 4 octets coarse (s) + 2 octets fine (1/65536 s).
- Return type:
bytes
- s2_msi_raw_generator.isp.frame_isp_headers(frame, apid, *, t0_seconds=0.0, line_period_s)#
Legacy per-line ISP headers (superseded by
packetize_stream()in the canonical Synthetic L0).Returns
(headers, packet_data_length):headers— uint8(n_lines, ISP_HEADER_LEN)primary + CUC-time header per line.packet_data_length— uint16(n_lines,)octet count of each packet’s data field (= CUC secondary header + one image line of uint16 samples).
- Return type:
tuple[ndarray,ndarray]
- s2_msi_raw_generator.isp.iter_packets(buf)#
Iterate CCSDS packets in a concatenated stream →
(header dict, cuc_seconds, body bytes).Shared by our reassembly and by structural scans of ISP files: the primary header’s Packet Data Length field walks the stream; a stream is well-formed iff packets tile it exactly.
bodyexcludes the CUC secondary header.
- s2_msi_raw_generator.isp.packetize_stream(payload, apid, *, segment_bounds, segment_times_gps, max_payload=8192, seq_start=0)#
Carry a byte stream in CCSDS space packets, honouring segment grouping.
segment_boundsare the byte offsets of segment starts withinpayload(ascending,segment_bounds[0] == 0); each segment is sliced into packets of at mostmax_payloaddata octets and flaggedSEQ_FIRST/SEQ_CONT/SEQ_LAST(SEQ_STANDALONEwhen a segment fits in one packet). Every packet carries a CUC secondary header stamped with its segment’s GPS time (segment_times_gps, seconds, one per segment). The 14-bit sequence counter starts atseq_startand is continuous across the whole stream.Returns
(isp, offsets, lengths): the concatenated packet stream (uint8 1-D), the byte offset of each packet within it (uint64), and each packet’s CCSDS Packet Data Length field value + 1 = data-field octet count (uint32).- Return type:
tuple[ndarray,ndarray,ndarray]
- s2_msi_raw_generator.isp.parse_cuc_time(b)#
Inverse of
cuc_time()— 6 octets → seconds (4-octet coarse + 2-octet fine / 65536).- Return type:
float
- s2_msi_raw_generator.isp.parse_primary_header(b)#
Inverse of
build_primary_header()(for tests / decode).- Return type:
dict
- s2_msi_raw_generator.isp.reassemble_segments(buf)#
Inverse of
packetize_stream(): packets → per-segment byte streams.Enforces seq_flags grammar (FIRST → CONT* → LAST, or STANDALONE) and 14-bit sequence-counter continuity; raises
ValueErroron gaps or malformed flag sequences.- Return type:
list[bytes]
s2_msi_raw_generator.l0product module#
Assemble a synthetic Synthetic L0 RAW EOProduct (Increment 2, ICD-IF-Synthetic L0).
Writes the EOPF L0 Zarr structure (ATBD Annex A.9 / REQ-FUNC-030–036):
measurements/d{DD}/b{BB}/band{BB} uint16 (line, column) — 12 det × 13 bands = 156 arrays quality/d{DD}/b{BB}/mask uint8 — 156 masks root attrs: stac_discovery (geometry/bbox/orbit/datetime) + other_metadata (sensor-config, datation)
Uses zarr (v2 format for EOPF/msi-processor interoperability), not the full eopf CPM. Line
timing / STAC datetime come from an Datation; when
with_isp is set the CCSDS ISP headers + conditions/anc_data/... SAD telemetry (S15) are written.
- s2_msi_raw_generator.l0product.build_root_metadata(*, platform='Sentinel-2A', datation=None, n_lines=1, active_detectors, footprint=None, orbit=None, eopf_type='S2MSIL0_', operation_mode='NOBS', datatake_type='INS-NOBS')#
Root STAC + sensor-configuration metadata (values; REQ-FUNC-033/034/035/038/048).
eopf_type/operation_mode/datatake_typeidentify the datatake kind (PSFD §3 vocabulary): nominal observationNOBS/INS-NOBS(default), dark calibrationDASC/INS-DASC(S2MSIDCA), sun-diffuser calibrationABSR/INS-ABSR(S2MSISCA) — see ICD-IF-L0-CAL for the source notes.- Return type:
dict
- s2_msi_raw_generator.l0product.decode_verify_band(canon_path, det, bname, l1a_path, line_slice)#
Ground-decode one band of a canonical Synthetic L0 and verify it against the original L1A DN.
Uses the CONSUMER decoder (msi-processor
ground_decode) when importable, cross-checked bit-exactly against the E2ES referenceread_l0_isp_dn(); falls back to the reference alone. Returns(reconstructed_dn, bit_exact_vs_original, cross_check)—cross_checkisNonewithout the consumer, else the decoder-agreement verdict. Module-level (picklable) so the driver’s ground-decode phase can fan bands out to a process pool (decompression is pure-Python/GIL-bound).- Return type:
tuple[ndarray,bool,bool|None]
- s2_msi_raw_generator.l0product.read_l0_isp_dn(path, det, bname)#
Ground decompression: canonical Synthetic L0 ISP stream → the exact uint16 DN frame.
The S2 L1B chain L1A-side operation (SentiWiki: decompression happens at L1A): read
measurements/d{DD}/b{BB}/isp, reassemble the packet groups (reassemble_segments()— seq_flags + continuity enforced), join them back into the CCSDS-122 stream and decode it bit-exactly.- Return type:
ndarray
- s2_msi_raw_generator.l0product.reverse_to_l0_frames(l1b_frames, *, seed=0, adfs=None, unit='S2A')#
Run the MVP reverse chain on each (detector, band) radiance frame → uint16 Synthetic L0 DN.
- Return type:
dict[tuple[int,str],ndarray]
- s2_msi_raw_generator.l0product.write_l0_decoded_product(out_path, frames, *, datation=None, platform='Sentinel-2B', orbit=None, footprint=None, eopf_type='S2MSIL0_')#
Write decoded raw-count frames as EOPF L0 product format-identical to the reference ESA L0-zarr layout.
The archived EOPF L0 stores the losslessly-decompressed counts as
measurements/d{DD}/b{BB}/img(uint16, dimsalt,act) plus per-band decode-quality group attrs (decoding_error_number,valid_line_percentage…), verified against theS02MSIL0__…TC7D.zarr. This makes the Synthetic L0 format-identical to the ESA product for a direct array comparison (validate-l0); the compressed as-downlinked transport lives in the sibling L0plus (write_l0_product()). Frames must be uint16(line, column).- Return type:
str
- s2_msi_raw_generator.l0product.write_l0_opencontainer(out_path, band_frames, *, masks=None, datation=None, platform='Sentinel-2A', footprint=None, orbit=None, emit_qc=True)#
Write the open-container L0 that
msi-processor’sl0_decodeingests (REQ-FUNC-042).Layout (the decoded form the processor reads directly, distinct from the canonical 156-array product):
measurements/detector/<BAND>uint16(line, detector),quality/l0_flags/<BAND>uint16 (QAFlagseed),conditions/{time,orbit,attitude}per-line telemetry, + shared root metadata. The hard invariant for the downstreamnucADF:nuc.gain[band]length must equalmeasurements/detector/<band>’s detector-axis width — so build the cal-DB at the samen_det.- Return type:
str
- s2_msi_raw_generator.l0product.write_l0_product(out_path, frames, *, platform='Sentinel-2A', datation=None, datetime_iso=None, masks=None, footprint=None, orbit=None, emit_qc=True, with_isp=False, isp_max_payload=8192, store_decoded=True, eopf_type='S2MSIL0_', operation_mode='NOBS', datatake_type='INS-NOBS', jobs=1)#
Write the Synthetic L0 RAW EOProduct Zarr to
out_pathand return it.Each frame must be uint16 in
[0, DN_MAX]. A quality mask is written per frame (saturated pixels flagged where DN ≥ DN_MAX if no explicit mask is given). The datation (Datation) sets the GPS/OBT line timing and the STAC datetime span;footprint/orbitset the STAC geometry/orbit (S2A defaults).datetime_isois a deprecated shortcut forDatation(epoch_utc=...).When
with_ispis set (S15) the band’s image data is CCSDS-122 lossless compressed (ccsds122) and carried as CCSDS space packets (packetize_stream(); codec segments = 8 image lines → line-accurate CUC datation;SEQ_FIRST/CONT/LASTgrouping) undermeasurements/d{DD}/b{BB}/{isp, isp_offsets, packet_data_length}; SAD telemetry goes toconditions/anc_data/s{APID}/isp. Achieved per-band compression ratios replace the staticcompression_ratemetadata. Withstore_decoded=Falsethe decodedband{BB}arrays are omitted — the product then stores ISPs only, mirroring the ESA S2 Synthetic L0 (SentiWiki: L0 = compressed ISPs; ground L1A decompresses viaread_l0_isp_dn()).jobs > 1fans the per-band CPU work (compression/packetization/SAD —_band_payload()) out to that many worker processes; the zarr writes stay in this process and the product is bit-identical to a serial run.- Return type:
str
s2_msi_raw_generator.metadata module#
Shared STAC / acquisition-identity metadata helpers.
Structure- and value-level parsing that io, naming, inventory and import_l0 all need
for the same ESA-product quirks (the doubled stac_discovery nesting, "null" datetimes,
unfilled orbit XPaths, and the data-take id orbit fallback). The helpers only normalise structure and
coerce values; the field-preference order (datetime vs start_datetime first, etc.) is left to
each caller because those orders are intentionally different.
Depends only on the standard library so it never introduces an import cycle within the package.
- s2_msi_raw_generator.metadata.DATATAKE_RE = re.compile('GS2[ABC]_[A-Z0-9_]+?_(?P<orbit>\\d{6})_N')#
Recovers the 6-digit absolute orbit embedded in EOPF data-take / datastrip id (
GS2A_..._<orbit>_N...).
- s2_msi_raw_generator.metadata.absolute_orbit_from_datatake_id(dtid)#
Recover the absolute orbit from EOPF data-take / datastrip id, or
None.- Return type:
int|None
- s2_msi_raw_generator.metadata.coerce_int(value)#
Coerce to
int; return(value, unparseable).A
Noneinput is treated as simply absent —(None, False)— whereas a present but non-integer value (e.g. an unfilled@@...@@XPath token) yields(None, True)so callers can distinguish “missing” from “malformed”.- Return type:
tuple[int|None,bool]
- s2_msi_raw_generator.metadata.normalise_stac(attrs)#
Flatten root attributes to
(stac, properties, flags).Handles the doubled
stac_discovery.stac_discoverynesting seen in some ESA products, recording"double_stac_discovery"inflagswhen it is collapsed. Always returns plain dicts, even when the metadata is missing or malformed.- Return type:
tuple[dict,dict,list[str]]
- s2_msi_raw_generator.metadata.parse_iso(value)#
Parse an ISO-8601 timestamp to an aware UTC datetime;
None/"null"yieldNone.- Return type:
datetime|None
s2_msi_raw_generator.naming module#
EOPF product file naming — the PSFD §3 product identification convention.
Implements the EOPF Product Structure and Format Definition (PSFD) §3 “Product Types and File Naming Rules” scheme:
MMMSSSCCC_YYYYMMDDTHHMMSS_UUUU_PRRR_XVVV[_Z*]
for the products this generator emits (Synthetic L0 RAW, plus the L1A / L1B / ISP / SAD context types).
ECSS-M-ST-40C requires a project-wide unique product identification coding system; this module is
that system for the reverse E2ES (the interface is recorded in docs/icd.md).
The grammar, field by field:
MMMSSSCCC— the 9-character type code (missionS02+ sensor + processing level); seeTYPE_CODES. A code ending in the pad character_yields the doubled underscore seen in operational product names, e.g.S02MSIL0__....YYYYMMDDTHHMMSS— acquisition start (UTC).UUUU— acquisition duration in whole seconds (4 digits,0001–9999).PRRR— platform unit letterP+ relative orbitRRR(001–143).XVVV— consolidation flagX+ a 3 hex-digit discriminatorVVV._Z*— an optional free token (segment / tile / role).
Examples (from the PSFD): S02MSIL0__20230216T182840_0001_A123_T000 and
S02MSIL1C_20230629T063559_0000_A064_T3A5.
- s2_msi_raw_generator.naming.CONSOLIDATION_FLAGS = frozenset({'S', 'T', '_'})#
Ttest /_nominal /Sspecial.- Type:
Valid consolidation flags (the
Xfield)
- s2_msi_raw_generator.naming.DEFAULT_START = datetime.datetime(2024, 4, 3, 10, 24, 15, tzinfo=datetime.timezone.utc)#
Fallback acquisition context used by
from_l1a_context()when L1A metadata is missing (the datation reference epoch, relative orbit 45, unitA).
- s2_msi_raw_generator.naming.EXTENSIONS = frozenset({'', '.zarr', '.zarr.zip'})#
Permitted product file extensions.
- s2_msi_raw_generator.naming.TYPE_CODES: dict[str, str] = {'S02MSIDCA': 'MSI dark calibration (DASC)', 'S02MSIISP': 'MSI CCSDS instrument source packets', 'S02MSIL0_': 'MSI Level-0 RAW', 'S02MSIL1A': 'MSI Level-1A', 'S02MSIL1B': 'MSI Level-1B', 'S02MSIL1C': 'MSI Level-1C', 'S02MSIL2A': 'MSI Level-2A', 'S02MSISCA': 'MSI sun-diffuser calibration (ABSR)', 'S02SADISP': 'Satellite ancillary data packets'}#
The 9-character product type codes emitted by this generator (PSFD §3), mapped to a short label. The trailing
_is the PSFD pad character (used when the level mnemonic is shorter than the 3-characterCCCfield), so e.g.S02MSIL0_plus the_field separator readsS02MSIL0__.
- s2_msi_raw_generator.naming.acquisition_context(attrs)#
Extract acquisition identity fields from L1A/L1B root attributes.
Reads the acquisition start, relative orbit and platform unit from the STAC discovery metadata (
attrs["stac_discovery"]["properties"]). Any field that is absent falls back to a module default (DEFAULT_START,DEFAULT_RELATIVE_ORBIT,DEFAULT_UNIT) and is recorded under"derived_from_defaults".- Return type:
dict
- s2_msi_raw_generator.naming.from_l1a_context(attrs, *, n_lines, line_period_s, product_type, ext='.zarr')#
Derive a product name from an L1A / L1B product’s root attributes.
Reads the acquisition context via
acquisition_context()and computes the duration asn_lines * line_period_s.- Parameters:
attrs (
dict) – The source product’s root attributes (STAC discovery under"stac_discovery").n_lines (
int) – Number of acquired lines (sets the duration together withline_period_s).line_period_s (
float) – Line period in seconds.product_type (
str) – The 9-character type code for the derived product (seeTYPE_CODES).ext (
str) – File extension. Default".zarr".
- Returns:
The product name and an info dict
{"derived_from_defaults": [field, ...]}naming each STAC field that fell back to a default.- Return type:
tuple[str,dict]
- s2_msi_raw_generator.naming.parse_psfd_name(name)#
Parse a PSFD §3 product file name into its fields — the inverse of
psfd_name().- Parameters:
name (
str) – A product file name, with or without an extension and with or without the optional_<z_suffix>token.- Returns:
Keys
product_type,start_utc(a naivedatetime.datetimeon UTC),duration_s(int),unit,relative_orbit(int),consolidation,discriminator,z_suffix(strorNone) andext.- Return type:
dict- Raises:
ValueError – If
namedoes not match the grammar or carries an unknown type code.
- s2_msi_raw_generator.naming.psfd_name(product_type, start_utc, duration_s, *, unit='A', relative_orbit, consolidation='T', discriminator=None, z_suffix=None, ext='.zarr')#
Build a PSFD §3 product file name.
- Parameters:
product_type (
str) – The 9-character type code; must be one ofTYPE_CODES.start_utc (
datetime) – Acquisition start time. Timezone-aware values are converted to UTC; naive values are formatted as given.duration_s (
float) – Acquisition duration in seconds, rounded half-up to a whole second and clamped to a minimum of 1. A value rounding above 9999 is rejected.unit (
str) – Platform unit — a single upper-case letter (thePfield). Default"A".relative_orbit (
int) – Relative orbit number in 1..143 (the Sentinel-2 repeat cycle).consolidation (
str) – Consolidation flag (theXfield); one ofCONSOLIDATION_FLAGS. Default"T".discriminator (
str|None) – Three upper-case hex characters (theVVVfield). WhenNone(default) a deterministic value is derived from the other fields viazlib.crc32.z_suffix (
str|None) – Optional free token appended as_<z_suffix>(letters, digits and underscores only).ext (
str) – File extension; one ofEXTENSIONS. Default".zarr".
- Returns:
The composed product file name.
- Return type:
str- Raises:
ValueError – If any field is invalid (unknown type code, out-of-range orbit or duration, or a malformed unit, consolidation, discriminator,
z_suffixor extension).
Examples
>>> from datetime import datetime >>> psfd_name("S02MSIL0_", datetime(2024, 4, 3, 10, 24, 15), 33.0, ... relative_orbit=45, discriminator="1A2") 'S02MSIL0__20240403T102415_0033_A045_T1A2.zarr'
s2_msi_raw_generator.quality module#
L0 quality flags — msi-processor QAFlag-compatible seeds + Sentinel-2 MSK_QUALIT masks.
Two representations, one source of truth:
l0_flags()→uint16bitmask using the same bit values as the processor’smsi_processor.computing.common.types.QAFlag(NO_DATA=1,LOST_PACKET=2,SATURATED=4,DEFECTIVE=8), so the processor’s monotone OR-accumulation picks up the generator’s seeds directly (the open-containerquality/l0_flags/<band>).to_msk_qualit()→uint8in the Sentinel-2MSK_QUALIT8-bit-plane layout (the canonical Synthetic L0 product maskquality/d{DD}/b{BB}/mask).
- s2_msi_raw_generator.quality.from_s10_qa(qa_s10)#
Map the low-level
reverse.s10_inject_defectsqa (bit0 = dead, bit1 = hot) to QAFlag bits.- Return type:
ndarray
- s2_msi_raw_generator.quality.l0_flags(dn, *, dead_cols=(), hot_pixels=(), dn_max=4095)#
QAFlag-compatible (
uint16) L0 quality seed for one(line, detector)frame.Flags
SATURATEDwhereDN ≥ dn_max,NO_DATAwhereDN == DN_NODATA(0),DEFECTIVEondead_cols/hot_pixels, andLOST_PACKETon wholly-zero trailing lines (the processor’s line-loss convention).- Return type:
ndarray
- s2_msi_raw_generator.quality.to_msk_qualit(qa)#
Translate a QAFlag
uint16seed to the S2MSK_QUALITuint88-bit-plane mask.- Return type:
ndarray
s2_msi_raw_generator.quality_report module#
EOPF EOQC-style per-product quality report (machine-readable OK/KO JSON).
Emulates the minimum content of the EOPF eopf.qualitycontrol (EOQC) per-product report — product
name/type, station, facility, sensing times, orbit numbers, an overall flag and a per-check list —
self-asserted from the Synthetic L0 product the generator just wrote. Pure json/stdlib (no eopf dependency);
when the EOQCProcessor is available (in the SDE) it may additionally be run. ECSS-Q-ST-20C.
- s2_msi_raw_generator.quality_report.build_qc_report(root_metadata, *, product_name, software_version='0.3.0', inspection_time=None, has_measurements=True)#
Build the EOQC per-product QC report from the L0 root metadata. Overall
OKiff all checks pass.- Return type:
dict
- s2_msi_raw_generator.quality_report.write_qc_report(path, report)#
Write the QC report as standalone JSON (
QC_report_{name}.jsonconvention). Returns the path.- Return type:
Path
s2_msi_raw_generator.quicklook module#
Dependency-free quicklook PNG writer for the L0 / L1B products (numpy + stdlib only).
Renders a small RGB preview of a {band: 2-D array} product with a per-channel percentile contrast
stretch — for the repo README / documentation front page. No matplotlib / PIL dependency (a minimal PNG
encoder using zlib + struct), so it runs anywhere the generator does, including the SDE.
- s2_msi_raw_generator.quicklook.save_rgb(bands, out_png, *, rgb=('B04', 'B03', 'B02'), upscale=1)#
Write an RGB quicklook PNG from a
{band: 2-D array}product. Returns the path.rgbselects the three bands for the R/G/B channels (each independently percentile-stretched).upscalenearest-neighbour enlarges small demo frames for visibility.- Return type:
str
s2_msi_raw_generator.reverse module#
Reverse radiometric chain (L1B radiance → Synthetic L0 RAW DN), pure NumPy.
Each function is one ATBD §5 step (S1, S6, S7, S11, S12, S13, S14). All operate on a 2-D
per-band, per-detector array (lines, detector_columns). The MVP chain (Increment 1) is
S1 → S6 → S7 → S11 → S12 → S13 → S14. Steps S3/S4/S5/S8/S9/S10/S15 (framing, offset, binning,
SWIR rearrangement, crosstalk, blind pixels, ISP) are Increment 3+/Full.
- s2_msi_raw_generator.reverse.forward_radiometric(dn, adf)#
Algebraic forward (processor) inverse of
reverse_radiometric(): DN_raw → radiance.- Return type:
ndarray
- s2_msi_raw_generator.reverse.reverse_full(radiance, adf, rng, *, swir_shifts=None, dead_cols=(), hot_pixels=())#
Extended per-band reverse chain (Inc 3): adds S8 (SWIR re-arrangement, reverse) and S10 (defects).
S1 → S6 → S7 → [S8] → S13 → S11 → S12 → [S10] → S14. Noise (S13) is on the signal DN (before the dark pedestal); defects (S10) are applied last so dead columns stay 0 and hot pixels stay saturated in the output. (S5 un-bin changes width and S9 crosstalk needs multiple bands — applied separately.) Returns
(uint16 Synthetic L0 DN, qa uint8).- Return type:
tuple[ndarray,ndarray]
- s2_msi_raw_generator.reverse.reverse_mvp(radiance, adf, rng)#
Full MVP reverse chain for one band/detector: radiance → uint16 Synthetic L0 DN.
S1 → S6 → S7 → S13 → S11 → S12 → S14. Noise (S13) is impressed on the signal DN (before the dark pedestal is added in S11), so σ=√(α²+β·DN_signal) reproduces the SNR@Lref exactly — the α,β model already captures the total noise at a given signal level.
- Return type:
ndarray
- s2_msi_raw_generator.reverse.reverse_radiometric(radiance, adf)#
Reverse S1→S7→S11→S12 only (no PSF, no noise, no quantize) — exactly invertible.
- Return type:
ndarray
- s2_msi_raw_generator.reverse.s10_inject_defects(img, dead_cols=(), hot_pixels=(), dn_max=4095)#
S10 — re-insert blind/defective pixels: dead columns → 0, hot pixels → saturated.
Returns
(img_with_defects, qa)whereqa(uint8) has bit0 = dead, bit1 = hot. (S2C Cal/Val: 3 defective in B11, 1 in B12.)- Return type:
tuple[ndarray,ndarray]
- s2_msi_raw_generator.reverse.s11_reapply_dark(dn, dark_dn)#
S11 — add the per-detector dark signal back:
DN += dark.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s12_reapply_onboard_eq(dn, eq_gain, eq_offset)#
S12 — reverse onboard equalization:
DN_raw = DN_eq / gain_ob + offset_ob.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s13_add_noise(dn, noise_a, noise_b, rng)#
S13 — add signal-dependent sensor noise:
σ = √(a + b·DN),DN += N(0, σ).- Return type:
ndarray
- s2_msi_raw_generator.reverse.s14_quantize(dn, dn_max=4095)#
S14 — quantize to 12-bit and clip:
clip(round(DN), 0, 4095)→ uint16.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s1_radiance_to_dn(radiance, gain)#
S1 — radiance → equalized signal DN:
DN = A · L, the absolute-calibration term of the official L1 ATBD raw modelX = A·G·L + D.AisBand.cal_gain(datasheet-derived from the noise model + SNR@Lref, so the chain reproduces SNR@Lref). Processor forward:L = DN/A.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s3_undo_framing(img)#
S3 — undo scene framing & round/clamp.
Framing (cutting the continuous detector strip into scenes) is a product-assembly concern, handled in
s2_msi_raw_generator.l0product; on a single full-strip granule array this is identity.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s4_undo_radiometric_offset(dn, offset=0.0)#
S4 — remove the PB04.00 product offset:
DN -= offset.Only relevant when entering from offset-encoded product DN; for the radiance entry path the offset is 0 (raw counts carry no product offset).
sensor.RADIO_ADD_OFFSET_L1Bis −100.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s5_unbin(img, factor=3, axis=1)#
S5 — un-bin a 60 m band to detector-level by replication along
axis(×``factor``).Inverse of forward mean-binning (e.g. 20 m → 60 m, factor 3). Replication preserves each cell’s value; binning the result back recovers the input.
- Return type:
ndarray
- s2_msi_raw_generator.reverse.s6_psf_reblur(img, psf)#
S6 — re-introduce optical blur: circular 2-D convolution with a DC-unit PSF kernel.
Radiometry-preserving (kernel sums to 1 ⇒ array total is preserved exactly).
- Return type:
ndarray
- s2_msi_raw_generator.reverse.s7_impress_relative_response(dn, prnu_gain)#
S7 — re-introduce per-detector PRNU:
DN /= gain[detector](undo equalization).- Return type:
ndarray
- s2_msi_raw_generator.reverse.s8_restage_swir(img, shifts)#
S8 — SWIR re-arrangement (reverse): re-introduce the staggered SWIR readout by rolling each detector column along-track.
shifts[c]is the integer line shift for columnc(PyRawS-style deterministic shift map). Exactly invertible by restaging with-shifts.- Return type:
ndarray
- s2_msi_raw_generator.reverse.s9_apply_crosstalk(bands, coeff=0.002)#
S9 — inter-band optical/electrical crosstalk within one resolution group.
Each band gains
coeff× (sum of the other bands).bandsmust be a dict of identical- shape arrays. <0.5 % channel-to-channel (ATBD Annex A.6). For smallcoeffthe inverse is ≈ subtractcoeff× neighbours.- Return type:
dict[str,ndarray]
s2_msi_raw_generator.s3fetch module#
Anonymous S3 object fetcher (stdlib only) for retrieving public input products.
A dependency-free helper for pulling Sentinel-2 L1A / L1B granules (or any objects) from a public,
anonymously-readable S3-compatible endpoint — e.g. an open-data bucket — into a local directory,
with listing pagination and per-object integrity verification. It uses only the standard library
(urllib.request, xml.etree.ElementTree, hashlib, concurrent.futures),
in keeping with the project’s pure numpy + stdlib policy, and issues unauthenticated GET
requests only (no credentials, no request signing).
Integrity is checked on every download: the HTTP Content-Length must equal the listed object
size, and for single-part objects (an ETag without a - part-count suffix) the MD5 of the
downloaded bytes must equal the ETag. Downloads are written to a .part-tmp sidecar and
atomically os.replace()-d into place, and destination paths are confined to the target
directory (keys that would escape via a parent reference are rejected).
- class s2_msi_raw_generator.s3fetch.S3Object(key, size, etag)#
Bases:
objectOne object in an S3 listing (a single
Contentsentry).- etag: str#
ETag with surrounding quotes removed; a
-marks a multipart upload (not a plain MD5).
- key: str#
The object key (its path within the bucket).
- size: int#
Object size in bytes.
- s2_msi_raw_generator.s3fetch.fetch_prefix(endpoint, prefix, dest_dir, *, strip_prefix=None, jobs=4, resume=True, timeout=120.0)#
Download every object under
prefixintodest_dirwith integrity verification.Each object is written to
dest_dir / <key with strip_prefix removed>. Downloads go to a.part-tmpsidecar first and are atomicallyos.replace()-d into place. The HTTPContent-Lengthis checked against the listed size for every object, and single-part ETags are verified as MD5. Withresumeset, an existing file whose size (and, when checkable, MD5) already matches is left untouched.- Parameters:
endpoint (
str) – Bucket endpoint URL.prefix (
str) – Key prefix to fetch.dest_dir (
str|Path) – Local destination directory (created as needed). Keys that would resolve outside it are rejected.strip_prefix (
str|None) – The portion removed from the front of each key to form its local path. Defaults toprefix.jobs (
int) – Number of concurrent download threads. Default 4.resume (
bool) – Skip files that are already complete. DefaultTrue.timeout (
float) – Per-request timeout in seconds. Default 120.
- Returns:
A manifest with keys
endpoint,prefix,n_objects,total_bytes,filesanderrors. Eachfilesentry is{"key", "size", "etag", "verify"}whereverifyis one of"md5","size"or"skip".- Return type:
dict- Raises:
RuntimeError – If any object failed to download or verify (or was rejected as an unsafe path). Every other object is processed first; the manifest is attached to the exception as
exc.manifest.
- s2_msi_raw_generator.s3fetch.list_prefix(endpoint, prefix, *, timeout=60.0)#
List every object under
prefixat an S3-compatibleendpoint(ListObjectsV2).Follows
NextContinuationTokenpagination until the listing is exhausted.- Parameters:
endpoint (
str) – Bucket endpoint URL, e.g."https://host/bucket".prefix (
str) – Key prefix to list.timeout (
float) – Per-request timeout in seconds. Default 60.
- Returns:
Every listed object under
prefix.- Return type:
list[S3Object]
- s2_msi_raw_generator.s3fetch.parse_list_xml(xml_bytes)#
Parse an S3
ListBucketResult(ListObjectsV2) response.The parse is namespace-tolerant — the default S3 XML namespace on every tag is stripped.
- Parameters:
xml_bytes (
bytes) – The raw XML response body.- Returns:
The objects on this page and the
NextContinuationTokenfor the following page, orNonewhen the listing is not truncated.- Return type:
tuple[list[S3Object],str|None]
- s2_msi_raw_generator.s3fetch.save_manifest(manifest, path)#
Write a fetch
manifesttopathas indented JSON.- Return type:
None
s2_msi_raw_generator.sad module#
Satellite Ancillary Data (SAD) — AOCS attitude + orbit ephemeris + thermal for the L0 ISP.
The S2A S2A_OPER_AUX_SADATA_* / HKTM binary inner layout is proprietary (S2 PDGS ISP ICD, not
public), so a bit-exact decode is infeasible. This module degrades gracefully in three tiers:
Outer framing decode (optional):
scan_ccsds_packets()/decode_sadata_framing()parse the CCSDS Space-Packet boundaries (APID, length, CUC time) of a SADATA/HKTM stream.Inner AOCS/orbit synthesis (physically plausible values):
synth_orbit_attitude()propagates a Sentinel-2 sun-synchronous circular orbit (ECEF position/velocity), a nadir/velocity-aligned attitude quaternion, and a slow thermal cycle — numpy-only, no external ephemeris.Re-pack as CCSDS ISP (
pack_sad_isp()): a documented big-endian float64 payload [q0..q3, x, y, z, vx, vy, vz, T] replacing the previous all-zero SAD payload.
msi-processor does not consume the SAD for L1B (it is optional passthrough); this exists for product
fidelity, EOQC Datation_Sync/Time_Correlation and future rigorous geometry.
- class s2_msi_raw_generator.sad.AocsSeries(times_s, position, velocity, quaternion, thermal)#
Bases:
objectPer-sample AOCS/orbit series (ECEF), one row per SAD packet or image line.
- position: ndarray#
- quaternion: ndarray#
- thermal: ndarray#
- times_s: ndarray#
- velocity: ndarray#
- s2_msi_raw_generator.sad.aocs_to_conditions(aocs)#
Per-line
conditions/*arrays for the open-container L0 handoff (used by the E2E MR).- Return type:
dict
- s2_msi_raw_generator.sad.decode_sadata_framing(tar_path)#
Tier-1 decode: CCSDS packet framing of an
S2A_OPER_AUX_SADATA_*/ HKTM.tar.Returns the aggregated packet headers (APID, sequence, length, CUC not decoded here — inner layout is proprietary). Enrichment only; the synthesis path (
synth_orbit_attitude()) is the default.- Return type:
list[dict]
- s2_msi_raw_generator.sad.orbit_ephemeris(datation, n_lines)#
(start, stop)orbit-ephemeris blocks (TAI/UTC/UT1 + ECEF position/velocity) for the metadata.- Return type:
tuple[dict,dict]
- s2_msi_raw_generator.sad.pack_sad_isp(aocs, apid)#
Pack an
AocsSeriesinto CCSDS SAD ISP packets (primary header + CUC + payload).- Return type:
tuple[ndarray,ndarray]
- s2_msi_raw_generator.sad.scan_ccsds_packets(buf)#
Scan a byte buffer for consecutive CCSDS Space Packets (outer-framing decode, tier 1).
- Return type:
list[dict]
- s2_msi_raw_generator.sad.synth_orbit_attitude(times_s, *, a=7157000.0, inclination_deg=98.62, raan_deg=0.0, u0_deg=0.0)#
Physically-plausible S2 circular-orbit ECEF position/velocity + nadir attitude at
times_s.- Return type:
- s2_msi_raw_generator.sad.unpack_sad_payload(rec)#
Decode one SAD ISP record →
{time, quaternion, position, velocity, thermal}.- Return type:
dict
s2_msi_raw_generator.sensor module#
Sentinel-2 MSI sensor model — REAL values harvested from product metadata + official docs.
Sources: ATBD Annex A.11 (physical_gains, TDI, line_period from S02MSIL1B_20240403…),
SentiWiki MSI radiometric table (SNR@Lref) and the official Spectral Response Functions
document (COPE-GSEG-EOPG-TN-15-0007 v4.0, 2024) for per-unit band centre/bandwidth/equivalent
wavelength. No synthetic values here. Per-unit (S2A/S2B/S2C) data is matched to the product’s
satellite via unit_from_platform().
- class s2_msi_raw_generator.sensor.Band(name, gsd_m, physical_gain, lref, snr_at_lref, has_tdi, unit='S2A', centre_nm=0.0, bandwidth_nm=0.0, equiv_wavelength_nm=0.0, noise_alpha=0.0, noise_beta=0.0)#
Bases:
objectPer-band sensor parameters (spectral characterisation is for unit
unit).- bandwidth_nm: float = 0.0#
- property cal_gain: float#
dn_ref / Lref— datasheet-derived (noise α,β + SNR@Lref), so the chain reproduces the SNR@Lref.- Type:
Absolute calibration gain A used in S1 (
DN = A·L)
- centre_nm: float = 0.0#
- property dark_dsnu: float#
Per-pixel dark non-uniformity (1σ DN) for this band’s focal plane (DQR value).
- property dn_ref: float#
Equalized signal DN at Lref, on the true 12-bit instrument scale.
Derived from the REAL noise model + REAL SNR@Lref: the DN where the noise σ=√(α²+β·DN) gives the spec SNR (
DN/σ = SNR), i.e. the positive root ofDN² − SNR²β·DN − SNR²α² = 0. This anchors the chain so the α,β reproduce the SNR@Lref. (The product’sphysical_gainis incoherent with α,β on this synthetic dataset, so it is kept for metadata / the round-trip bridge but not used to set the working DN scale.)
- equiv_wavelength_nm: float = 0.0#
- gsd_m: int#
- has_tdi: bool#
- lref: float#
- name: str#
- noise_alpha: float = 0.0#
- noise_beta: float = 0.0#
- physical_gain: float#
- snr_at_lref: float#
- unit: str = 'S2A'#
- s2_msi_raw_generator.sensor.all_bands(unit='S2A')#
All 13 bands in canonical order, for unit
S2A/B/C.- Return type:
list[Band]
- s2_msi_raw_generator.sensor.band(name, unit='S2A')#
Return the
Bandmodel for a band name (e.g."B04"), for unitS2A/B/C.- Return type:
- s2_msi_raw_generator.sensor.band_number(name)#
Band number used in the Synthetic L0 spectral_band_info keys:
B01→"01",B8A→"8A".- Return type:
str
- s2_msi_raw_generator.sensor.esun(name, unit='S2A')#
Per-band ESUN (extraterrestrial solar irradiance, W·m⁻²·µm⁻¹) for
unit— Thuillier 2003.The value the processor’s
toaunit consumes as thespectralADF for TOA reflectance. Only S2A/S2B are published (ATBD §A.3); Sentinel-2C raises until its SRF-specific set exists.- Return type:
float
- s2_msi_raw_generator.sensor.spectral_band_info(unit='S2A')#
Per-band spectral_band_info block for the L0 root metadata (values).
Radiometric values are from product metadata (Annex A.11); spectral centre/bandwidth/equivalent wavelength are the per-unit values from the SRF document (COPE-GSEG-EOPG-TN-15-0007).
- Return type:
dict[str,dict]
- s2_msi_raw_generator.sensor.unit_from_platform(platform)#
Map a product platform string (
"Sentinel-2A","S2B", …) to a unit keyS2A/B/C.- Return type:
str
- s2_msi_raw_generator.sensor.zarr_band_key(name)#
Zarr group key for a band in the Synthetic L0 product:
B03→"b03",B8A→"b8a".- Return type:
str