diff --git a/.gitattributes b/.gitattributes index e8cdc91a08..dcb152c1ca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -120,3 +120,7 @@ src/objects/*/source/observations/*.INFO -text -whitespace # Native results and byte inventories are reproducible generated evidence. tests/oracles/sbmt/projection.json linguist-generated=true tools/oracles/sbmt/runtime.lock.json linguist-generated=true + +# Preserve the exact archived VOTable response bytes, including CRLF. +tests/fixtures/telescope-vo/*.xml -text -whitespace +tests/fixtures/telescope-vo/*.fits binary diff --git a/docs/images/telescopes/enceladus-isis-spectrum.png b/docs/images/telescopes/enceladus-isis-spectrum.png new file mode 100644 index 0000000000..1506f4289d Binary files /dev/null and b/docs/images/telescopes/enceladus-isis-spectrum.png differ diff --git a/docs/images/telescopes/eris-aperture-spectrum-oracle.png b/docs/images/telescopes/eris-aperture-spectrum-oracle.png new file mode 100644 index 0000000000..23a7a12b65 Binary files /dev/null and b/docs/images/telescopes/eris-aperture-spectrum-oracle.png differ diff --git a/docs/images/telescopes/eris-aperture-spectrum.png b/docs/images/telescopes/eris-aperture-spectrum.png new file mode 100644 index 0000000000..9253c87308 Binary files /dev/null and b/docs/images/telescopes/eris-aperture-spectrum.png differ diff --git a/docs/images/telescopes/eris-band-image-oracle.png b/docs/images/telescopes/eris-band-image-oracle.png new file mode 100644 index 0000000000..e4dfc57027 Binary files /dev/null and b/docs/images/telescopes/eris-band-image-oracle.png differ diff --git a/docs/images/telescopes/eris-band-image.png b/docs/images/telescopes/eris-band-image.png new file mode 100644 index 0000000000..46690ad35a Binary files /dev/null and b/docs/images/telescopes/eris-band-image.png differ diff --git a/docs/images/telescopes/eris-feature-map-oracle.png b/docs/images/telescopes/eris-feature-map-oracle.png new file mode 100644 index 0000000000..1d85094ffc Binary files /dev/null and b/docs/images/telescopes/eris-feature-map-oracle.png differ diff --git a/docs/images/telescopes/eris-feature-map.png b/docs/images/telescopes/eris-feature-map.png new file mode 100644 index 0000000000..1a12c687a2 Binary files /dev/null and b/docs/images/telescopes/eris-feature-map.png differ diff --git a/docs/images/telescopes/eris-native-plane.png b/docs/images/telescopes/eris-native-plane.png new file mode 100644 index 0000000000..f1cb27fbfe Binary files /dev/null and b/docs/images/telescopes/eris-native-plane.png differ diff --git a/docs/images/telescopes/eris-pixel-spectrum.png b/docs/images/telescopes/eris-pixel-spectrum.png new file mode 100644 index 0000000000..eb26ceca16 Binary files /dev/null and b/docs/images/telescopes/eris-pixel-spectrum.png differ diff --git a/docs/images/telescopes/europa-projected-brightness.png b/docs/images/telescopes/europa-projected-brightness.png new file mode 100644 index 0000000000..f9cf3147e9 Binary files /dev/null and b/docs/images/telescopes/europa-projected-brightness.png differ diff --git a/docs/images/telescopes/europa-sphere-html.png b/docs/images/telescopes/europa-sphere-html.png new file mode 100644 index 0000000000..3def9e4b4b Binary files /dev/null and b/docs/images/telescopes/europa-sphere-html.png differ diff --git a/docs/images/telescopes/europa-sphere-reference.png b/docs/images/telescopes/europa-sphere-reference.png new file mode 100644 index 0000000000..0888cbee4a Binary files /dev/null and b/docs/images/telescopes/europa-sphere-reference.png differ diff --git a/docs/images/telescopes/europa-sphere-rotation.gif b/docs/images/telescopes/europa-sphere-rotation.gif new file mode 100644 index 0000000000..aa1888519a Binary files /dev/null and b/docs/images/telescopes/europa-sphere-rotation.gif differ diff --git a/docs/images/telescopes/moon-pds-radius.png b/docs/images/telescopes/moon-pds-radius.png new file mode 100644 index 0000000000..046291a16e Binary files /dev/null and b/docs/images/telescopes/moon-pds-radius.png differ diff --git a/docs/images/telescopes/vo-betelgeuse-cutout.png b/docs/images/telescopes/vo-betelgeuse-cutout.png new file mode 100644 index 0000000000..6cae9f7fae Binary files /dev/null and b/docs/images/telescopes/vo-betelgeuse-cutout.png differ diff --git a/docs/virtual-telescopes.md b/docs/virtual-telescopes.md index 01c2f3b97b..32d95d7d50 100644 --- a/docs/virtual-telescopes.md +++ b/docs/virtual-telescopes.md @@ -152,40 +152,65 @@ Astropy owns the [weighted fitting](https://docs.astropy.org/en/stable/api/astro The wavelength-by-wavelength assessment accommodates the spatial PSF variation described in [STScI's IFU guidance](https://jwst-docs.stsci.edu/methods-and-roadmaps/jwst-integral-field-spectroscopy). -Source-package descriptions remain declarations. Native source qualification also reads -`facts.nativeMetadata` from the pinned product: the selected science structure, supported -data units, wavelength centers, recorded calibration references and remaining limitations. -It never copies the package description or instrument catalogue into verified facts. - -- FITS images: one unambiguous science HDU; separable linear `WAVE` or `FREQ` WCS, - converted to micrometres. Coordinate-bin intervals exclude planes without finite samples. - These intervals describe the sampled grid, not optical passbands or spectral resolving power. - Coupled, tabular, velocity and air-wavelength WCS remain unsupported and explicit. -- ISIS3 cubes: `BandBin.Center` must match the core band count and be strictly monotonic. - Units must be stated or established by a supported recorded calibration convention. - For [VIMS RC19](https://isis.astrogeology.usgs.gov/8.1.0/Application/presentation/Tabbed/vimscal/vimscal.html), - the time-dependent `Center` is used, never `MissionAverage`. `RadiometricCalibration.OutputUnits` - records the data unit. Calibration filenames are retained as references; their presence does - not qualify the external calibration files. Centers alone do not establish continuous coverage; - explicit widths are needed, and empty bands remain gaps. -- PDS products: pdr exposes units and band coordinates from the decoded object's own label - block. A PDS4 optical filter must explicitly reference the selected array. Metadata from another - array or an ambiguous set of science arrays cannot satisfy the request. - -Data-unit validation currently recognizes `I/F`, dimensionless values, counts/DN, electrons, -`Jy`, `mJy`, `MJy/sr`, `Jy/beam`, `K` and `W m-2 sr-1 um-1`. Other units are retained in an -explicit limitation instead of guessed. Product metadata validation is not an independent -validation of radiometric accuracy, quality flags or uncertainty. - -Applicable FITS `BMAJ` and `BMIN` headers in `Jy/beam` images establish the recorded -restoring beam; its major-axis FWHM answers angular-resolution requirements and its evidence -points to the exact product hash. A missing beam axis, multiple per-plane beams, or incompatible -units cannot establish that resolution. This does not independently validate deconvolution or -residual emission. Pixel spacing, map scale and nominal instrument optics never become a PSF. - -The metadata reader is part of the qualification implementation digest. Product, label, -calibration input, reader or output changes invalidate reuse. A source-qualified product can -still remain an unresolved answer when its files do not establish the requested science facts. +Source descriptions remain declarations. Source qualification and reducer qualification now +use one `product-science.mts` readback owner. `facts.nativeMetadata` records each science array, +units, wavelength coordinates, usable samples, uncertainty association and remaining limitations. +Instrument catalogue values never become verified product facts. + +Astropy (the existing pinned Python environment) owns unit parsing and FITS WCS transforms. +Linear, nonlinear and table-backed separable wavelength/frequency coordinates are supported; +velocity coordinates require a recorded rest wavelength or frequency. Tabulated centers do not +invent bin edges. Spatially coupled spectral coordinates and air wavelengths remain explicit +limitations. Multiple science HDUs retain separate metadata: aggregate wavelength coverage is +their intersection, never a mosaic. A companion ERR/VAR/IVAR or DQ/MASK must match exactly one +science array by EXTVER and shape. Uncertainty dimensions must agree with the science unit. + +The conservative usable-sample policy requires finite science, zero supplied DQ/MASK and valid +supplied uncertainty. Negative unmasked errors are refused; missing uncertainty units remain +unknown and cannot justify usable coverage. No uncertainty array is explicitly recorded as +unknown. Fully masked wavelength planes do not establish coverage. Numeric validity does not +independently establish radiometric accuracy or correctness of the observatory's error model. + +ISIS BandBin coordinates are count-checked and strictly monotonic, with explicit units or the +recorded VIMS RC19 convention. Named geometry backplanes are ancillary data, not spectra. +Singleton ancillary FITS axes do not turn a two-dimensional image into a spectral cube. +PDS ordinal UTC timestamps and absent optional processing-level labels are handled directly. +Centers alone do not establish passband widths. pdr owns PDS scaling and special-value masks; +PDS4 special constants are scoped to their own array. Ambiguous PDS science arrays remain +unresolved. Extracted PDS labels whose attached pointers lie outside the pinned file are not +advertised as complete observations. + +Recorded CRDS and ISIS calibration references resolve to exact official archive files. Each +retrieved file is hashed, checked on reuse, and included in delivery. Retrieval is bounded to +16 MB per file and 64 MB per qualification; missing, timed-out or oversized references remain +explicitly unresolved. Instrument/configuration selectors are compared where available. VIMS +band-center references can additionally be checked against recorded original-band indices. +A pin proves which bytes were used; selector agreement does not independently prove calibration +accuracy. The product's recorded calibration is not a new raw-data recalibration. + +Applicable FITS BMAJ/BMIN or a complete channel-indexed BEAMS table establishes the recorded +restoring beam for Jy/beam-equivalent units. Per-plane resolution uses the worst usable major +axis. Missing or ambiguous beam identities remain unknown. Pixel spacing and nominal optics +never become a measured PSF. + +Product, label, calibration dependency, reader, package lock or output mutations invalidate +qualification reuse. Unknown science requirements remain unknown in request satisfaction, +even when the delivered bytes are fully verified. + +For reproducible empirical delivery checks: + +```sh +node tools/objects/telescopes/survey-delivery.mts output/survey --random 20 +# Repeat with the seed written in output/survey/survey.json: +node tools/objects/telescopes/survey-delivery.mts output/replay --random 20 SEED +``` + +The pool comprises body packages with a locally available numeric product under 256 MB, +without filtering on prior success. Bodies and eligible observations are sampled separately. +If fewer bodies are locally available, the survey tests the whole pool and records both counts. +The report retains the seed, complete pool, chosen targets, failures, blocked cases and delivered +request-satisfaction results. It exercises saved query → qualification → delivery with existing +local observations; it is not a fresh all-archive discovery survey or raw pipeline rerun. Source observations and explicit selections expose `requestSatisfaction` and `satisfaction`, respectively. Published body-map descriptors also carry `satisfaction`. Its status is `fulfilled`, @@ -599,3 +624,314 @@ the previous zero-excluding policy do not establish agreement under the new poli recompare existing cubes to renew them. No pipeline rerun is needed for unchanged products and inputs. The scope states the actual compared archive planes, including aligned subsets supplied without a request interval. + +## From a delivered product to an output + +`tools/objects/telescopes/outputs.mts` is the final boundary after `session.mts` delivery. +The CLI exposes `telescope outputs RESULT_JSON` and `telescope export RESULT_JSON`. +[The command guide](../packages/telescope/README.md#outputs) covers selectors and files. + +Executable outputs include a native FITS plane, a pixel spectrum, a wavelength-weighted +band image, a background-subtracted region mean spectrum and a continuum-subtracted feature map. Astropy owns coordinates and units; the shared scientific reader applies the +same uncertainty and quality policy used by qualification; Astropy NDData owns aggregate +arithmetic and uncertainty propagation. Astropy WCSAxes owns sky-coordinate axes, +ImageNormalize owns display scaling, and quantity_support owns spectral unit conversion. +Matplotlib renders PNG/SVG, tightly bounded around the chart, labels and legend +with a 0.12-inch gutter. PNG backgrounds are transparent; the light labels suit +dark backgrounds. Astropy also writes reusable FITS images or ECSV spectra; +CSV and a product record accompany every export. The record pins the original delivery, +its files, the chosen HDU/plane/pixel, the implementation, package versions and derived bytes. +No plotting stage upgrades the original scientific request's satisfaction. + +There is no embedded viewer or viewer service. The command returns ordinary files; Jdaviz +is neither installed nor launched. It can be used separately to explore the original cube. +Static export does not depend on a browser or notebook. + +Image FITS files retain the source's separable celestial WCS on the unchanged pixel grid, +physical BUNIT, a MASK extension (1 = missing), and ERR when uncertainties are available. +Absent or coupled celestial coordinates produce explicit pixel axes and a recorded reason; +malformed WCS is refused. Spectral ECSV files retain wavelength and value units, missing-value +masks, standard deviations, selection and uncertainty policy. Both formats refer to the pinned +receipt. CSV remains a simple numeric convenience, with semantics in that receipt. + +Display scaling is linear over the finite range; feature maps use a range symmetric around +zero. The receipt records limits, colormap, coordinate frame and WCS warnings. No reprojection, +smoothing or change to the exported measurements is performed. These are recorded sky +coordinates, not a new astrometric calibration or body registration. + +The package APIs are documented by [Astropy visualization](https://docs.astropy.org/en/stable/visualization/index.html). +css.earth retains the measurement definition, explicit selection, missing-sample policy and +provenance. This is a thin file export boundary, not another plotting toolkit. + +### Example exports: Eris, JWST NIRSpec IFU + +These figures come from the local level-3 cube +`jw01191-o019_t002_nirspec_g235m-f170lp_s3d.fits`, SCI HDU 1, with shape +191 wavelengths × 55 rows × 51 columns. Its SHA-256 is +`fbeeb9737ecf46c2b1aad5e27cb55a50e6e8f83fc8e7e347c3aa80d3e07f4bab`. +These figures use the product's MJy/sr units and quality mask. Pixel and plane selectors +are zero-based. Astropy 8.0.1 reads the product; Matplotlib 3.11.2 renders the figures. + +![Eris NIRSpec image plane at 2.2997 micrometres, with source sky-coordinate axes and a surface-brightness colour bar](images/telescopes/eris-native-plane.png) + +**Image:** plane 95 at 2.2997 µm, shown on the native image grid with source ICRS sky coordinates. The diamond-shaped +footprint is the cube's sampled field, not Eris's surface. Masked samples are omitted. + +![Eris spectrum at image pixel 25,27, with wavelength and surface-brightness axes and recorded uncertainty](images/telescopes/eris-pixel-spectrum.png) + +**Spectrum:** one selected pixel, `(25, 27)`, across the cube's approximately +2.2–2.4 µm interval. Shading shows the recorded ±1σ uncertainty. This is not an +aperture-integrated spectrum or a claim about the significance of spectral features. + +Given this cube's delivered `result.json`, reproduce the selections with: + +```sh +telescope export result.json --output image --hdu 1 --plane 95 --out eris-image +telescope export result.json --output spectrum --hdu 1 --pixel 25,27 --out eris-spectrum +``` + +The delivery still reports unresolved angular resolution because the caller has not +accepted its profile-model assumptions. Exporting these figures does not change that verdict. + +![Eris mean image over 2.2–2.4 micrometres](images/telescopes/eris-band-image.png) + +**Band image:** mean surface brightness over 2.2–2.4 µm, weighted by the overlap of each +qualified wavelength bin with that interval. Each visible pixel has every selected sample. +The image retains its native grid; this is not a registered body map. + +![Eris aperture spectrum with a separate background region subtracted](images/telescopes/eris-aperture-spectrum.png) + +**Aperture spectrum:** mean over the fixed box `[19,24,25,30]`, minus the mean over +background box `[29,24,35,30]`. Coordinates are zero-based, upper bounds exclusive; +these are two disjoint 6 × 6 pixel regions. The result remains mean surface brightness +in MJy/sr, without a total-flux or aperture-correction claim. A missing sample in either +region masks that channel instead of changing the measured area. + +![Eris continuum-subtracted feature integral over 2.30–2.34 micrometres](images/telescopes/eris-feature-map.png) + +**Feature map:** the wavelength integral over 2.30–2.34 µm after subtracting a linear +continuum anchored by weighted means over 2.26–2.29 and 2.35–2.38 µm. The colour scale +is symmetric around zero: blue is negative (absorption relative to this continuum), red +is positive. Values are MJy µm/sr, not total flux. This selected-window example does not +establish a chemical identification or detection significance; field-edge residuals remain. + +```sh +telescope export result.json --output band-image --hdu 1 --band 2.2,2.4 --out eris-band +telescope export result.json --output aperture-spectrum --hdu 1 --aperture 19,24,25,30 --background 29,24,35,30 --out eris-aperture +telescope export result.json --output feature-map --hdu 1 --band 2.30,2.34 --continuum 2.26,2.29,2.35,2.38 --out eris-feature +``` + +All three examples use the same pinned cube above and the default `--uncertainty omit`: +spatial/spectral covariance has not been supplied. The API can propagate sample variances +with `--uncertainty independent`, including background and continuum errors, but its receipt +and spectrum legend identify that assumption explicitly. No smoothing, PSF matching or +resampling is applied. FITS/ECSV data, PNG, SVG, CSV and a pinned product record accompany each export. + + +### Independent numerical references + +The new output arithmetic uses Astropy NDData. A separate reference tool reads the original +FITS cube and computes spectra with Photutils aperture sums and spectral integrals with +specutils. It does not call the production reducer. These comparisons cover all finite output +samples; units and missing-sample masks match exactly. Reproduction commands are in the +[CLI guide](../packages/telescope/README.md#independent-output-checks). + +| Eris output | Independent reference | Valid samples | Maximum absolute difference | +| --- | --- | ---: | ---: | +| Band image | specutils 2.4.0 `line_flux`, divided by wavelength width | 1,309 | 4.45e-15 MJy/sr | +| Aperture spectrum | Photutils 3.0.0 rectangular aperture sums divided by area | 191 | 4.45e-16 MJy/sr | +| Feature map | specutils 2.4.0 integration after explicit continuum subtraction | 1,312 | 1.12e-16 MJy µm/sr | + +![Eris band image, independent specutils reference, and their numerical difference](images/telescopes/eris-band-image-oracle.png) + +![Eris aperture spectrum overlaid with the Photutils reference, with residuals below](images/telescopes/eris-aperture-spectrum-oracle.png) + +![Eris feature map, independent specutils reference, and their numerical difference](images/telescopes/eris-feature-map-oracle.png) + +The residual colour scales are in the stated physical units, at floating-point roundoff levels. +They are not science signal. Known-answer fixtures separately verify uncertainty propagation, +including continuum and background contributions. These checks validate the extraction +arithmetic; they share Astropy FITS/WCS decoding and do not establish calibration accuracy, +unknown error covariance, molecular identity or detection significance. + +Output ownership and remaining adapters: + +| Output | Required scientific input | Existing owner / remaining adapter | +| --- | --- | --- | +| Native image | Qualified pixel array and mask | Astropy FITS/WCSAxes output adapter; native sky or pixel coordinates, not body coordinates | +| Spectral chart | Qualified wavelength axis and explicit pixel or fixed region | Executable FITS output adapter; optional background subtraction and explicitly conditional uncertainty | +| Band image | Qualified units and wavelength bin edges | Executable wavelength-weighted mean; partial boundary bins included | +| Feature map | Qualified bins plus feature and bracketing continuum windows | Executable continuum-subtracted wavelength integral; signed residual, no detection claim | +| Surface map | Measurement definition, viewing geometry, rotation/frame and resolution evidence | PlanetMapper-backed `telescope project`; scientific publication remains `body-map-publication.mts` | +| Body sphere | Qualified surface map plus a prepared layer | Standalone HTML from `telescope export --output sphere`, using the existing PolyCSS renderer | +| 3D scatter/volume | Explicit coordinate frame, units and measured or explicitly modeled depth | Existing point-field and density-volume owners; `--output points` or `volume` packages a physical `object.json` | + +A wavelength axis, radial velocity or image intensity cannot silently become physical depth. +PDS arrays pass through `pdr`; ISIS3 cores use the existing shared reader. Both feed +Astropy and Matplotlib through temporary file-backed arrays. `--structure NAME` +selects an ambiguous PDS array, with `--hdu 0` addressing its normalized FITS array. +The original labels, input hashes, decoding versions and limitations remain in the receipt. +The recognized IMAGE / SIGMA_MAP_IMAGE / QUALITY_MAP_IMAGE convention preserves +standard deviations and conservatively accepts only zero quality flags. This is the +[OSIRIS documented all-science-cases criterion](https://rosetta-osiris.eu/documents/SCIENCE_USER_GUIDE.PDF), +not an assertion that every flagged pixel is useless for every analysis. + +There is no one-million-pixel CLI cap. Extraction writes NumPy files instead of +serializing pixel arrays through JSON. PNG/SVG previews use a recorded nearest-sample +stride when an axis exceeds 1600 pixels; FITS and CSV keep the entire native grid. +Format readers and serialization still need memory and disk space proportional to their inputs. + +### Native examples and physical handoffs + +```sh +telescope export enceladus/pick-1/result.json --output spectrum --hdu 0 --pixel 10,10 --out enceladus-spectrum +telescope export comet-67p/pick-10/result.json --output image --structure IMAGE --hdu 0 --out comet-image +telescope export src/objects/stellar-neighbourhood/object.json --output points --out stars-handoff +telescope export src/objects/milky-way/object.json --output volume --out volume-handoff +``` + +![Enceladus spectrum from the delivered ISIS3 cube](images/telescopes/enceladus-isis-spectrum.png) + +Enceladus: Cassini VIMS cube C1487299582_1_ir.cub, pixel (10,10), recorded spectral +centres and dimensionless I/F. No spectral bin edges or uncertainty are invented. +The source request remains unresolved; a figure does not upgrade qualification. + +![Moon native PDS4 radius grid](images/telescopes/moon-pds-radius.png) + +Moon: LOLA LDEM_16, 5760 × 2880 (16,588,800 cells). `pdr.get_scaled` applies +both the label's factor 0.5 and offset 1,737,400 metres. Values span +1,728,418.5–1,748,085.5 m; the figure's native pixel axes do not assert a new +planetary projection. Its preview samples every fourth pixel; FITS and CSV retain +all samples. Supplied uncertainty is unavailable and remains unknown. + +The 2048 × 2048 OSIRIS image n20151026t125938783id40f22.img also exported, retaining +its sigma map and all 4,194,304 grid cells. Only 26,917 cells pass the conservative +zero-flag criterion. Its mostly masked preview is not a useful picture of the comet; +the flags remain in the pinned original product for a measurement-specific policy. + +The physical handoffs copy the existing prepared object, point bank or volume slices, +textures, frame, source recipe and credits. The application's loaders validate them; +a second renderer is not added. HYG's 109,389 stars retain the source astrometry and +its stated epoch limitations. The Milky Way retains its model interpretation. +The exports reproduce every renderer file byte-for-byte and reject changed textures. +Raw source catalogues/grids are referenced, not bundled: this is a renderer handoff. +An ordinary RA/Dec/wavelength cube still needs a justified physical reconstruction +before it can enter either 3D owner; this command does not supply one. + +### Projection and sphere + +```sh +telescope export europa/pick-1/result.json --output band-image --hdu 1 \ + --band 4.2,4.3 --uncertainty independent --out europa-band +telescope project europa-band/output.product.json --geometry navigation.json --out europa-map +telescope export europa-map/map.fits.product.json --output sphere --out europa-sphere +``` + +`project` uses PlanetMapper 1.14.0 for image navigation and nearest-neighbour +surface resampling. PlanetMapper owns its SPICE geometry through SpiceyPy; +its projection dependency is pyproj/PROJ. Astropy owns the numerical FITS +output and Matplotlib the figure. The sphere is one standalone HTML file, +using the target's existing standard sphere—the same lane as Mercury. The +prepared mesh, camera, facing/depth bindings and physical frame are reused; +the shared raster lane packs the measurement into a surface lens. CSS, +JavaScript and base64 images are embedded, and the document prohibits network +requests. Inactive image bindings (including Mercury’s unused interior images) are +cleared without changing the prepared geometry or camera. There is no export-owned mesh or camera. A target without the existing +standard sphere package is refused. The export also embeds the prepared world +context and uses the application's physical-camera mount. Prepare that context +with `pnpm prepare:world-context` before exporting. + +The projection uses the pinned navigation ellipsoid. The display keeps the +body package's standard reference sphere and physical scale; both are recorded. +Quantitative colours are unlit. The output contains no PlanetMapper GUI, remote +scripts, canvas or WebGL. + +Navigation is an explicit scientific input. `navigation.json` contains: + +```json +{ + "schema": "cssearth-navigation-input@1", + "observer": "JWST", + "kernels": [ + {"file": "pck00011.tpc", "role": "rotation", "source": "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/pck00011.tpc", "bytes": 131226, "sha256": "3dff7b1dbeceaa01f25467767d3fa25816051c85d162d1edf04acb310ee28bb1"} + ], + "registration": {"method": "wcs", "explanation": "Header WCS; no independently fitted centre"}, + "width": 360, + "height": 180, + "maximumEmissionDegrees": 65 +} +``` + +The example shows the rotation pin; supply the complete ordered kernel set +(leapseconds, shape/rotation, target ephemeris and observer ephemeris) for the +observation. Paths are relative to the navigation file. No kernels are found +implicitly in a home directory or downloaded by the projection command. +A fitted registration uses `method: "disc"`, `parameters: [x, y, radius, rotation]` +in PlanetMapper's zero-based native-image convention, an explanation, and an +`evidence` file with its `file`, `bytes` and `sha256`. WCS must still be valid; +there is no silent image-centre fallback. WCS registration establishes a +coordinate model, not an independently measured pointing accuracy. + +The current route accepts bounded two-dimensional intensive measurements +(dimensionless or per steradian) with supplied uncertainty, a celestial WCS, +a matching target/telescope header and `DATE-BEG`/`DATE-END`. Flux per pixel, +missing uncertainty, unresolved discs and unsupported WCS distortion are +refused. This bounded route does not yet cover every instrument's metadata +conventions or target-in-field associations. + +`map.fits` contains VALUE, SIGMA and EMISSION planes. Its existing body-map +sidecar records east-positive longitude, planetocentric latitude, the frame, +measurement and mask. Nearest sampling preserves the original value/error +pair; repeated cells are correlated. Navigation uncertainty, beam smearing +and rotation during the exposure are not propagated. No missing hemisphere +is synthesized. Source request satisfaction is retained separately; projection +**does not qualify surface publication or turn pixel sampling into a PSF**. + +The HTML preserves the target's prepared reference sphere and physical camera +frame. It opens directly at the measurement with no startup flight; shared +controls own drag and zoom. Colour is not relit. Grey is unobserved. + +#### Europa projection, standalone sphere and independent checks + +The example uses the archive cube +`jw01250-o002_t001_nirspec_g395h-f290lp_s3d.fits`, SHA256 +`838c59a8b0ddcb8e7f464324e1a1515dcfe8f4a42c7c79b7d10e8f23f5ffe64a`, +already pinned by the Europa 1250 reproduction record. This run checks those +archive bytes; it does not claim a new Spec3 reproduction. Its 4.2–4.3 µm +brightness image uses the explicit independent-sample uncertainty assumption. +The disc registration reuses the [previous Europa fit](https://github.com/layoutit/css.earth/blob/4ac4a4a9eb076d63760768e9f4ca3408882f2bcc/src/objects/europa/evidence/jwst-band-maps.json), converted from top-row-first +coordinates to native FITS coordinates; the map excludes emission angles above 65°. + +![Europa projected brightness](images/telescopes/europa-projected-brightness.png) + +The actual standalone HTML export, rotated through the shared drag controls +(61 browser frames, resized from 1280 × 720 to 960 × 540 and played at 12.5 fps): + +![Europa measurement rotating in the standard css.earth sphere](images/telescopes/europa-sphere-rotation.gif) + +The independent reference traces orthographic rays through the pinned triaxial +ellipsoid using NumPy and draws their sampled values using Matplotlib. It checks +the emission mask and exact preservation of measurement/error pairs. Separately, +the export oracle compares the HTML's prepared tree, camera, facing/depth bindings, +surface-hit geometry and sky against the hash-pinned original body runtime. +Those records must be identical. The reference image below checks the projected +measurement; it is not a screenshot or a second rendering implementation. + +![Independent Europa sphere reference](images/telescopes/europa-sphere-reference.png) + +The preceding reference is separate from the HTML rotation preview above. The standalone +Europa HTML was inspected through a local HTTP preview: initial rendering, drag +rotation and wheel zoom remained visible with no browser errors. There is no +startup flight; the first drag preserves camera distance. Screenshot +comparison has not been performed; the draft does not claim pixel parity. +The [navigation recipe](../tests/fixtures/telescope-projection/europa-navigation.json), +[registration evidence](../tests/fixtures/telescope-projection/europa-registration.json) and +[numerical report](../tests/fixtures/telescope-projection/europa-oracle.json) pin this example. +Place each downloaded kernel under the recipe's `kernels/` directory; changed archive bytes +are refused. The executable oracle is: + +```sh +node tools/objects/telescopes/sphere-oracle.mts \ + europa-map/map.fits.product.json europa-sphere/sphere.product.json \ + europa-band/image.fits output/sphere-oracle +``` diff --git a/docs/vo-observation-access.md b/docs/vo-observation-access.md new file mode 100644 index 0000000000..2a09bd934b --- /dev/null +++ b/docs/vo-observation-access.md @@ -0,0 +1,66 @@ +# VO observation access + +The Telescope CLI uses the same saved query, numbered choice, qualification and delivery flow for VO archive products. PyVO 1.9.1 owns TAP, VOTable, DataLink and synchronous SODA protocol operations in `astronomy-packages/client.mts`. The `telescopes/vo/` modules own archive identity, target association, access choices and receipts. Existing native-product qualification and figure exporters read the acquired FITS file. + +```sh +telescope query betelgeuse --wavelength 0.78,0.85 --kind image \ + --any-time --min-arcsec 1 \ + --icrs-circle 88.792938,7.407063,0.00005555555555555556 --out observations +telescope get observations --pick N +telescope outputs observations/pick-N/result.json +telescope export observations/pick-N/result.json --output image --hdu 0 --out figure +``` + +Select the number actually returned by the query. A cutout does not establish achieved angular resolution, wavelength coverage, calibration accuracy or complete usable coverage of the requested region. Those verdicts come from qualification of the returned product and can remain unresolved. + +`--spectral-frame barycentric` explicitly permits a SODA BAND request in barycentric metres. A wavelength tuple without a frame is not silently converted into that request. BAND includes the continuum support needed by a band-depth request. CIRCLE is optional, explicitly ICRS, and never inferred from a moving target's name or requested resolution. A service that advertises CIRCLE but no BAND cannot execute a BAND request. Unsupported requested constraints are rejected. + +The initial service profiles cover ESO and ALMA ObsCore 1.1 and ESA PSA EPN-TAP 2.0. They perform bounded name/alias searches with the applicable product and wavelength filters, not complete inventory searches. Each result retains its exact ADQL, sample limit, original response pin, schema and resource metadata. Overflow preserves useful rows and remains distinct from completed empty results in the recorded scope. A service failure does not turn other archives into failures or become a negative observation claim. + +Observation, publisher/dataset, selected file, acquisition operation and received-byte identities are separate. EPN granules are service/table scoped. Repeated or absent row identifiers produce snapshot-and-row-bound keys with an explicit limitation. Target names and classes are compared with the application catalogue; sky overlap alone is not an association. Native qualification checks the actual FITS target header and arrays. + +DataLink previews are not science choices. Multiple science links remain separate. Both direct links and advertised DataLink service descriptors can lead to nested responses. Dispatch follows the advertised standard; a DataLink descriptor is not treated as SODA. PyVO resolves fixed and referenced parameters, and the public query forwards them unchanged. The visited identity includes the endpoint and bound parameters, so two datasets at one endpoint remain distinct; nesting and request bounds still apply. + +SODA parameter validation uses the standard name/UCD/unit declarations, including `meta.ref.url;meta.curation` for ID and `em.wl;stat.interval` for BAND. The separate ESO compatibility rule accepts its captured fixed-ID `meta.id;meta.dataset` declaration only for its advertised service. ALMA's captured malformed nine-character MIME field is retained and refused; correcting nested dispatch does not qualify that separate route or justify guessing a protocol from its hostname. + +The default limits are 1 GiB per science acquisition, 32 MiB per metadata response, three nested DataLink edges, 32 access-description requests per public query, and 1 GiB / 1024 members per expanded package. Override them with `--max-science-bytes`, `--max-metadata-bytes`, `--max-link-depth`, `--max-link-requests`, `--max-expanded-bytes` and `--max-package-members`. Overrides affect acquisition identity. Metadata estimates are advisory; received bytes enforce the bound. Transfers stage before publication, do not retry automatically, and never replace failed subsets with whole-product downloads. ZIP/tar access is limited to one unambiguous FITS image/cube with a complete pinned member set. Unsupported science formats and ambiguous multiple science members are refused; package availability does not imply a new PDS decoder. + +`archive-subset-origin` means the archive returned these exact bytes for this parent and operation. `archive-retrieval-origin` records direct retrieval without asserting a final calibration level. Neither means local recalibration, agreement with a parent array, unchanged sampling or fulfillment of the scientific request. Qualification retains uncertainty/quality arrays, reads units and wavelength metadata from the product, and keeps unavailable calibration evidence explicit. + +Top-level coordinate/time systems are preserved. EPN time normalization resolves referenced TIMESYS and row-level scale/reference-position declarations, refusing contradictions and unresolved references. EPN's UTC default applies only when no explicit scale overrides it; it is not a universal JD default. Conversion still requires day units and a compatible origin, so the captured PSA missing-unit limitation remains explicit. UTC scale conversion does not apply a light-travel-time correction. The standard `spatial_coordinate_description` field survives normalization. Transfer errors expose typed `authentication`, `no-content`, `byte-limit`, `protocol`, `transport`, `interrupted`, `identity` or `local-io` codes. + +Region assessment uses Astropy celestial WCS and the returned valid-pixel mask. Missing boundary samples or invalid in-region pixel centers can establish partial coverage. Successful discrete checks leave continuous coverage unknown; they do not prove that every point in the requested sky circle was measured. + +Acquisition records, original responses, the discovery snapshot, science file and qualification receipt travel together in the existing `cssearth-telescope-delivery@1` export. Modified evidence invalidates reuse and figure export. `telescope get observations --pick N --offline` verifies an already delivered local artifact without refreshing an archive; it cannot perform a new acquisition. This is replay of historically qualified bytes, not requalification with the current software. Normal get requires a current implementation binding. Qualification records carry the qualifier source digest separately from acquisition software. + +The legacy `loadQueryInputs(root, targetString)` overload keeps its earlier archive-loading behavior. Callers supplying a full scientific request receive VO discovery and access planning too. Existing saved choice keys are preserved; VO choices use an explicit acquisition-key variant. + +Protocol fixtures and their limits are documented in [the fixture inventory](../tests/fixtures/telescope-vo/README.md). Fixture tests do not replace live acquisition/delivery evidence. Primary contracts: [ObsCore 1.1](https://www.ivoa.net/documents/ObsCore/20170509/REC-ObsCore-v1.1-20170509.pdf), [EPN-TAP 2.0](https://www.ivoa.net/documents/EPNTAP/20220822/REC-EPNTAP-2.0.html), [SODA 1.0](https://www.ivoa.net/documents/SODA/20170517/REC-SODA-1.0.html), [DataLink](https://www.ivoa.net/documents/DataLink/) and [the pinned PyVO implementation](https://github.com/astropy/pyvo/tree/v1.9.1). + +## Live evidence and validation + +On 2026-09-20 the public query/get/outputs/export flow delivered a 169,920-byte ESO Betelgeuse FITS cutout (112×112 usable samples). A second 95,040-byte cutout and the 8,458,560-byte direct FITS product passed the same saved-session acquisition/qualification/delivery owners and public output commands. Those additional runs reused the retained live ObsCore discovery response and fetched fresh access descriptions and science bytes. All three have distinct acquisition keys and science hashes; this alone does not prove every cross-subset cache rejection case. + +[Machine-readable evidence](../tests/fixtures/telescope-vo/live-evidence.json) records the received bytes and delivery/figure digests. The example is a requested sky crop, not a centered stellar portrait; the bright emission is clipped at its edge. Wavelength coverage and achieved resolution remain unresolved. + +![ESO Betelgeuse cutout exported by the existing native-image owner](images/telescopes/vo-betelgeuse-cutout.png) + +Tests cover descriptor/ref binding, separate subset choices, native qualification, portable delivery, offline replay, tampered evidence and failure without a full-product fallback. New deliveries explicitly record the producing output directory; identical bytes stored at different package paths cannot be mistaken for one another. ZIP/TAR tests retain labels and referenced format files, refuse missing dependencies and unsafe member paths, and enforce both streamed expansion and member-count limits. + +These live captures predate the final path-binding and package-hardening changes. Those changes are exercised by the offline integration tests, and do not change the retained live science bytes or the plotting algorithms. The pinned responses are evidence for the documented query scope, not an inventory or certification of every service. + +## Moving-object observation packages + +The Emilylakdawalla proposal supplies useful consumers for the shared pipeline. Its reported observation counts and photometry are proposal claims until their source bytes and scientific checks are reproduced; this integration does not publish those measurements. + +- **Designations:** the object catalogue owns explicit aliases, including numbered and provisional designations. The target resolver consumes them, preserves collisions as ambiguity and never repurposes `systemName` as a designation. Archive queries carry those declared names and retain the archive's own target identifier. +- **Discovery versus detection:** SSOIS footprints and predicted WISE intersections belong in the existing investigation records. An image crossing is not a detection, and a catalogue association is not calibrated photometry. A future promotion needs a pinned exposure, epoch, positional residual, motion/quality/background checks and a receipt for the intended measurement. Name-only empty searches remain explicitly scoped; they cannot exclude incidental detections. +- **Native source closure:** use the existing source manifest and source-observation intake for pinned images. Preserve original compressed bytes separately from decoded files, and include headers, labels, external format files and calibration companions required by the selected decoder. Archive or packaging success establishes origin and integrity only. +- **Measurement products:** MPC ADES astrometry and SDSS MOC photometry need shared importers and native table qualification before they can enter selectable products. `photometry` is already a product kind, but the generic source decoder does not yet qualify that catalogue product; `astrometry` has no supported product route. Neither gap should be hidden by relabelling those rows as images. An astrometry request must not invent a wavelength band merely to satisfy the current query contract; positional uncertainty must remain distinct from image PSF resolution. Keep MPC magnitudes as heterogeneous reported attributes until calibrated; retain SDSS errors, flags, passbands and frame identities. +- **Rendering:** unresolved multiband color is integrated photometry. It cannot become a geographic surface map or supply invented shape, radius, albedo or rotation values. Geometry and a body display package are independent of these pipeline changes. + +The relevant next provider implementations are MPC ADES and SDSS catalogue/frame qualification through the existing acquisition, source-product, qualification and output owners. They require pinned upstream fixtures and reviewed attribution/reuse terms. Body-specific packages, SSOIS discovery and WISE detection qualification remain separate work; no per-asteroid query branch is introduced. + +The VO interoperability review adds standards-derived cases and local HTTP integration coverage: 25 access, metadata, coverage and saved-session tests; six EPN normalization cases; and two PyVO/public-query parameter-forwarding tests pass. Scoped Telescope TypeScript passes. This pass fixes standard SODA declarations, descriptor-based DataLink nesting and EPN time/spatial metadata; it does not repeat live archive transfers. + +Earlier validation at `3472201eeb` includes 11 package-extraction tests, 44 target/query tests, product-record tests, legacy sessions and native export/handoff checks. Those owners are unchanged by this review. The catalogue alias-loader assertion passed, while its containing object-schema suite retains one unrelated discovery-path expectation failure (6/7). The full tools typecheck remains blocked by missing generated modules; these are not reported as full-suite passes. diff --git a/packages/telescope/README.md b/packages/telescope/README.md index 2c90f8ec72..4b7f4f7eb2 100644 --- a/packages/telescope/README.md +++ b/packages/telescope/README.md @@ -31,3 +31,138 @@ Choose a number from your saved query. It is not a fixed observation ID or a cla Use `--json` for machine-readable stdout and `--verbose` for detailed evidence. Progress goes to stderr. Exit codes: **0** query has retrievable choices or delivered product fulfills the request; **1** operation failed; **2** invalid arguments; **3** no retrievable choices or delivered data still has unresolved requirements; **4** delivered product refuses the request. A successful download does not imply exit 0. The wrapper and scientific implementation remain separate versioned components: updating this npm package does not update the checkout's science code. `telescope --version` reports the wrapper version; each product receipt records the scientific software and inputs used. + +## Archive products + +Scientific queries also inspect the bounded ESO/ALMA ObsCore and ESA PSA EPN-TAP services through PyVO. Their results enter the same saved choices, `get`, `outputs` and `export` commands. An advertised synchronous SODA service can fulfill an explicit ICRS cutout; failed subsets never fall back to a full download. Direct FITS and supported single-science-file ZIP/TAR products retain their complete pinned input set. + +Use `telescope help` for region, frame and byte/member limits. `get --offline` replays an already delivered, pinned artifact without a remote refresh; it does not requalify it with current software. Acquisition verifies origin and integrity, while scientific request satisfaction can remain unresolved. [Protocol ownership, evidence and limitations](../../docs/vo-observation-access.md). + +## Outputs + +After `get`, inspect what the delivered product can support: + +```sh +telescope outputs runs/eris/pick-1/result.json +telescope export runs/eris/pick-1/result.json --output image --hdu 1 --plane 95 --out figures/eris-plane +telescope export runs/eris/pick-1/result.json --output spectrum --hdu 1 --pixel 25,27 --out figures/eris-pixel +telescope export runs/eris/pick-1/result.json --output band-image --hdu 1 --band 2.2,2.4 --out figures/eris-band +telescope export runs/eris/pick-1/result.json --output aperture-spectrum --hdu 1 --aperture 19,24,25,30 --background 29,24,35,30 --out figures/eris-aperture +telescope export runs/eris/pick-1/result.json --output feature-map --hdu 1 --band 2.30,2.34 --continuum 2.26,2.29,2.35,2.38 --out figures/eris-feature +``` + +Selectors are zero-based and explicit. The adapter exports qualified FITS images, pixel/region spectra, +band images and continuum-subtracted feature maps to FITS/ECSV, PNG, SVG, CSV and a product record. It uses the same masks, +units and wavelength coordinates as qualification. CSV blanks preserve excluded samples; +plots do not bridge them. Supplied variance/inverse variance is converted to standard deviation +in the science unit. + +- `band-image` returns a mean weighted by spectral bin overlap with `--band`, in the original + science unit. Partial boundary bins contribute their overlap widths; descending axes work too. +- `aperture-spectrum` returns the arithmetic mean over a fixed rectangular pixel region. + Bounds are `X0,Y0,X1,Y1`, with exclusive upper bounds. Choose a disjoint `--background` box + to subtract its mean, or explicitly choose `--background none`. This is a mean per-pixel + quantity, not total source flux; there is no solid-angle conversion or aperture correction. +- `feature-map` integrates the residual after subtracting a linear continuum anchored by the + weighted means of two bracketing `--continuum` bands. Positive values are emission and + negative values absorption relative to that continuum. Units are the source unit × µm; + a frequency density integrated over wavelength is not a bolometric flux. The map makes + no chemical-identification or detection-significance claim. + +Aggregation requires every contributing sample to be valid for an output pixel/channel. +It does not silently change the aperture or renormalize around spectral gaps. Band/feature +maps require qualified bin edges; tabulated wavelength centers alone are insufficient. +Extraction uses temporary file-backed arrays, with no one-million-pixel CLI limit. +FITS/CSV retain the full native grid; figures use a recorded nearest-sample stride +when an axis exceeds 1600 pixels. Reader and serialization memory/disk costs remain. +PDS and ISIS products enter the same plotting path after decoding by their existing +owners. Select ambiguous PDS arrays with `--structure NAME` and use `--hdu 0`. + +Astropy `NDDataArray` owns weighted arithmetic and standard-deviation propagation; css.earth +owns the selected regions, continuum definition and strict missing-sample policy. +Astropy WCSAxes, ImageNormalize and quantity_support own scientific plotting conventions; +Matplotlib renders the PNG/SVG tightly around the chart, labels and legend with a +0.12-inch gutter. PNG backgrounds are transparent; light labels suit dark backgrounds. +No Jdaviz installation, notebook or browser is required. + +Every export also returns a `data` path: `image.fits` for images or `spectrum.ecsv` for +spectra, written by Astropy. Images carry their source celestial WCS when it is separable +on the unchanged grid, BUNIT, MASK (1 = missing) and ERR when supplied. If coordinates are +absent or coupled to other axes, the figure uses pixels and the receipt explains why; +malformed WCS is refused. ECSV carries explicit wavelength/value units, masks, selection +and uncertainty policy. These files can be opened independently of css.earth. +The receipt pins both the scientific data and figure, and records coordinate frame, +linear display limits, colormap and WCS warnings. No new astrometric calibration is implied. + +Aggregate uncertainties default to omitted because covariance is unknown. Explicit +`--uncertainty independent` propagates validated per-sample variances, including the +background/continuum contributions, conditional on independent errors. Resampled pixels or +channels may violate that assumption. Figures label this condition; CSV and the receipt retain +it. No spatial/spectral resolution matching is performed when combining samples. + +[Real Eris examples and source hash](../../docs/virtual-telescopes.md#example-exports-eris-jwst-nirspec-ifu). + +Output directories must be new. Delivery files and their producing record are rechecked before +export, and files are checked again before publication. Outputs keep the original request and +its satisfaction result. Exporting a figure does not resolve missing science evidence. + +The existing astronomy Python environment now includes hash-pinned Matplotlib. Reinstall it +with `node tools/objects/astronomy-packages/toolchain.mts install` after pulling changed pins. +The npm package still does not install scientific dependencies automatically. + +Surface projection is explicit: + +```sh +telescope project MEASUREMENT/output.product.json --geometry navigation.json --out MAP +telescope export MAP/map.fits.product.json --output sphere --out SPHERE +``` + +The navigation file pins SPICE kernels and chooses WCS or a supported fitted disc. +`project` writes a body map and a figure. The sphere is a standalone HTML file using +the target's existing css.earth standard sphere and physical scale. CSS, JavaScript +and base64 images are embedded. The projection ellipsoid is recorded separately. Projection preserves +unknown beam resolution and does not qualify scientific publication. See the +[navigation contract and oracle](../../docs/virtual-telescopes.md#from-a-measurement-to-a-surface-and-sphere). +Physical 3D handoffs use an existing `point-field` or `density-volume` object package: + +```sh +telescope export src/objects/stellar-neighbourhood/object.json --output points --out stars +telescope export src/objects/milky-way/object.json --output volume --out galaxy +``` + +These copy the prepared renderer files and credits, validate them with the exact +application loaders, and write a pinned receipt. The physical frame and model +interpretation stay intact. Raw source datasets are referenced, not bundled. +A spectral cube still needs a justified physical reconstruction; this export does +not interpret wavelength as depth. Restore missing prepared inputs with the +repository's `setup:prepared --object=ID` command. + +Mercury's inactive interior image bindings are removed for the surface export; +its prepared geometry and camera remain unchanged. + +## Independent output checks + +The [output oracle](../../tools/objects/telescopes/output-oracle.mts) reads the original pinned +FITS data independently of the production reducer. Specutils 2.4.0 integrates the spectral +windows; Photutils 3.0.0 measures rectangular apertures. Native plane and pixel exports compare +directly with FITS slices. The reference tools are optional test dependencies, installed without +changing the production astronomy environment: + +```sh +output/toolchains/astroquery/env/bin/python -m venv --system-site-packages work/telescope-oracles/env +work/telescope-oracles/env/bin/python -m pip install -c tools/objects/astronomy-packages/requirements.lock -r tools/objects/astronomy-packages/oracle-requirements.txt +node tools/objects/telescopes/output-oracle.mts figures/eris-band work/telescope-oracles/env/bin/python output/oracles/eris-band +CSSEARTH_ORACLE_PYTHON="$PWD/work/telescope-oracles/env/bin/python" node --test tools/objects/telescopes/cube-outputs.test.mts +``` + +Each comparison writes a residual figure and a JSON report identifying the source hash, +reference versions, mask/unit agreement and maximum numerical difference. It fails above +1e-10 of the reference peak. All valid samples are compared; masked samples must agree too. +The normal tests also check hand-computed signals, continuum slopes, background subtraction, +partial-bin weighting and propagated errors. Package oracles are opt-in and only run when +`CSSEARTH_ORACLE_PYTHON` is set; the command above enables them. + +This verifies numerical extraction. Both readers still use Astropy FITS/WCS; neither verifies +archive calibration, unknown covariance, aperture corrections, molecular identity or detection +significance. Specutils mask interpolation is avoided by checking complete selected coverage +explicitly. Its line-flux function is called per spectrum, not on a multidimensional flux array. diff --git a/packages/telescope/src/help.mts b/packages/telescope/src/help.mts index f87a291922..62bd529451 100644 --- a/packages/telescope/src/help.mts +++ b/packages/telescope/src/help.mts @@ -2,9 +2,41 @@ export const HELP = `Telescope — retrieve a qualified telescope product for a telescope query TARGET --wavelength MIN,MAX --kind cube --any-time --min-arcsec N --out DIRECTORY telescope get DIRECTORY --pick N + telescope get DIRECTORY --pick N --offline + telescope outputs DIRECTORY/pick-N/result.json [--structure NAME] + telescope export DIRECTORY/pick-N/result.json --output image --hdu N [--structure NAME] --plane N --out DIRECTORY + telescope export DIRECTORY/pick-N/result.json --output spectrum --hdu N --pixel X,Y --out DIRECTORY + telescope export RESULT_JSON --output band-image --hdu N --band LO,HI --out DIRECTORY + telescope export RESULT_JSON --output aperture-spectrum --hdu N --aperture X0,Y0,X1,Y1 --background none --out DIRECTORY + telescope export RESULT_JSON --output feature-map --hdu N --band LO,HI --continuum L0,L1,R0,R1 --out DIRECTORY + +Physical object handoff (existing measured/modelled depth): + telescope export OBJECT_JSON --output points|volume --out DIRECTORY + Reuses the existing point/volume loaders; copies pinned renderer resources and credits. + +Surface outputs: + telescope project MEASUREMENT/output.product.json --geometry navigation.json --out MAP_DIRECTORY + telescope export MAP_DIRECTORY/map.fits.product.json --output sphere --out SPHERE_DIRECTORY + Navigation pins SPICE kernels and explicitly chooses WCS or disc registration. + Projection preserves unknown beam resolution and request satisfaction. See docs/virtual-telescopes.md. + +Aggregate outputs: + --background X0,Y0,X1,Y1 Subtract a disjoint region's mean spectrum + --uncertainty omit|independent Default omit; independent explicitly assumes no covariance + --band LO,HI Wavelength interval in micrometres + Apertures are fixed pixel boxes with exclusive upper bounds; spectra are region means. + Band images are wavelength-weighted means. Feature maps integrate a continuum residual. Query options use micrometres, arcseconds and kilometres: --from ISO --to ISO Time range instead of --any-time + --icrs-circle RA,DEC,RADIUS Explicit ICRS cutout, in degrees + --spectral-frame barycentric Permit advertised SODA BAND subsetting + --max-science-bytes N Science transfer bound (default 1 GiB) + --max-metadata-bytes N Metadata response bound (default 32 MiB) + --max-expanded-bytes N Expanded package bound (default 1 GiB) + --max-package-members N Package file bound (default 1024) + --max-link-depth N Nested DataLink edges (default 3) + --max-link-requests N Access-description requests (default 32) --min-km N --range-km N Required surface resolution --min-elements N --range-km N --radius-km N --continuum LEFT_MIN,LEFT_MAX,RIGHT_MIN,RIGHT_MAX @@ -17,7 +49,11 @@ Query options use micrometres, arcseconds and kilometres: Queries save immutable numbered choices in DIRECTORY/query.json. Get revalidates the choice, qualifies it if needed, and exports pinned data and evidence to DIRECTORY/pick-N/. -Native products only; surface publication remains telescope:publish-map. +Outputs support qualified FITS images, spectra, band images and feature maps. HDU, plane and +pixel indices are zero-based. Cubes require an explicit plane for image export. +Export writes FITS images or ECSV spectra, PNG, SVG, CSV and a pinned receipt. +The output directory must be new. No browser or viewer service is required. +Surface publication remains telescope:publish-map; physical 3D adapters need geometry. Sphere export prepares one standalone HTML file. Exit codes: 0 ready/fulfilled, 1 operation failed, 2 invalid arguments, 3 no retrievable choice or unresolved request, 4 refused request. The npm command accepts --workspace PATH (or CSSEARTH_WORKSPACE) for a css.earth science checkout. diff --git a/site/object-catalog.mts b/site/object-catalog.mts index e81aa36971..22e500d013 100644 --- a/site/object-catalog.mts +++ b/site/object-catalog.mts @@ -5,7 +5,8 @@ import type { ObjectClassification, ObjectDefinitionInput, ObjectEntry } from '. import type { NavigationDistance } from './navigation-distance.mts'; export interface CatalogContext { name?: string; color?: string; order?: number; } -export type CatalogEntry = ObjectEntry & { order?: number; context?: CatalogContext }; +/** Alternate scientific names owned by the object's package. They are not navigation labels or system membership. */ +export type CatalogEntry = ObjectEntry & { readonly aliases: readonly string[]; order?: number; context?: CatalogContext }; function classification(value: unknown): ObjectClassification { switch (value) { @@ -21,6 +22,14 @@ function order(value: unknown): number | undefined { return value; } +function aliases(value: unknown, id: string): readonly string[] { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value) || value.some(alias => typeof alias !== 'string' || !alias.trim())) { + throw new TypeError(`Invalid catalogue aliases: ${id}.`); + } + return Object.freeze([...value]); +} + /** Decode package metadata at both the build and application boundaries. */ export function catalogEntry(input: unknown, loadScene: ObjectDefinitionInput['loadScene'], distance: NavigationDistance, discovery?: ObjectDiscovery): CatalogEntry { if (!record(input) || input.schema !== 'cssearth-object@1' || typeof input.id !== 'string' || !record(input.properties)) { @@ -29,7 +38,7 @@ export function catalogEntry(input: unknown, loadScene: ObjectDefinitionInput['l const catalog = input.properties.catalog; if (!record(catalog)) throw new TypeError(`Missing catalogue entry: ${input.id}.`); const { name, systemName, color, distanceAu, description } = catalog; - const keys = ['name', 'systemName', 'classification', 'color', 'distanceAu', 'description', 'order', 'context', 'featured', 'illustrationLenses', 'orientationReference']; + const keys = ['name', 'systemName', 'classification', 'color', 'distanceAu', 'description', 'aliases', 'order', 'context', 'featured', 'illustrationLenses', 'orientationReference']; if (Object.keys(catalog).some(key => !keys.includes(key)) || typeof name !== 'string' || typeof systemName !== 'string' || typeof color !== 'string' || typeof distanceAu !== 'number' || typeof description !== 'string') throw new TypeError(`Invalid catalogue metadata: ${input.id}.`); if (catalog.orientationReference !== undefined && (!Number.isInteger(catalog.orientationReference) || Number(catalog.orientationReference) < 1)) throw new TypeError(`Invalid orientation reference: ${input.id}.`); @@ -50,5 +59,6 @@ export function catalogEntry(input: unknown, loadScene: ObjectDefinitionInput['l // validated as authored metadata but never published as a measured distance. return { ...defineObject({ id: input.id, name, systemName, color, distance, description, classification: classification(catalog.classification), route: `/${input.id}/`, - worldFrame: input.properties.worldFrame, discovery, loadScene }), order: order(catalog.order), ...(context ? { context } : {}) }; + worldFrame: input.properties.worldFrame, discovery, loadScene }), aliases: aliases(catalog.aliases, input.id), + order: order(catalog.order), ...(context ? { context } : {}) }; } diff --git a/site/test/object-schema.test.mts b/site/test/object-schema.test.mts index 5ca521a0da..e584687e0a 100644 --- a/site/test/object-schema.test.mts +++ b/site/test/object-schema.test.mts @@ -8,6 +8,7 @@ import { resolve } from "node:path"; import { authoredObjectFixture } from "./authored-object-fixture.mts"; import { defineObject, defineObjects, OBJECT_CLASSIFICATIONS } from "../object-schema.mts"; +import { catalogEntry } from '../object-catalog.mts'; import { SCENE_OBJECTS, requireSceneObject } from "../objects.mts"; import { parsePreparedWorldCameraFrame } from '../../src/renderers/css/dist/index.js'; import { @@ -50,6 +51,16 @@ test("defines one generic renderable-object contract", () => { assert.equal(objectRecord.worldFrame, null); }); +test('catalogue metadata carries only explicit scientific aliases', () => { + const descriptor = { schema: 'cssearth-object@1', id: 'fixture', properties: { catalog: { + name: 'Fixture', systemName: 'Test System', classification: 'dwarf-planet', color: '#abcdef', distanceAu: 1, + description: 'Prepared fixture object.', aliases: ['274860', '2009 RE26'], + } } }; + const entry = catalogEntry(descriptor, loadScene, testDistance(1)); + assert.deepEqual(entry.aliases, ['274860', '2009 RE26']); + assert.throws(() => catalogEntry({ ...descriptor, properties: { catalog: { ...descriptor.properties.catalog, aliases: [''] } } }, loadScene, testDistance(1)), /Invalid catalogue aliases/); +}); + test('world-frame capability is validated and copied at the registry boundary', () => { const frame = { referenceFrame: 'heliocentric-icrf', epochJdTt: 2451545, originM: [1, 2, 3], presentationToReference: [1, 0, 0, 0, -1, 0, 0, 0, 1], diff --git a/src/renderers/css/navigation/object-orbit.ts b/src/renderers/css/navigation/object-orbit.ts index e69ac4360b..45f3d3ce59 100644 --- a/src/renderers/css/navigation/object-orbit.ts +++ b/src/renderers/css/navigation/object-orbit.ts @@ -498,7 +498,7 @@ export function createRetainedCubicSkyOrbit({ flight.sample(frame.rotation); publish(); }; - if (windowTarget.matchMedia("(prefers-reduced-motion: reduce)").matches) { + if (transition?.durationMilliseconds === 0 || windowTarget.matchMedia("(prefers-reduced-motion: reduce)").matches) { sample(1); return Promise.resolve({ completed: true }); } diff --git a/src/renderers/css/validation/prepared-object.test.ts b/src/renderers/css/validation/prepared-object.test.ts index 643a397693..6c773c4bac 100644 --- a/src/renderers/css/validation/prepared-object.test.ts +++ b/src/renderers/css/validation/prepared-object.test.ts @@ -162,7 +162,9 @@ test('prepared lens transitions require bounded duration and an explicit zoom po transition: { durationMilliseconds: 650, preserveZoom: true } as unknown }; item(input.variants).navigation = { maximumZoom: camera.maximumZoom, camera: destination }; assert.equal(parsePreparedObjectRuntime(input), input); - for (const transition of [null, {}, { durationMilliseconds: 0, preserveZoom: true }, + destination.transition = { durationMilliseconds: 0, preserveZoom: true }; + assert.equal(parsePreparedObjectRuntime(input), input); + for (const transition of [null, {}, { durationMilliseconds: -1, preserveZoom: true }, { durationMilliseconds: 10001, preserveZoom: true }, { durationMilliseconds: 650, preserveZoom: 'true' }]) { destination.transition = transition; assert.throws(() => parsePreparedObjectRuntime(input), /camera transition/); diff --git a/src/renderers/css/validation/presentation.ts b/src/renderers/css/validation/presentation.ts index a1da9ce100..9fb097f372 100644 --- a/src/renderers/css/validation/presentation.ts +++ b/src/renderers/css/validation/presentation.ts @@ -37,8 +37,8 @@ export function requireVariants(value: unknown, tree: PreparedTree, resources: R const pose = record(navigation.camera, 'navigation camera', ['controlPitch', 'controlYaw', 'controlRoll', 'zoom', 'transition']); if (pose.transition !== undefined) { const transition = record(pose.transition, 'camera transition', ['durationMilliseconds', 'preserveZoom']); - const duration = positive(transition.durationMilliseconds, 'camera transition duration'); - if (duration > 10000) fail('camera transition exceeds 10 seconds'); + const duration = finite(transition.durationMilliseconds, 'camera transition duration'); + if (duration < 0 || duration > 10000) fail('camera transition duration must be between 0 and 10 seconds'); boolean(transition.preserveZoom, 'camera transition preserve zoom'); } if (pose.controlRoll !== undefined) finite(pose.controlRoll, 'navigation roll'); diff --git a/tests/fixtures/telescope-projection/europa-navigation.json b/tests/fixtures/telescope-projection/europa-navigation.json new file mode 100644 index 0000000000..cad2590cde --- /dev/null +++ b/tests/fixtures/telescope-projection/europa-navigation.json @@ -0,0 +1,59 @@ +{ + "schema": "cssearth-navigation-input@1", + "observer": "JWST", + "kernels": [ + { + "file": "kernels/naif0012.tls", + "source": "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls", + "bytes": 5257, + "sha256": "678e32bdb5a744117a467cd9601cd6b373f0e9bc9bbde1371d5eee39600a039b", + "role": "ephemeris" + }, + { + "file": "kernels/pck00011.tpc", + "source": "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/pck00011.tpc", + "bytes": 131226, + "sha256": "3dff7b1dbeceaa01f25467767d3fa25816051c85d162d1edf04acb310ee28bb1", + "role": "rotation" + }, + { + "file": "kernels/de440s.bsp", + "source": "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440s.bsp", + "bytes": 32726016, + "sha256": "c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2", + "role": "ephemeris" + }, + { + "file": "kernels/jup365.bsp", + "source": "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/satellites/jup365.bsp", + "bytes": 1136581632, + "sha256": "dbf016c01ba4d022154838000cf3f06962cf958ddc503a366f7fe8f81495c5cb", + "role": "ephemeris" + }, + { + "file": "kernels/jwst_rec.bsp", + "source": "https://naif.jpl.nasa.gov/pub/naif/JWST/kernels/spk/jwst_rec.bsp", + "bytes": 139507712, + "sha256": "6f2f9b66854926a07237301e1033e2cba59245ef26338828e5bf7e5aad92768f", + "role": "ephemeris" + } + ], + "registration": { + "method": "disc", + "parameters": [ + 24.71, + 25.14, + 4.875, + 0 + ], + "explanation": "Prior Europa 1250 blurred-disc fit: top-row-first centre [24.71,26.86] becomes native FITS [24.71,25.14]; radius 9.75/2 pixels. Rotation from celestial WCS. Input evidence: existing Europa jwst-band-maps.json; positional uncertainty is not propagated.", + "evidence": { + "file": "europa-registration.json", + "sha256": "0c1b43f6df7b875871bfb5fabdbce9cbf03b32638c6710595adcc7e1c349bf08", + "bytes": 25766 + } + }, + "width": 360, + "height": 180, + "maximumEmissionDegrees": 65 +} diff --git a/tests/fixtures/telescope-projection/europa-oracle.json b/tests/fixtures/telescope-projection/europa-oracle.json new file mode 100644 index 0000000000..9cda932547 --- /dev/null +++ b/tests/fixtures/telescope-projection/europa-oracle.json @@ -0,0 +1,156 @@ +{ + "oracle": "Analytic orthographic ray intersection with pinned triaxial ellipsoid; NumPy / Matplotlib", + "purpose": "independent projection reference; the HTML uses the existing standard sphere, not this ellipsoid renderer", + "samplePairsPreserved": true, + "emissionMaskVerified": true, + "pixelComparison": "not run: reference is not an HTML screenshot", + "versions": { + "numpy": "2.5.3", + "matplotlib": "3.11.2" + }, + "sceneParity": { + "runtimeSha256": "b39dddfec4e30954e7908e7893325b6caa6cf516fcdff0e27e03f75ef0d0d31a", + "contextSha256": "4330a638b653f9529d1089b0069353ba8669b7f447c055d7c8b929e1e75b7242", + "physicalCameraMountValidated": true, + "missingContextRejected": true, + "unchanged": [ + "tree", + "camera", + "facing", + "depthPartitions", + "surfaceHit", + "sky" + ], + "treeSha256": "4fea4a67e9eaa8ff90df28ed3d75a7523bccabca1d6718dd006dc28724700e95", + "mutationRejected": true + }, + "controlBinding": { + "lensIds": [ + "normal" + ], + "ready": true, + "actions": 1, + "missingControlRejected": true + }, + "selfContained": { + "inlineCss": true, + "inlineJavaScript": true, + "embeddedImages": 2, + "networkForbidden": true + }, + "inputs": { + "map": { + "sha256": "69333e2b7a98858e8b83f91aeb523774eef3d607b99368b00e0c2145249ef16e", + "bytes": 9318 + }, + "sphere": { + "sha256": "7ffef328bffc42df4ec224cdac472c815c7f5077efff7de167826f35642cab94", + "bytes": 8689 + } + }, + "source": "tools/objects/telescopes/sphere-oracle.mts", + "example": { + "cube": "jw01250-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "cubeSha256": "838c59a8b0ddcb8e7f464324e1a1515dcfe8f4a42c7c79b7d10e8f23f5ffe64a", + "selection": { + "kind": "band-image", + "hdu": 1, + "band": [ + 4.2, + 4.3 + ], + "uncertainty": "independent" + }, + "registrationSource": "https://github.com/layoutit/css.earth/blob/4ac4a4a9eb076d63760768e9f4ca3408882f2bcc/src/objects/europa/evidence/jwst-band-maps.json" + }, + "illustrations": [ + { + "file": "docs/images/telescopes/europa-projected-brightness.png", + "sha256": "9e2935a38528b9d8b4759bc398c80e1858e14783a48da4dffdbb1cc2ef99e754", + "bytes": 60808 + }, + { + "file": "docs/images/telescopes/europa-sphere-reference.png", + "sha256": "b54d17fe6835caa998424f60e63865e6c2188af450f4b0d411de692b03b82bb7", + "bytes": 213940 + }, + { + "file": "docs/images/telescopes/europa-sphere-html.png", + "sha256": "2aa5a78eafa9e9ed1b4e5ad3a1fa02b846d3974d72d335146f20f0010ed34614", + "bytes": 209323, + "kind": "browser screenshot of the standalone HTML at its initial view", + "viewport": { + "width": 1280, + "height": 720 + }, + "htmlSha256": "72cbf9b3aaaaa4cf594c5a0730eb0f4bf2bb7da7c80e82110dadab5700cdb896" + }, + { + "file": "docs/images/telescopes/europa-sphere-rotation.gif", + "sha256": "366ea7cd3fc920e65f1e10c34b4a4a8c2e6a27f95d5c9222a738e2230b1b3358", + "bytes": 2632417, + "kind": "browser drag sequence, played at 12.5 frames per second; illustration, not a performance recording", + "frames": 61, + "dimensions": [ + 960, + 540 + ], + "sourceViewport": [ + 1280, + 720 + ], + "htmlSha256": "72cbf9b3aaaaa4cf594c5a0730eb0f4bf2bb7da7c80e82110dadab5700cdb896" + } + ], + "browser": { + "transport": "local HTTP preview of the self-contained HTML", + "html": { + "path": "sphere.html", + "bytes": 8445876, + "sha256": "72cbf9b3aaaaa4cf594c5a0730eb0f4bf2bb7da7c80e82110dadab5700cdb896" + }, + "rendered": true, + "rotationAndWheelZoomChecked": true, + "initialFlight": false, + "firstDrag": { + "sceneTranslationZBeforePx": -75.0604, + "sceneTranslationZAfterPx": -75.0604, + "rotationChanged": true + }, + "consoleErrors": 0, + "screenshotComparison": "not performed" + }, + "presentationUpdate": { + "pngBackground": "transparent", + "bounds": "tight", + "gutterInches": 0.12, + "erisValues": [ + { + "kind": "image", + "csvSha256": "86132ee9b61bf95c9717f9857290b41f0c6daf9e5e6d5398f90dcf149ce8dd23", + "unchanged": true + }, + { + "kind": "spectrum", + "csvSha256": "fe31f8066ab945bbdaaf8d70aac9d614238ad7f1dbfe34f8f4b1ffe72616d51b", + "unchanged": true + }, + { + "kind": "band-image", + "csvSha256": "8011e33333f73500ead1dbc21b566a5c2e5e609558783428ae85125b0746b8b9", + "unchanged": true + }, + { + "kind": "aperture-spectrum", + "csvSha256": "4119662f5e8d689bed94bd51e9665da64ce235192b17fb0337ce80dd9442614a", + "unchanged": true + }, + { + "kind": "feature-map", + "csvSha256": "392f46a567354a0c5cbbbd86288fba2dd6032a29fd6992c00ff467c1ab484ef9", + "unchanged": true + } + ], + "europaValueSigmaEmissionUnchanged": true + } +} diff --git a/tests/fixtures/telescope-projection/europa-registration.json b/tests/fixtures/telescope-projection/europa-registration.json new file mode 100644 index 0000000000..362b2627a2 --- /dev/null +++ b/tests/fixtures/telescope-projection/europa-registration.json @@ -0,0 +1,843 @@ +{ + "schema": "cssearth-jwst-band-maps-evidence@1", + "maps": [ + { + "id": "carbon-dioxide", + "cubes": [ + { + "observation": "jw01250-o002_t001_nirspec_g395h-f290lp", + "program": "europa-1250", + "cube": "jw01250-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2022-11-23T08:18:02.305", + "end": "2022-11-23T08:31:49.687" + }, + "disc": { + "diameterPixels": 9.75, + "centrePixels": [ + 24.71, + 26.86 + ], + "blurPixels": 1, + "fitResidualOverPeak": 0.0255 + }, + "camera": { + "observerLatitude": 2.6916, + "observerWestLongitude": 93.7325, + "sunLatitude": 2.8984, + "sunWestLongitude": 83.4295, + "northAzimuthDegrees": 25.5436, + "rangeKm": 660327692 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0409, + "median": 0.0889, + "maximum": 0.1728 + } + } + }, + { + "observation": "jw04023-o001_t001_nirspec_g395h-f290lp", + "program": "europa-4023-o001", + "cube": "jw04023-o001_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2024-01-10T05:49:41.890", + "end": "2024-01-10T06:19:15.640" + }, + "disc": { + "diameterPixels": 9.33, + "centrePixels": [ + 23.86, + 27.83 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.025 + }, + "camera": { + "observerLatitude": 3.2911, + "observerWestLongitude": 154.5177, + "sunLatitude": 3.3623, + "sunWestLongitude": 143.4619, + "northAzimuthDegrees": 20.2438, + "rangeKm": 689881567 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.038, + "median": 0.0939, + "maximum": 0.1797 + } + } + }, + { + "observation": "jw04023-o002_t001_nirspec_g395h-f290lp", + "program": "europa-4023-o002", + "cube": "jw04023-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2024-09-23T08:36:38.748", + "end": "2024-09-23T09:06:14.290" + }, + "disc": { + "diameterPixels": 9.01, + "centrePixels": [ + 26.03, + 25.71 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.0312 + }, + "camera": { + "observerLatitude": 2.8077, + "observerWestLongitude": 254.13, + "sunLatitude": 2.8905, + "sunWestLongitude": 265.5217, + "northAzimuthDegrees": 3.0765, + "rangeKm": 714828968 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0142, + "median": 0.0399, + "maximum": 0.0718 + } + } + }, + { + "observation": "jw04023-o003_t001_nirspec_g395h-f290lp", + "program": "europa-4023-o003", + "cube": "jw04023-o003_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2024-09-27T16:47:46.002", + "end": "2024-09-27T17:17:17.767" + }, + "disc": { + "diameterPixels": 9.12, + "centrePixels": [ + 26.31, + 25.71 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.0326 + }, + "camera": { + "observerLatitude": 2.803, + "observerWestLongitude": 334.0185, + "sunLatitude": 2.8756, + "sunWestLongitude": 345.2105, + "northAzimuthDegrees": 2.9765, + "rangeKm": 705741807 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0286, + "median": 0.07, + "maximum": 0.1861 + } + } + }, + { + "observation": "jw09230-o001_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o001", + "cube": "jw09230-o001_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-02-10T17:26:26.345", + "end": "2025-02-10T17:56:08.670" + }, + "disc": { + "diameterPixels": 9.2, + "centrePixels": [ + 27.65, + 28.79 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.023 + }, + "camera": { + "observerLatitude": 2.6187, + "observerWestLongitude": 93.6104, + "sunLatitude": 2.4411, + "sunWestLongitude": 82.9836, + "northAzimuthDegrees": 7.4392, + "rangeKm": 700014166 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0402, + "median": 0.0911, + "maximum": 0.1757 + } + } + }, + { + "observation": "jw09230-o002_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o002", + "cube": "jw09230-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-02-11T08:24:53.684", + "end": "2025-02-11T08:54:25.514" + }, + "disc": { + "diameterPixels": 9.19, + "centrePixels": [ + 26.57, + 29.04 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.0246 + }, + "camera": { + "observerLatitude": 2.6191, + "observerWestLongitude": 156.8687, + "sunLatitude": 2.4409, + "sunWestLongitude": 146.1899, + "northAzimuthDegrees": 7.4468, + "rangeKm": 700868109 + }, + "map": { + "areaShare": 0.2886, + "depth": { + "minimum": 0.0346, + "median": 0.0919, + "maximum": 0.1798 + } + } + }, + { + "observation": "jw09230-o003_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o003", + "cube": "jw09230-o003_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-02-10T04:28:36.906", + "end": "2025-02-10T04:58:22.816" + }, + "disc": { + "diameterPixels": 9.21, + "centrePixels": [ + 26.33, + 28.77 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.0267 + }, + "camera": { + "observerLatitude": 2.6183, + "observerWestLongitude": 38.8901, + "sunLatitude": 2.4414, + "sunWestLongitude": 28.3049, + "northAzimuthDegrees": 7.4537, + "rangeKm": 699311288 + }, + "map": { + "areaShare": 0.2886, + "depth": { + "minimum": 0.0331, + "median": 0.0933, + "maximum": 0.1751 + } + } + }, + { + "observation": "jw09230-o004_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o004", + "cube": "jw09230-o004_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-01-29T05:35:28.399", + "end": "2025-01-29T06:05:10.724" + }, + "disc": { + "diameterPixels": 9.58, + "centrePixels": [ + 26.18, + 28.48 + ], + "blurPixels": 1.1, + "fitResidualOverPeak": 0.0333 + }, + "camera": { + "observerLatitude": 2.6544, + "observerWestLongitude": 267.2732, + "sunLatitude": 2.4863, + "sunWestLongitude": 257.724, + "northAzimuthDegrees": 7.4821, + "rangeKm": 672343547 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0097, + "median": 0.0416, + "maximum": 0.0726 + } + } + } + ], + "overlaps": [ + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw04023-o001_t001_nirspec_g395h-f290lp", + "cells": 6538, + "rmsDifference": 0.0114, + "correlation": 0.949 + }, + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o001_t001_nirspec_g395h-f290lp", + "cells": 14244, + "rmsDifference": 0.0036, + "correlation": 0.996 + }, + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 6245, + "rmsDifference": 0.0116, + "correlation": 0.949 + }, + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 7252, + "rmsDifference": 0.0096, + "correlation": 0.974 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw04023-o002_t001_nirspec_g395h-f290lp", + "cells": 2194, + "rmsDifference": 0.0458, + "correlation": 0.911 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o001_t001_nirspec_g395h-f290lp", + "cells": 6522, + "rmsDifference": 0.0119, + "correlation": 0.94 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 13946, + "rmsDifference": 0.0027, + "correlation": 0.997 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 775, + "rmsDifference": 0.0289, + "correlation": 0.997 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 1006, + "rmsDifference": 0.0355, + "correlation": 0.875 + }, + { + "first": "jw04023-o002_t001_nirspec_g395h-f290lp", + "second": "jw04023-o003_t001_nirspec_g395h-f290lp", + "cells": 4284, + "rmsDifference": 0.0141, + "correlation": 0.768 + }, + { + "first": "jw04023-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 2420, + "rmsDifference": 0.0425, + "correlation": 0.892 + }, + { + "first": "jw04023-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 12556, + "rmsDifference": 0.0048, + "correlation": 0.95 + }, + { + "first": "jw04023-o003_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 6033, + "rmsDifference": 0.0107, + "correlation": 0.967 + }, + { + "first": "jw04023-o003_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 5812, + "rmsDifference": 0.0228, + "correlation": 0.866 + }, + { + "first": "jw09230-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 6229, + "rmsDifference": 0.0121, + "correlation": 0.939 + }, + { + "first": "jw09230-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 7265, + "rmsDifference": 0.0101, + "correlation": 0.97 + }, + { + "first": "jw09230-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 594, + "rmsDifference": 0.0309, + "correlation": 0.991 + }, + { + "first": "jw09230-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 1194, + "rmsDifference": 0.0315, + "correlation": 0.865 + } + ], + "map": { + "cells": 45312, + "areaShare": 0.8883, + "depth": { + "minimum": 0.0102, + "median": 0.0648, + "maximum": 0.1813 + }, + "medianError": 0.0007, + "peak": { + "latitude": -1.5, + "westLongitude": 350.5 + } + } + }, + { + "id": "hydrogen-peroxide", + "cubes": [ + { + "observation": "jw01250-o002_t001_nirspec_g395h-f290lp", + "program": "europa-1250", + "cube": "jw01250-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2022-11-23T08:18:02.305", + "end": "2022-11-23T08:31:49.687" + }, + "disc": { + "diameterPixels": 9.75, + "centrePixels": [ + 24.72, + 26.73 + ], + "blurPixels": 0.9, + "fitResidualOverPeak": 0.0151 + }, + "camera": { + "observerLatitude": 2.6916, + "observerWestLongitude": 93.7325, + "sunLatitude": 2.8984, + "sunWestLongitude": 83.4295, + "northAzimuthDegrees": 25.5436, + "rangeKm": 660327692 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": -0.0087, + "median": 0.0451, + "maximum": 0.0811 + } + } + }, + { + "observation": "jw04023-o001_t001_nirspec_g395h-f290lp", + "program": "europa-4023-o001", + "cube": "jw04023-o001_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2024-01-10T05:49:41.890", + "end": "2024-01-10T06:19:15.640" + }, + "disc": { + "diameterPixels": 9.33, + "centrePixels": [ + 24.18, + 27.82 + ], + "blurPixels": 0.9, + "fitResidualOverPeak": 0.0241 + }, + "camera": { + "observerLatitude": 3.2911, + "observerWestLongitude": 154.5177, + "sunLatitude": 3.3623, + "sunWestLongitude": 143.4619, + "northAzimuthDegrees": 20.2438, + "rangeKm": 689881567 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0043, + "median": 0.0321, + "maximum": 0.0829 + } + } + }, + { + "observation": "jw04023-o002_t001_nirspec_g395h-f290lp", + "program": "europa-4023-o002", + "cube": "jw04023-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2024-09-23T08:36:38.748", + "end": "2024-09-23T09:06:14.290" + }, + "disc": { + "diameterPixels": 9.01, + "centrePixels": [ + 26.14, + 25.78 + ], + "blurPixels": 1, + "fitResidualOverPeak": 0.0255 + }, + "camera": { + "observerLatitude": 2.8077, + "observerWestLongitude": 254.13, + "sunLatitude": 2.8905, + "sunWestLongitude": 265.5217, + "northAzimuthDegrees": 3.0765, + "rangeKm": 714828968 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": -0.0155, + "median": 0.0138, + "maximum": 0.0509 + } + } + }, + { + "observation": "jw04023-o003_t001_nirspec_g395h-f290lp", + "program": "europa-4023-o003", + "cube": "jw04023-o003_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2024-09-27T16:47:46.002", + "end": "2024-09-27T17:17:17.767" + }, + "disc": { + "diameterPixels": 9.12, + "centrePixels": [ + 26.08, + 25.78 + ], + "blurPixels": 1, + "fitResidualOverPeak": 0.0247 + }, + "camera": { + "observerLatitude": 2.803, + "observerWestLongitude": 334.0185, + "sunLatitude": 2.8756, + "sunWestLongitude": 345.2105, + "northAzimuthDegrees": 2.9765, + "rangeKm": 705741807 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": 0.0003, + "median": 0.0161, + "maximum": 0.0662 + } + } + }, + { + "observation": "jw09230-o001_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o001", + "cube": "jw09230-o001_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-02-10T17:26:26.345", + "end": "2025-02-10T17:56:08.670" + }, + "disc": { + "diameterPixels": 9.2, + "centrePixels": [ + 27.59, + 28.66 + ], + "blurPixels": 1, + "fitResidualOverPeak": 0.0139 + }, + "camera": { + "observerLatitude": 2.6187, + "observerWestLongitude": 93.6104, + "sunLatitude": 2.4411, + "sunWestLongitude": 82.9836, + "northAzimuthDegrees": 7.4392, + "rangeKm": 700014166 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": -0.0261, + "median": 0.0411, + "maximum": 0.0917 + } + } + }, + { + "observation": "jw09230-o002_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o002", + "cube": "jw09230-o002_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-02-11T08:24:53.684", + "end": "2025-02-11T08:54:25.514" + }, + "disc": { + "diameterPixels": 9.19, + "centrePixels": [ + 26.91, + 28.97 + ], + "blurPixels": 0.9, + "fitResidualOverPeak": 0.0231 + }, + "camera": { + "observerLatitude": 2.6191, + "observerWestLongitude": 156.8687, + "sunLatitude": 2.4409, + "sunWestLongitude": 146.1899, + "northAzimuthDegrees": 7.4468, + "rangeKm": 700868109 + }, + "map": { + "areaShare": 0.2886, + "depth": { + "minimum": 0.0006, + "median": 0.0326, + "maximum": 0.0702 + } + } + }, + { + "observation": "jw09230-o003_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o003", + "cube": "jw09230-o003_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-02-10T04:28:36.906", + "end": "2025-02-10T04:58:22.816" + }, + "disc": { + "diameterPixels": 9.21, + "centrePixels": [ + 25.96, + 28.64 + ], + "blurPixels": 1, + "fitResidualOverPeak": 0.0152 + }, + "camera": { + "observerLatitude": 2.6183, + "observerWestLongitude": 38.8901, + "sunLatitude": 2.4414, + "sunWestLongitude": 28.3049, + "northAzimuthDegrees": 7.4537, + "rangeKm": 699311288 + }, + "map": { + "areaShare": 0.2886, + "depth": { + "minimum": -0.011, + "median": 0.0307, + "maximum": 0.1007 + } + } + }, + { + "observation": "jw09230-o004_t001_nirspec_g395h-f290lp", + "program": "europa-9230-o004", + "cube": "jw09230-o004_t001_nirspec_g395h-f290lp_s3d.fits", + "exposure": { + "start": "2025-01-29T05:35:28.399", + "end": "2025-01-29T06:05:10.724" + }, + "disc": { + "diameterPixels": 9.58, + "centrePixels": [ + 26.26, + 28.45 + ], + "blurPixels": 1, + "fitResidualOverPeak": 0.0255 + }, + "camera": { + "observerLatitude": 2.6544, + "observerWestLongitude": 267.2732, + "sunLatitude": 2.4863, + "sunWestLongitude": 257.724, + "northAzimuthDegrees": 7.4821, + "rangeKm": 672343547 + }, + "map": { + "areaShare": 0.2887, + "depth": { + "minimum": -0.0039, + "median": 0.0139, + "maximum": 0.026 + } + } + } + ], + "overlaps": [ + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw04023-o001_t001_nirspec_g395h-f290lp", + "cells": 6538, + "rmsDifference": 0.0101, + "correlation": 0.816 + }, + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o001_t001_nirspec_g395h-f290lp", + "cells": 14244, + "rmsDifference": 0.0099, + "correlation": 0.769 + }, + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 6245, + "rmsDifference": 0.0094, + "correlation": 0.828 + }, + { + "first": "jw01250-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 7252, + "rmsDifference": 0.0119, + "correlation": 0.616 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw04023-o002_t001_nirspec_g395h-f290lp", + "cells": 2194, + "rmsDifference": 0.0085, + "correlation": 0.175 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o001_t001_nirspec_g395h-f290lp", + "cells": 6522, + "rmsDifference": 0.0104, + "correlation": 0.793 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 13946, + "rmsDifference": 0.0065, + "correlation": 0.869 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 775, + "rmsDifference": 0.0246, + "correlation": -0.183 + }, + { + "first": "jw04023-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 1006, + "rmsDifference": 0.0076, + "correlation": 0.309 + }, + { + "first": "jw04023-o002_t001_nirspec_g395h-f290lp", + "second": "jw04023-o003_t001_nirspec_g395h-f290lp", + "cells": 4284, + "rmsDifference": 0.0087, + "correlation": -0.144 + }, + { + "first": "jw04023-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 2420, + "rmsDifference": 0.011, + "correlation": 0.035 + }, + { + "first": "jw04023-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 12556, + "rmsDifference": 0.0056, + "correlation": 0.164 + }, + { + "first": "jw04023-o003_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 6033, + "rmsDifference": 0.0083, + "correlation": 0.578 + }, + { + "first": "jw04023-o003_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 5812, + "rmsDifference": 0.0039, + "correlation": 0.233 + }, + { + "first": "jw09230-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o002_t001_nirspec_g395h-f290lp", + "cells": 6229, + "rmsDifference": 0.0097, + "correlation": 0.798 + }, + { + "first": "jw09230-o001_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 7265, + "rmsDifference": 0.0106, + "correlation": 0.72 + }, + { + "first": "jw09230-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o003_t001_nirspec_g395h-f290lp", + "cells": 594, + "rmsDifference": 0.0193, + "correlation": 0.111 + }, + { + "first": "jw09230-o002_t001_nirspec_g395h-f290lp", + "second": "jw09230-o004_t001_nirspec_g395h-f290lp", + "cells": 1194, + "rmsDifference": 0.0086, + "correlation": 0.519 + } + ], + "map": { + "cells": 45312, + "areaShare": 0.8883, + "depth": { + "minimum": -0.011, + "median": 0.0234, + "maximum": 0.0638 + }, + "medianError": 0.0005, + "peak": { + "latitude": -7.5, + "westLongitude": 77.5 + } + } + } + ] +} diff --git a/tests/fixtures/telescope-projection/output-handoffs.json b/tests/fixtures/telescope-projection/output-handoffs.json new file mode 100644 index 0000000000..077cbc0eec --- /dev/null +++ b/tests/fixtures/telescope-projection/output-handoffs.json @@ -0,0 +1,243 @@ +{ + "schema": "cssearth-telescope-output-checks@1", + "testedBase": "9a93ce772b4376f54844a8b5094ba0097563d66a", + "scope": "Native exports, large arrays, physical-object handoffs, inactive sphere assets", + "tests": { + "existingFocusedPassed": 25, + "newHandoffPassed": 3, + "inactiveSphereBindingsPassed": 1, + "scopedTypeScript": "passed" + }, + "native": { + "enceladus": { + "outputs": [ + { + "path": "figure.png", + "bytes": 52877, + "sha256": "b24dafab35bfbfbae854ceff026fbfa313731686f51d8cbeb113810f31ec787f" + }, + { + "path": "figure.svg", + "bytes": 32020, + "sha256": "942113f6a4de210c4fe46e4ff2052f913d5addfa7930b2f44df4831f1e045824" + }, + { + "path": "values.csv", + "bytes": 7769, + "sha256": "4f2661725834af3b91dc2231a0f4bf7b614c3da7e754ebbebdb118cf8619473c" + }, + { + "path": "spectrum.ecsv", + "bytes": 9311, + "sha256": "05e14b6af0ca26b5110925434018cf131d0d6dfd7090f56080d1ef9bb97b9bf4" + } + ], + "selection": { + "kind": "spectrum", + "hdu": 0, + "pixel": [ + 10, + 10 + ] + }, + "quality": { + "policy": "finite-science; DQ/MASK=0; finite nonnegative uncertainty when supplied", + "samples": 139264, + "finite": 139264, + "usable": 139264, + "flagged": 0, + "invalidUncertainty": 0, + "mask": null + }, + "uncertainty": { + "status": "unknown", + "kind": null, + "structure": null + }, + "sourceSatisfaction": "unresolved" + }, + "comet-67p": { + "outputs": [ + { + "path": "figure.png", + "bytes": 41368, + "sha256": "5327593ac88a47acacda0f22bc2220d00295fa3a9548c4d156e39f17f5f7cf7d" + }, + { + "path": "figure.svg", + "bytes": 40855, + "sha256": "c98a6d8aa818a98841865a4f5ae472f68fbc1cb5fc4094e5ea752301a8bbe6e8" + }, + { + "path": "values.csv", + "bytes": 50215671, + "sha256": "af8244d35828165927f162021abcfb7a4627d52780f115211ee270c3bd1426db" + }, + { + "path": "image.fits", + "bytes": 71314560, + "sha256": "c16c7156f85340cca673a314d3f675070bda4867e21e21a4c792373ae824e5b9" + } + ], + "selection": { + "kind": "image", + "hdu": 0, + "structure": "IMAGE" + }, + "quality": { + "policy": "finite-science; DQ/MASK=0; finite nonnegative uncertainty when supplied", + "samples": 4194304, + "finite": 4194304, + "usable": 26917, + "flagged": 4167387, + "invalidUncertainty": 0, + "mask": "MASK" + }, + "uncertainty": { + "status": "validated", + "kind": "standard-deviation", + "structure": "ERR" + }, + "sourceSatisfaction": "unresolved" + }, + "moon": { + "outputs": [ + { + "path": "figure.png", + "bytes": 664370, + "sha256": "8ccffcf4082dea35661a4e227fc36b234bad9eb7ed4154a28cd71fce7f48f8c2" + }, + { + "path": "figure.svg", + "bytes": 383516, + "sha256": "2f0466761e51162f8548a611abc4e0bbeb5cf66b3d58ba5237ebb5b8280bfd48" + }, + { + "path": "values.csv", + "bytes": 355363242, + "sha256": "05a794045ac06b37f55c1e8448e013ba7a4a7f5d48e62efa4401355d8710ee2a" + }, + { + "path": "image.fits", + "bytes": 149304960, + "sha256": "08def453bba204adbf49a75d1eda5f4cf6fe6504e81cc6e968d334193173aa13" + } + ], + "selection": { + "kind": "image", + "hdu": 0 + }, + "quality": { + "policy": "finite-science; DQ/MASK=0; finite nonnegative uncertainty when supplied", + "samples": 16588800, + "finite": 16588800, + "usable": 16588800, + "flagged": 0, + "invalidUncertainty": 0, + "mask": null + }, + "sourceSatisfaction": "unresolved", + "labelScaling": { + "factor": 0.5, + "offset": 1737400, + "owner": "pdr.get_scaled" + }, + "normalization": { + "owner": "astropy.visualization.ImageNormalize", + "stretch": "linear", + "minimum": 1728418.5, + "maximum": 1748085.5, + "colormap": "viridis", + "missing": "transparent" + } + } + }, + "spatial": { + "points": { + "target": "stellar-neighbourhood", + "files": 7, + "byteIdenticalPortableRoundTrip": true, + "modifiedTextureRejected": true, + "frame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + 0, + 0, + 0 + ], + "localToReferenceXyzw": [ + 0, + 0, + 0, + 1 + ], + "metersPerUnit": 30856775814913670, + "boundsUnits": { + "min": [ + -1024, + -1024, + -1024 + ], + "max": [ + 1024, + 1024, + 1024 + ] + } + } + }, + "volume": { + "target": "milky-way", + "files": 472, + "byteIdenticalPortableRoundTrip": true, + "modifiedTextureRejected": true, + "frame": { + "referenceFrame": "sun-icrf", + "epochJdTt": 2461286.5, + "originM": [ + -13547678355343626000, + -215634148836467800000, + -119449188638369900000 + ], + "localToReferenceXyzw": [ + 0.26949593540448213, + -0.4510814161748912, + -0.25008111046409387, + 0.8132385473962505 + ], + "metersPerUnit": 60000000000000000000, + "boundsUnits": { + "min": [ + -10, + -10, + -1.25 + ], + "max": [ + 10, + 10, + 1.25 + ] + } + } + } + }, + "mercury": { + "target": "mercury", + "treeGeometryExact": true, + "inactiveBindings": [ + "--mercury-interior-outer-image", + "--mercury-interior-outer-poles-image" + ], + "bytes": 9358453 + }, + "limits": [ + "Mercury browser opening blocked by local file URL policy; structural parity only.", + "Physical handoffs consume existing physical object packages; no new spectral-cube depth reconstruction.", + "PDS quality policy retains zero flags only; most of the 67P example is masked." + ], + "checks": { + "updatedEuropaOracle": "passed", + "pdsScalingRegression": "passed" + } +} diff --git a/tests/fixtures/telescope-vo/README.md b/tests/fixtures/telescope-vo/README.md new file mode 100644 index 0000000000..746de60529 --- /dev/null +++ b/tests/fixtures/telescope-vo/README.md @@ -0,0 +1,19 @@ +# VO boundary fixtures + +Captured on 2026-09-20 using the repository's pinned PyVO 1.9.1 during the independent review of PR #423 at `cbcad927220c95853260363c2a6a4cd8ff6002c8`. + +| File | Provenance and supported claim | +| --- | --- | +| `alma-obscore.xml` | Original ALMA TAP response body. Two rows have distinct observation IDs but the same publisher ID. The nine-character MIME field is malformed. SHA-256 `ea9d3eda733a4299d5b98fb2110b2f0cf26e410fafc0fcbbeb5b84621f49226f`. | +| `eso-links.xml` | **PyVO reserialization, not original HTTP bytes.** ESO advertises a fixed dataset ID and CIRCLE but no BAND. SHA-256 `10aa66b0a724d8aab1cc0df7b28dfd7e09b8136baeed4720503ee7f4c4d4a80c`. | +| `eso-circle.fits` | Actual 290,880-byte response for ICRS circle 88.792938, 7.407063, radius 0.3/3600 degrees; 166×166 primary array. SHA-256 `fd2a2d371e121bb50f64d781ac57b60f2f76d2c25d71f1c6a5ab9b5e262def60`. This is a transfer/format fixture, not a calibration or region-coverage oracle. | +| `eso-obscore.xml` | Original ESO TAP response to `SELECT TOP 1 * FROM ivoa.ObsCore WHERE dataproduct_type='image' AND s_ra BETWEEN 88.78 AND 88.81 AND s_dec BETWEEN 7.39 AND 7.42`. Captured by the new boundary; reproduces zero-dimensional masked values. SHA-256 `0aee7b10fa96ed66c0de76da16f917e3674eb1fc1904660a422aa9f86ffb0a22`. | +| `psa-epn.xml` | Original PSA TAP response to `SELECT TOP 1 * FROM psa.epn_core WHERE target_name='Mars'`. Captured by the new boundary; tests EPN field metadata and missing values. SHA-256 `9ee97fdf5bd1a48e7701f556484d42943a33b9fc532a34d68fd360e440aa6b85`. | + +Source services: [ALMA TAP](https://almascience.eso.org/tap), [ESO DataLink](https://archive.eso.org/datalink/links?ID=ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647), [ESO SODA](https://dataportal.eso.org/dataPortal/soda/sync). + +Tests may replace the ESO service endpoint with a local test server. Those responses are synthetic protocol fixtures and make no live-service claim. The ESO fixed-ID UCD `meta.id;meta.dataset` compatibility is limited to that documented declaration; missing parameters are never invented. + +The access-standards tests derive positive ID/BAND declarations from [SODA 1.0 §3.5 and §4](https://www.ivoa.net/documents/SODA/20170517/REC-SODA-1.0.html); the ESO exception is tested separately. Nested DataLink tests exercise endpoint-plus-parameter identity, traversal limits and original-response pins. `links-boundary.test.mts` also serves synthetic responses over local HTTP through the actual PyVO boundary and public query loader, checking opaque ID encoding and descriptor traversal. None establishes live ALMA service support. + +The EPN time fixtures are synthetic VOTables based on [EPN-TAP 2.0 time parameters](https://www.ivoa.net/documents/EPNTAP/20220822/REC-EPNTAP-2.0.html). They distinguish default UTC, explicit scales, conflicting declarations, unresolved references and missing units. These validate protocol normalization, not astrophysical timing corrections or the completeness of PSA's metadata. diff --git a/tests/fixtures/telescope-vo/alma-obscore.xml b/tests/fixtures/telescope-vo/alma-obscore.xml new file mode 100644 index 0000000000..947785155b --- /dev/null +++ b/tests/fixtures/telescope-vo/alma-obscore.xml @@ -0,0 +1,84 @@ + + + + + + + + + publisher dataset identifier + + + internal dataset identifier + + + name of intended target + + + RA of central coordinates + + + DEC of central coordinates + + + start time of observation (MJD) + + + end time of observation (MJD) + + + start spectral coordinate value + + + stop spectral coordinate value + + + URL to download the data + + + Content format of the data + + + calibration level (2 or 3). 2 if product_type = MOUS, 3 if product_type = GOUS + + + type of product + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ADS/JAO.ALMA#2022.A.00026.Suid://A001/X360d/Xae.source.Betelgeuse.spw.25Betelgeuse88.79312263901077.40713844925590860159.46700960183.4385930.00138964790450319770.0014018251227735255https://almascience.org/datalink/sync?ID=uid://A001/X360d/Xaeapplication/x-votable+xml; content=datalink2cube
ADS/JAO.ALMA#2022.A.00026.Suid://A001/X360d/Xae.source.Betelgeuse.spw.27Betelgeuse88.79312263901077.40713844925590860159.46700960183.4385930.00137495756674832730.0013868775844155475https://almascience.org/datalink/sync?ID=uid://A001/X360d/Xaeapplication/x-votable+xml; content=datalink2cube
+ +
+
diff --git a/tests/fixtures/telescope-vo/eso-circle.fits b/tests/fixtures/telescope-vo/eso-circle.fits new file mode 100644 index 0000000000..616437c883 --- /dev/null +++ b/tests/fixtures/telescope-vo/eso-circle.fits @@ -0,0 +1,224 @@ +SIMPLE = T / conforms to FITS standard BITPIX = -64 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 166 NAXIS2 = 166 ORIGIN = 'ESO-PARANAL' / European Southern Observatory DATE = '2026-09-20T13:23:32' / Date this file was written TELESCOP= 'ESO-VLT-U3' / ESO INSTRUME= 'SPHERE ' / Instrument used. OBJECT = 'Betelgeuse' / Original target. RA = 88.79293874999999 / 5h55m10.3053s DEC = 7.40706388888889 / +7d24m25.43s EQUINOX = 2000.0 / Standard FK5 RADECSYS= 'FK5 ' / Coordinate system EXPTIME = 24.76298 / s MJD-OBS = 60647.20658711 / Obs start DATE-OBS= '2024-12-03T04:47:35.063' / Observing date UTC = 17252.000 / [s] 04:47:32.000 UTC LST = 17757.871 / [s] 04:55:57.871 LST PI-COI = 'UNKNOWN ' / PI-COI name. OBSERVER= 'UNKNOWN ' / Name of observer. ORIGFILE= 'SPHERE_ZIMPOL_Betelgeuse_P1_N_I_phase3.fits' / Original File Name HIERARCH ESO ADA ABSROT END = 0.00000 / [deg] Abs rot angle at exp end HIERARCH ESO ADA ABSROT PPOS = 'POS ' / sign of probe position HIERARCH ESO ADA ABSROT START = 0.00000 / [deg] Abs rot angle at exp start HIERARCH ESO ADA GUID DEC = 7.36306 / [deg] 07:21:47.0 Guide star DEC J2000 HIERARCH ESO ADA GUID RA = 88.814435 / [deg] 05:55:15.4 Guide star RA J2000 HIERARCH ESO ADA GUID STATUS = 'ON ' / Status of autoguider HIERARCH ESO ADA POSANG = 0.00000 / [deg] Position angle at start HIERARCH ESO AOS GARBCOL MODE = 'ON ' / SPARTA Garbage Collection HIERARCH ESO AOS HOCTR MODE = 'LEAKY ' / SPARTA high-order controller mode HIERARCH ESO AOS HOLOOP STATE = 'CLOSED_LOOP' / State of Visible High Order LoopHIERARCH ESO AOS IRLOOP STATE = 'OPEN_LOOP' / State of IR Differential Tip Tilt HIERARCH ESO AOS IROPTIM WEIGTMOD = 'STATIC ' / SPARTA IR Optimised Weighting MHIERARCH ESO AOS IRWFS MODE = 'DTTS2 ' / SPARTA Infrared WFS detector mode HIERARCH ESO AOS OPTIMTT KALMAN = 'TURB_ONLY' / SPARTA Image Tip Tilt Optimised HIERARCH ESO AOS OPTMODAL GAIN = 'ON ' / SPARTA Optimised Modal Gain HIERARCH ESO AOS PUPLOOP STATE = 'CLOSED_LOOP' / State of Pupil Loop HIERARCH ESO AOS SLAVING MODE = 'DYNAMIC ' / SPARTA current slaving mode HIERARCH ESO AOS TTLOOP STATE = 'CLOSED_LOOP' / State of Image Tip Tilt Loop HIERARCH ESO AOS VISOPTIM WEIGTMAP = 'STATIC ' / SPARTA Visible WFS Optimised WHIERARCH ESO AOS VISWFS MODE = 'GAIN_30_FREQ_1380Hz' / SPARTA Visible WFS detectHIERARCH ESO DET BINX = 2 / Setup binning factor along X HIERARCH ESO DET BINY = 2 / Setup binning factor along Y HIERARCH ESO DET CHIPS = 2 / Number of chips in the mosaic HIERARCH ESO DET DEV1 BOARD1 BCKPL= '000010805002' / Serial no (backplane) HIERARCH ESO DET DEV1 BOARD1 REV= '5.6.0 ' / Revision HIERARCH ESO DET DEV1 BOARD1 SERNO= '000011AA7F89' / Serial no (board) HIERARCH ESO DET DEV1 BOARD1 TRANS= '00001216C06A' / Serial no (transition boardHIERARCH ESO DET DEV1 BOARD1 TYPE= 'FEB ' / Type HIERARCH ESO DET DEV1 BOARD1 VERSION= 2 / Version HIERARCH ESO DET DEV1 BOARD2 BCKPL= '000010805002' / Serial no (backplane) HIERARCH ESO DET DEV1 BOARD2 REV= '5.6.0 ' / Revision HIERARCH ESO DET DEV1 BOARD2 SERNO= '000013A6D468' / Serial no (board) HIERARCH ESO DET DEV1 BOARD2 TRANS= '00001216C971' / Serial no (transition boardHIERARCH ESO DET DEV1 BOARD2 TYPE= 'FEB ' / Type HIERARCH ESO DET DEV1 BOARD2 VERSION= 2 / Version HIERARCH ESO DET DEV1 EXP RDTTIME= 26.364 / [s] Image readout time HIERARCH ESO DET DEV1 EXP XFERTIM= 26.749 / [s] Image transfer time HIERARCH ESO DET DEV1 ID = 'ZIMPOL ' / Detector system Id HIERARCH ESO DET DEV1 REV = '3.1.1 ' / Revision HIERARCH ESO DET DEV1 SHUT ID= 'eso-01 ' / Shutter unique identifier HIERARCH ESO DET DEV1 SHUT TMCLOS= 0.000 / [s] Time taken to close shutter HIERARCH ESO DET DEV1 SHUT TMOPEN= 0.000 / [s] Time taken to open shutter HIERARCH ESO DET DEV1 SHUT TYPE= 'nostatus' / Shutter type HIERARCH ESO DET DEV1 SOFW BASE= '273537 ' / Base software version HIERARCH ESO DET DEV1 SOFW DETMOD= 'Not defined' / Detector module revision HIERARCH ESO DET DEV1 SOFW MODE= 'NORMAL ' / Software operational mode HIERARCH ESO DET DEV1 SOFW OPT= '273537 ' / Optical software version HIERARCH ESO DET DID = 'ESO-VLT-DIC.NGCDCS,ESO-VLT-DIC.NGCCON' / Used diHIERARCH ESO DET DIT1 = 0.1000000 / [s] Actual subintegration time HIERARCH ESO DET DKTM = 0.1000000 / [s] Dark current time HIERARCH ESO DET EXP NO = 249 / Unique exposure ID number HIERARCH ESO DET EXP TYPE = 'Normal ' / Exposure type HIERARCH ESO DET NAME = 'NGC-ZIMPOL-DCS' / Name of detector system HIERARCH ESO DET NDIT = 22 / Number of Sub-Integrations HIERARCH ESO DET OUTPUTS = 4 / Number of outputs HIERARCH ESO DET READ CURID = 1 / Used readout mode id HIERARCH ESO DET READ CURNAME= 'FastPolarimetry' / Used readout mode name HIERARCH ESO DET SEQ1 EXPTIME= 24.7629800 / [s] Exposure Sequence Time HIERARCH ESO DET SEQ1 INITTIME= 0.5023296 / [s] Exposure Init Time HIERARCH ESO DET UIT1 = 0.1000000 / [s] User defined subintegration tiHIERARCH ESO DPR CATG = 'SCIENCE ' / Observation category HIERARCH ESO DPR TECH = 'POLARIMETRY,P1' / Observation technique HIERARCH ESO DPR TYPE = 'OBJECT ' / Observation type HIERARCH ESO INS COMB POLA = ' ' / Polarisation combination name. HIERARCH ESO INS COMB VCOR = 'V_NC_WF ' / Visible coronograph combination name. HIERARCH ESO INS DATE = '2000-06-16' / Instrument release date (yyyy-mm-dd). HIERARCH ESO INS3 DID1 = 'ZIMPOL_ICS-342802' / Data dictionary for INS. HIERARCH ESO INS3 DID2 = 'PRIMARY-FITS-1.2' / Data dictionary for INS. HIERARCH ESO INS3 DROT1 DEC = 72444.57909 / Telescope declination. HIERARCH ESO INS3 DROT1 ENC END = -90000 / Encoder position at end [Enc]. HIERARCH ESO INS3 DROT1 ENC START = -90000 / Encoder position at start [Enc]. HIERARCH ESO INS3 DROT1 END = -89.9720 / Position angle at end [deg]. HIERARCH ESO INS3 DROT1 MODE = 'STAT ' / Rotation mode for ZIMP comp rot. HIERARCH ESO INS3 DROT1 POSANG = 180.0000 / Position angle [deg]. HIERARCH ESO INS3 DROT1 RA = 55632.414497 / Telescope right ascension. HIERARCH ESO INS3 DROT1 START = -89.9720 / Position angle at start [deg]. HIERARCH ESO INS3 DROT2 DEC = 72444.57909 / Telescope declination. HIERARCH ESO INS3 DROT2 ENC END = 0 / Encoder position at end [Enc]. HIERARCH ESO INS3 DROT2 ENC START = 0 / Encoder position at start [Enc]. HIERARCH ESO INS3 DROT2 END = 0.0000 / Position angle at end [deg]. HIERARCH ESO INS3 DROT2 MODE = 'STAT ' / Rotation mode for ZIMP HWP rot. HIERARCH ESO INS3 DROT2 POSANG = 0.0000 / Position angle [deg]. HIERARCH ESO INS3 DROT2 RA = 55632.414497 / Telescope right ascension. HIERARCH ESO INS3 DROT2 START = 0.0000 / Position angle at start [deg]. HIERARCH ESO INS3 FLCM DELAY = 45 / ZIMPOL FLC delay compensation HIERARCH ESO INS3 FLCM MODULATE = T / ZIMPOL FLC modulation off/on (F/T) HIERARCH ESO INS3 FLCM MPERIOD = 1034 / ZIMPOL FLC modulation period HIERARCH ESO INS3 FLCM PHASE = 0 / ZIMPOL FLC modulation phase HIERARCH ESO INS3 FLCM STATUS = 7 / ZIMPOL FLC last status HIERARCH ESO INS3 FLPG VAL = 0.03800 / [mbar] ZIMPOL FLC pressure HIERARCH ESO INS3 ID = 'ZIMPOL/176642' / Instrument ID. HIERARCH ESO INS3 MODE = 'P1 ' / Instrument mode used. HIERARCH ESO INS3 OPTI1 ID = 'COMP ' / OPTIi unique ID. HIERARCH ESO INS3 OPTI1 NAME = 'COMP ' / ZIMPOL polarization compensator seleHIERARCH ESO INS3 OPTI1 NO = 2 / OPTIi slot number. HIERARCH ESO INS3 OPTI1 TYPE = 'LENS ' / OPTIi element. HIERARCH ESO INS3 OPTI2 ID = 'FILT_ND_2.0' / OPTIi unique ID. HIERARCH ESO INS3 OPTI2 NAME = 'ND_2.0 ' / ZIMPOL common filter HIERARCH ESO INS3 OPTI2 NO = 6 / OPTIi slot number. HIERARCH ESO INS3 OPTI2 TYPE = 'FILTER ' / OPTIi element. HIERARCH ESO INS3 OPTI3 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS3 OPTI3 NAME = 'OPEN ' / ZIMPOL HW plate selector HIERARCH ESO INS3 OPTI3 NO = 1 / OPTIi slot number. HIERARCH ESO INS3 OPTI3 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS3 OPTI4 ID = 'RETA_FLC' / OPTIi unique ID. HIERARCH ESO INS3 OPTI4 NAME = 'FLC ' / ZIMPOL modulator selector HIERARCH ESO INS3 OPTI4 NO = 2 / OPTIi slot number. HIERARCH ESO INS3 OPTI4 TYPE = 'RETA_FLC' / OPTIi element. HIERARCH ESO INS3 OPTI5 ID = 'FILT_V ' / OPTIi unique ID. HIERARCH ESO INS3 OPTI5 NAME = 'V ' / ZIMPOL filter in arm 1 HIERARCH ESO INS3 OPTI5 NO = 9 / OPTIi slot number. HIERARCH ESO INS3 OPTI5 TYPE = 'FILTER ' / OPTIi element. HIERARCH ESO INS3 OPTI6 ID = 'FILT_I_NB' / OPTIi unique ID. HIERARCH ESO INS3 OPTI6 NAME = 'N_I ' / ZIMPOL filter in arm 2 HIERARCH ESO INS3 OPTI6 NO = 5 / OPTIi slot number. HIERARCH ESO INS3 OPTI6 TYPE = 'FILTER ' / OPTIi element. HIERARCH ESO INS3 POS1 ENC = 30350 / [Enc] Absolute position. HIERARCH ESO INS3 POS1 POS = 25.0 / [deg] ZIMPOL compensator tilt angle. HIERARCH ESO INS3 POS1 SWSIM = T / If T, function is software simulated. HIERARCH ESO INS3 POS2 ENC = -6343 / [Enc] Absolute position. HIERARCH ESO INS3 POS2 POS = -20.1 / [pix] ZIMPOL tilt1 mirror position. HIERARCH ESO INS3 POS3 ENC = -11824 / [Enc] Absolute position. HIERARCH ESO INS3 POS3 POS = -14.0 / [pix] ZIMPOL tilt2 mirror position. HIERARCH ESO INS3 POS4 ENC = 5361 / [Enc] Absolute position. HIERARCH ESO INS3 POS4 POS = 6.1 / [pix] ZIMPOL tilt3 mirror position. HIERARCH ESO INS3 POS5 ENC = 0 / [Enc] Absolute position. HIERARCH ESO INS3 POS5 POS = 0.0 / [pix] ZIMPOL tilt4 mirror position. HIERARCH ESO INS3 POS6 ENC = -11808 / [Enc] Absolute position. HIERARCH ESO INS3 POS6 POS = -14.0 / [pix] ZIMPOL tilt5 mirror position. HIERARCH ESO INS3 POS7 ENC = -13143 / [Enc] Absolute position. HIERARCH ESO INS3 POS7 POS = -14.0 / [pix] ZIMPOL tilt6 mirror position. HIERARCH ESO INS3 SENS301 VAL = 3.76E-05 / [mbar] ZIMPOL detector P. HIERARCH ESO INS3 SENSOR1 ID = 'flom ' / Sensor device unique id. HIERARCH ESO INS3 SENSOR1 NAME = 'FLC modulator T' / Sensor device common name. HIERARCH ESO INS3 SWSIM = 'NORMAL ' / Software simulation. HIERARCH ESO INS3 TEMP301 ID = 'TEMP1 ' / Temperature sensor ID. HIERARCH ESO INS3 TEMP301 NAME = 'Temperature sensor' / Temperature sensor name.HIERARCH ESO INS3 TEMP301 VAL = 25.00 / [C] ZIMPOL FLC T. HIERARCH ESO INS3 TEMP302 VAL = 187.92 / [K] ZIMPOL detector T. HIERARCH ESO INS3 TEMP303 VAL = 188.00 / [K] ZIMPOL detector T setpoint. HIERARCH ESO INS3 TEMP304 VAL = 188.87 / [K] ZIMPOL detector T -spare. HIERARCH ESO INS3 TEMP305 VAL = 300.00 / [K] ZIMPOL detector T setpoint -spare. HIERARCH ESO INS3 TEMP306 VAL = 93.13 / [K] ZIMPOL cold plate T. HIERARCH ESO INS3 TEMP307 VAL = 93.00 / [K] ZIMPOL cold plate T setpoint. HIERARCH ESO INS3 TEMP308 VAL = 273.30 / [K] ZIMPOL exhausted gas T. HIERARCH ESO INS3 TEMP309 VAL = 273.00 / [K] ZIMPOL exhausted gas T setpoint. HIERARCH ESO INS3 TEMP310 VAL = 118.36 / [K] ZIMPOL sorption pump T. HIERARCH ESO INS3 TEMP311 VAL = 0.00 / [K] ZIMPOL sorption pump T setpoint. HIERARCH ESO INS3 TEMP312 ID = 'SETP ' / Temperature sensor ID. HIERARCH ESO INS3 TEMP312 NAME = 'FLC T set point' / Temperature sensor name. HIERARCH ESO INS3 TEMP312 VAL = 25.00 / [C] ZIMPOL FLC T set point. HIERARCH ESO INS4 ADC1 DEC = 72444.64222 / Telescope declination. HIERARCH ESO INS4 ADC1 ENC END = 431984 / Encoder position at end [Enc]. HIERARCH ESO INS4 ADC1 ENC START = 431974 / Encoder position at start [Enc]. HIERARCH ESO INS4 ADC1 END = 122.9920 / Position angle at end [deg]. HIERARCH ESO INS4 ADC1 MODE = 'AUTO ' / NIR ADC motor 1 compensation mode. HIERARCH ESO INS4 ADC1 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 ADC1 START = 122.9870 / Position angle at start [deg]. HIERARCH ESO INS4 ADC2 DEC = 72444.64222 / Telescope declination. HIERARCH ESO INS4 ADC2 ENC END = 397829 / Encoder position at end [Enc]. HIERARCH ESO INS4 ADC2 ENC START = 397853 / Encoder position at start [Enc]. HIERARCH ESO INS4 ADC2 END = 16.4645 / Position angle at end [deg]. HIERARCH ESO INS4 ADC2 MODE = 'AUTO ' / NIR ADC motor 2 compensation mode. HIERARCH ESO INS4 ADC2 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 ADC2 START = -45.7010 / Position angle at start [deg]. HIERARCH ESO INS4 ADC3 DEC = 72444.64222 / Telescope declination. HIERARCH ESO INS4 ADC3 ENC END = 203356 / Encoder position at end [Enc]. HIERARCH ESO INS4 ADC3 ENC START = 203547 / Encoder position at start [Enc]. HIERARCH ESO INS4 ADC3 END = -122.4430 / Position angle at end [deg]. HIERARCH ESO INS4 ADC3 MODE = 'AUTO ' / VIS ADC motor 1 compensation mode. HIERARCH ESO INS4 ADC3 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 ADC3 START = -122.3520 / Position angle at start [deg]. HIERARCH ESO INS4 ADC4 DEC = 72444.64222 / Telescope declination. HIERARCH ESO INS4 ADC4 ENC END = 431528 / Encoder position at end [Enc]. HIERARCH ESO INS4 ADC4 ENC START = 431517 / Encoder position at start [Enc]. HIERARCH ESO INS4 ADC4 END = -14.0365 / Position angle at end [deg]. HIERARCH ESO INS4 ADC4 MODE = 'AUTO ' / VIS ADC motor 2 compensation mode. HIERARCH ESO INS4 ADC4 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 ADC4 START = -14.0420 / Position angle at start [deg]. HIERARCH ESO INS4 BILZ ERROR = 0 / BILZ stabilization system error status HIERARCH ESO INS4 BILZ ID = 'bilz ' / BILZ stabilization system Device ID HIERARCH ESO INS4 BILZ INWORD = 1799 / BILZ stabilization input word HIERARCH ESO INS4 BILZ OUTWORD = 110 / BILZ stabilization output word HIERARCH ESO INS4 BILZ STATUS = 40964 / BILZ stabilization system status HIERARCH ESO INS4 BILZ SWSIM = F / If T, function is software simulated HIERARCH ESO INS4 COMB CAL = 'OBS ' / Calibration devices in common path HIERARCH ESO INS4 COMB CALSEL = 'OPEN ' / For internal use, open the calib. sHIERARCH ESO INS4 COMB CPI = 'OPEN ' / Assembly for CPI global settings HIERARCH ESO INS4 COMB IBS = ' ' / Assembly for infrared beamsplitter HIERARCH ESO INS4 COMB POLA_CPI = 'N_I ' / Assembly for polarimetry at CPI lHIERARCH ESO INS4 COMB ROT = 'PUPIL ' / Assembly for derotator and ADC modes HIERARCH ESO INS4 COMB VBS = ' ' / Assembly for visible beamsplitter HIERARCH ESO INS4 DID1 = 'SPHERE_ICS-374390' / Data dictionary for INS. HIERARCH ESO INS4 DID2 = 'PRIMARY-FITS-1.2' / Data dictionary for INS. HIERARCH ESO INS4 DROT1 DEC = 72444.64222 / Telescope desclination. HIERARCH ESO INS4 DROT1 ENC END = 13610 / Encoder position at end [Enc]. HIERARCH ESO INS4 DROT1 ENC START = 13637 / Encoder position at start [Enc]. HIERARCH ESO INS4 DROT1 END = -27.3880 / Position angle at end [deg](h). HIERARCH ESO INS4 DROT1 MODE = 'ELEV ' / Half-Wave Plate 1 rotation mode. HIERARCH ESO INS4 DROT1 POSANG = 0.0000 / Position angle [deg]. HIERARCH ESO INS4 DROT1 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 DROT1 START = -27.3630 / Position angle at start [deg]. HIERARCH ESO INS4 DROT2 DEC = 72444.64222 / Telescope desclination. HIERARCH ESO INS4 DROT2 ENC END = 3208784 / Encoder position at end [Enc]. HIERARCH ESO INS4 DROT2 ENC START = 3208764 / Encoder position at start [Enc]. HIERARCH ESO INS4 DROT2 END = -269.9990 / Position angle at end [deg](h). HIERARCH ESO INS4 DROT2 MODE = 'STAT ' / Derotator mode. HIERARCH ESO INS4 DROT2 POSANG = 180.0000 / Position angle [deg]. HIERARCH ESO INS4 DROT2 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 DROT2 START = -269.9988 / Position angle at start [deg]. HIERARCH ESO INS4 DROT3 DEC = 72444.64222 / Telescope desclination. HIERARCH ESO INS4 DROT3 ENC END = 112819 / Encoder position at end [Enc]. HIERARCH ESO INS4 DROT3 ENC START = 112857 / Encoder position at start [Enc]. HIERARCH ESO INS4 DROT3 END = 282.0630 / Position angle at end [deg](h). HIERARCH ESO INS4 DROT3 MODE = 'USER ' / Half-Wave Plate 2 rotation mode. HIERARCH ESO INS4 DROT3 POSANG = 0.0000 / Position angle [deg]. HIERARCH ESO INS4 DROT3 RA = 55632.411377 / Telescope right ascension. HIERARCH ESO INS4 DROT3 START = 282.1450 / Position angle at start [deg]. HIERARCH ESO INS4 FILT1 ID = 'FILT_ND_4.0' / Filter unique id. HIERARCH ESO INS4 FILT1 NAME = 'ND_4.0 ' / Calibration unit filter. HIERARCH ESO INS4 FILT1 NO = 7 / Filter wheel position index. HIERARCH ESO INS4 FILT2 ID = 'FILT_ND_3.5' / Filter unique id. HIERARCH ESO INS4 FILT2 NAME = 'ND_3.5 ' / NIR Neutral Density filters. HIERARCH ESO INS4 FILT2 NO = 4 / Filter wheel position index. HIERARCH ESO INS4 FILT3 ID = 'FILT_780_L' / Filter unique id. HIERARCH ESO INS4 FILT3 NAME = 'LP_780 ' / Wavefront sensor spectral filter. HIERARCH ESO INS4 FILT3 NO = 5 / Filter wheel position index. HIERARCH ESO INS4 ID = 'SPHERE/176642' / Instrument ID. HIERARCH ESO INS4 LAMP1 SWSIM = T / T for software simulation. HIERARCH ESO INS4 LAMPPWR1 SWSIM = T / T for software simulation. HIERARCH ESO INS4 MODE = 'ZIMPOL_P1' / Instrument mode used. HIERARCH ESO INS4 OPTI1 ID = 'LAMP_QL1' / OPTIi unique ID. HIERARCH ESO INS4 OPTI1 NAME = 'QTH1 ' / Quartz-Tungsten-Halogen lamp selectoHIERARCH ESO INS4 OPTI1 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI1 TYPE = 'LAMP ' / OPTIi element. HIERARCH ESO INS4 OPTI10 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI10 NAME = 'OPEN ' / Apodizers wheel. HIERARCH ESO INS4 OPTI10 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI10 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI11 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI11 NAME = 'OPEN ' / NIR coronagraphs wheel. HIERARCH ESO INS4 OPTI11 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI11 TYPE = 'MASK ' / OPTIi element. HIERARCH ESO INS4 OPTI12 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI12 NAME = 'OPEN ' / FP3 source selector. HIERARCH ESO INS4 OPTI12 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI12 SWSIM = T / T for software simulation. HIERARCH ESO INS4 OPTI12 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI13 ID = 'MIRR_YJHK' / OPTIi unique ID. HIERARCH ESO INS4 OPTI13 NAME = 'MIRR ' / IRDIS-IFS selector. HIERARCH ESO INS4 OPTI13 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI13 TYPE = 'MIRROR ' / OPTIi element. HIERARCH ESO INS4 OPTI14 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI14 NAME = 'OPEN ' / IFS Lyot wheel. HIERARCH ESO INS4 OPTI14 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI14 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI15 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI15 NAME = 'OPEN ' / IFS pupil lens. HIERARCH ESO INS4 OPTI15 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI15 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI16 ID = 'BSPL_VIS' / OPTIi unique ID. HIERARCH ESO INS4 OPTI16 NAME = 'BS_GREY ' / WFS-ZIMPOL selector. HIERARCH ESO INS4 OPTI16 NO = 2 / OPTIi slot number. HIERARCH ESO INS4 OPTI16 TYPE = 'BEAMSPLITTER' / OPTIi element. HIERARCH ESO INS4 OPTI17 ID = 'MASK_CLEARV_1' / OPTIi unique ID. HIERARCH ESO INS4 OPTI17 NAME = 'CLEAR1 ' / VIS coronagraphs wheel. HIERARCH ESO INS4 OPTI17 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI17 TYPE = 'MASK ' / OPTIi element. HIERARCH ESO INS4 OPTI18 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI18 NAME = 'OPEN ' / FP4 source select. HIERARCH ESO INS4 OPTI18 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI18 SWSIM = T / T for software simulation. HIERARCH ESO INS4 OPTI18 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI19 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI19 NAME = 'OPEN ' / VIS Lyot stop wheel. HIERARCH ESO INS4 OPTI19 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI19 TYPE = 'MASK ' / OPTIi element. HIERARCH ESO INS4 OPTI2 ID = 'FIBR_FP1_VIS' / OPTIi unique ID. HIERARCH ESO INS4 OPTI2 NAME = 'FP1_VIS ' / Fiber selector. HIERARCH ESO INS4 OPTI2 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI2 TYPE = 'FIBER ' / OPTIi element. HIERARCH ESO INS4 OPTI20 ID = 'FP3_1 ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI20 NAME = 'FP3_1 ' / FP3 focus stage. HIERARCH ESO INS4 OPTI20 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI20 SWSIM = T / T for software simulation. HIERARCH ESO INS4 OPTI20 TYPE = 'FIBER ' / OPTIi element. HIERARCH ESO INS4 OPTI21 ID = 'FP4_2 ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI21 NAME = 'FP4_2 ' / FP4 focus stage. HIERARCH ESO INS4 OPTI21 NO = 2 / OPTIi slot number. HIERARCH ESO INS4 OPTI21 SWSIM = T / T for software simulation. HIERARCH ESO INS4 OPTI21 TYPE = 'FIBER ' / OPTIi element. HIERARCH ESO INS4 OPTI22 ID = 'SMALL ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI22 NAME = 'SMALL ' / WFS spatial filter. HIERARCH ESO INS4 OPTI22 NO = 2 / OPTIi slot number. HIERARCH ESO INS4 OPTI22 TYPE = 'MASK ' / OPTIi element. HIERARCH ESO INS4 OPTI23 ID = 'OUT ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI23 NAME = 'OUT ' / HiRISE pick-off mirror. HIERARCH ESO INS4 OPTI23 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI23 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI3 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI3 NAME = 'OPEN ' / FP1 source selector. HIERARCH ESO INS4 OPTI3 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI3 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI4 ID = 'FOCU_NOM' / OPTIi unique ID. HIERARCH ESO INS4 OPTI4 NAME = 'NOM ' / FP1 focus. HIERARCH ESO INS4 OPTI4 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI4 TYPE = 'FOCUS ' / OPTIi element. HIERARCH ESO INS4 OPTI5 ID = 'MASK_GRID' / OPTIi unique ID. HIERARCH ESO INS4 OPTI5 NAME = 'GRID ' / [mm] Grid selector vert. position. HIERARCH ESO INS4 OPTI5 NO = 2 / OPTIi slot number. HIERARCH ESO INS4 OPTI5 TYPE = 'MASK ' / OPTIi element. HIERARCH ESO INS4 OPTI6 ID = 'RETA1_VIS' / OPTIi unique ID. HIERARCH ESO INS4 OPTI6 NAME = 'H_VIS ' / Half-Wave Plate 1 selector. HIERARCH ESO INS4 OPTI6 NO = 2 / OPTIi slot number. HIERARCH ESO INS4 OPTI6 TYPE = 'RETARDER' / OPTIi element. HIERARCH ESO INS4 OPTI7 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI7 NAME = 'OPEN ' / Common path polarizers. HIERARCH ESO INS4 OPTI7 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI7 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 OPTI8 ID = 'RETA2_VIS' / OPTIi unique ID. HIERARCH ESO INS4 OPTI8 NAME = 'H_VIS ' / Half-Wave Plate 2 selector. HIERARCH ESO INS4 OPTI8 NO = 2 / OPTIi slot number. HIERARCH ESO INS4 OPTI8 TYPE = 'RETARDER' / OPTIi element. HIERARCH ESO INS4 OPTI9 ID = 'OPEN ' / OPTIi unique ID. HIERARCH ESO INS4 OPTI9 NAME = 'OPEN ' / FP2 source selector. HIERARCH ESO INS4 OPTI9 NO = 1 / OPTIi slot number. HIERARCH ESO INS4 OPTI9 TYPE = 'FREE ' / OPTIi element. HIERARCH ESO INS4 POS ENC = 8391 / [Enc] Absolute position of Active Toric MirroHIERARCH ESO INS4 POS POS = 8391.0 / [nmrms] Position of Active Toric Mirror 3. HIERARCH ESO INS4 SENS401 ID = 'PCNT ' / Sensor ID. HIERARCH ESO INS4 SENS401 NAME = 'Part. counter status' / sensor common name. HIERARCH ESO INS4 SENS401 VAL = 0.00 / Particle counter status. HIERARCH ESO INS4 SENS402 ID = 'PCNT ' / Sensor ID. HIERARCH ESO INS4 SENS402 NAME = 'Part. counter 0.5um' / sensor common name. HIERARCH ESO INS4 SENS402 VAL = -87500.00 / [p/m3] Particle counter 0.5 um. HIERARCH ESO INS4 SENS403 ID = 'PCNT ' / Sensor ID. HIERARCH ESO INS4 SENS403 NAME = 'Part. counter 5.0um' / sensor common name. HIERARCH ESO INS4 SENS403 VAL = -8750.00 / [p/m3] Particle counter 5.0 um. HIERARCH ESO INS4 SENS404 ID = 'CBL1I ' / Sensor ID. HIERARCH ESO INS4 SENS404 NAME = 'Laser 1330 - current' / sensor common name. HIERARCH ESO INS4 SENS404 VAL = -0.000 / [mA] Laser 1330 - current. HIERARCH ESO INS4 SENS405 ID = 'CBL1T ' / Sensor ID. HIERARCH ESO INS4 SENS405 NAME = 'Laser 1330 - T error' / sensor common name. HIERARCH ESO INS4 SENS405 VAL = -0.125 / [K] Laser 1330 - T error. HIERARCH ESO INS4 SENS406 ID = 'CBL2I ' / Sensor ID. HIERARCH ESO INS4 SENS406 NAME = 'Laser 1550 - current' / sensor common name. HIERARCH ESO INS4 SENS406 VAL = -0.000 / [mA] Laser 1550 - current. HIERARCH ESO INS4 SENS407 ID = 'CBL2T ' / Sensor ID. HIERARCH ESO INS4 SENS407 NAME = 'Laser 1550 - T error' / sensor common name. HIERARCH ESO INS4 SENS407 VAL = 0.111 / [K] Laser 1550 - T error. HIERARCH ESO INS4 SENS408 ID = 'CBL3I ' / Sensor ID. HIERARCH ESO INS4 SENS408 NAME = 'Laser 980 - current' / sensor common name. HIERARCH ESO INS4 SENS408 VAL = -0.001 / [mA] Laser 980 - current. HIERARCH ESO INS4 SENS409 ID = 'CBL3T ' / Sensor ID. HIERARCH ESO INS4 SENS409 NAME = 'Laser 980 - T error' / sensor common name. HIERARCH ESO INS4 SENS409 VAL = -0.125 / [K] Laser 980 - T error. HIERARCH ESO INS4 SENS410 ID = 'CBL4I ' / Sensor ID. HIERARCH ESO INS4 SENS410 NAME = 'Laser 1742 - current' / sensor common name. HIERARCH ESO INS4 SENS410 VAL = -0.000 / [mA] Laser 1742 - current. HIERARCH ESO INS4 SENS411 ID = 'CBL4T ' / Sensor ID. HIERARCH ESO INS4 SENS411 NAME = 'Laser 1742 - T error' / sensor common name. HIERARCH ESO INS4 SENS411 VAL = -0.347 / [K] Laser 1742 - T error. HIERARCH ESO INS4 SENS412 ID = 'CBL5I ' / Sensor ID. HIERARCH ESO INS4 SENS412 NAME = 'Laser 2004 - current' / sensor common name. HIERARCH ESO INS4 SENS412 VAL = 0.000 / [mA] Laser 2004 - current. HIERARCH ESO INS4 SENS413 ID = 'CBL5T ' / Sensor ID. HIERARCH ESO INS4 SENS413 NAME = 'Laser 2004 - T error' / sensor common name. HIERARCH ESO INS4 SENS413 VAL = -0.125 / [K] Laser 2004 - T error. HIERARCH ESO INS4 SENS414 ID = 'CBL6I ' / Sensor ID. HIERARCH ESO INS4 SENS414 NAME = 'Laser 1080 - current' / sensor common name. HIERARCH ESO INS4 SENS414 VAL = -0.001 / [mA] Laser 1080 - current. HIERARCH ESO INS4 SENS415 ID = 'CBL6T ' / Sensor ID. HIERARCH ESO INS4 SENS415 NAME = 'Laser 1080 - T error' / sensor common name. HIERARCH ESO INS4 SENS415 VAL = 0.111 / [K] Laser 1080 - T error. HIERARCH ESO INS4 SENS416 ID = 'CBBLI ' / Sensor ID. HIERARCH ESO INS4 SENS416 NAME = 'Laser 532 - current' / sensor common name. HIERARCH ESO INS4 SENS416 VAL = 0.000 / [mA] Laser 532 - current. HIERARCH ESO INS4 SENS417 ID = 'CFPD1 ' / Sensor ID. HIERARCH ESO INS4 SENS417 NAME = 'VIS FF intensity' / sensor common name. HIERARCH ESO INS4 SENS417 VAL = 0.000 / [V] Quartz-Tungsten-Halogen lamp: VIS FFHIERARCH ESO INS4 SENS418 ID = 'CFPD2 ' / Sensor ID. HIERARCH ESO INS4 SENS418 NAME = 'NIR FF intensity' / sensor common name. HIERARCH ESO INS4 SENS418 VAL = 0.000 / [V] Quartz-Tungsten-Halogen lamp: NIR FFHIERARCH ESO INS4 SENS419 ID = 'CBPD ' / Sensor ID. HIERARCH ESO INS4 SENS419 NAME = 'QTH on/off' / sensor common name. HIERARCH ESO INS4 SENS419 VAL = 0 / Quartz-Tungsten-Halogen lamp: on/off. HIERARCH ESO INS4 SENS424 VAL = 1.59E-05 / [mbar] DTTS vessel pressure. HIERARCH ESO INS4 SENS425 ID = 'CBIL ' / Sensor ID. HIERARCH ESO INS4 SENS425 NAME = 'CalUnit door' / sensor common name. HIERARCH ESO INS4 SENS425 VAL = 1 / Calibration unit door: closed/open. HIERARCH ESO INS4 SENS426 ID = 'HEAT ' / Sensor ID. HIERARCH ESO INS4 SENS426 NAME = 'Heater range' / sensor common name. HIERARCH ESO INS4 SENS426 VAL = 0.00 / [mW] DTTS heater power. HIERARCH ESO INS4 SENS427 ID = 'HEAT ' / Sensor ID. HIERARCH ESO INS4 SENS427 NAME = 'Heater output' / sensor common name. HIERARCH ESO INS4 SENS427 VAL = 0.00 / [%] DTTS heater output. HIERARCH ESO INS4 SENS428 ID = 'HODM ' / Sensor ID. HIERARCH ESO INS4 SENS428 NAME = 'HODM relative Humidity sensor' / sensor commonHIERARCH ESO INS4 SENS428 VAL = 2.39 / [%] HODM relative Humidity. HIERARCH ESO INS4 SENSOR1 ID = 'cpyg ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR1 NAME = 'Yokogawa DAQ' / Sensor device common name. HIERARCH ESO INS4 SENSOR10 ID = 'ccc2 ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR10 NAME = 'Cabinet T WIDE' / Sensor device common name. HIERARCH ESO INS4 SENSOR11 ID = 'ccc3 ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR11 NAME = 'Cabinet T TALL' / Sensor device common name. HIERARCH ESO INS4 SENSOR12 ID = 'cbil ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR12 NAME = 'Cal unit interlock' / Sensor device common naHIERARCH ESO INS4 SENSOR2 ID = 'dtls ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR2 NAME = 'Lake Det T control' / Sensor device common namHIERARCH ESO INS4 SENSOR3 ID = 'asen1 ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR3 NAME = 'Analog sen. calbox' / Sensor device common namHIERARCH ESO INS4 SENSOR4 ID = 'asen2 ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR4 NAME = 'QTH lamp sensor' / Sensor device common name. HIERARCH ESO INS4 SENSOR5 ID = 'asen3 ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR5 NAME = 'Calib unit T sensor' / Sensor device common naHIERARCH ESO INS4 SENSOR6 ID = 'cbpd ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR6 NAME = 'QTH lamp sensor' / Sensor device common name. HIERARCH ESO INS4 SENSOR9 ID = 'ccc1 ' / Sensor device unique id. HIERARCH ESO INS4 SENSOR9 NAME = 'Cabinet T SMALL' / Sensor device common name. HIERARCH ESO INS4 SHUT ST = T / Shutter open [T] or closed [F]. HIERARCH ESO INS4 SHUT SWSIM = F / T for software simulation. HIERARCH ESO INS4 SWSIM = 'NORMAL ' / Software simulation. HIERARCH ESO INS4 TEMP401 ID = 'T1 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP401 NAME = 'OBE Temp 1' / Temperature sensor name. HIERARCH ESO INS4 TEMP401 VAL = 10000.00 / [K] *Broken* IRDIS Optical Bench T LeHIERARCH ESO INS4 TEMP402 ID = 'T2 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP402 NAME = 'OBE Temp 2' / Temperature sensor name. HIERARCH ESO INS4 TEMP402 VAL = 84.80 / [K] IRDIS Optical Bench T Center. HIERARCH ESO INS4 TEMP403 ID = 'T3 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP403 NAME = 'OBE Temp 3' / Temperature sensor name. HIERARCH ESO INS4 TEMP403 VAL = 85.30 / [K] IRDIS Optical Bench T Right. HIERARCH ESO INS4 TEMP404 ID = 'T4 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP404 NAME = 'TSC Temp 4' / Temperature sensor name. HIERARCH ESO INS4 TEMP404 VAL = 10000.00 / [K] *Broken* IRDIS Thermal Screen T HHIERARCH ESO INS4 TEMP405 ID = 'T5 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP405 NAME = 'TSC Temp 5' / Temperature sensor name. HIERARCH ESO INS4 TEMP405 VAL = 86.00 / [K] IRDIS Thermal Screen T Middle. HIERARCH ESO INS4 TEMP406 ID = 'T6 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP406 NAME = 'TSC Temp 6' / Temperature sensor name. HIERARCH ESO INS4 TEMP406 VAL = 10000.00 / [K] *Broken* IRDIS Thermal Screen T LHIERARCH ESO INS4 TEMP407 ID = 'T7 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP407 NAME = 'ANT Temp 7' / Temperature sensor name. HIERARCH ESO INS4 TEMP407 VAL = 76.70 / [K] IRDIS Aux LN2 Tank T Low. HIERARCH ESO INS4 TEMP408 ID = 'T8 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP408 NAME = 'ANT Temp 8' / Temperature sensor name. HIERARCH ESO INS4 TEMP408 VAL = 77.40 / [K] IRDIS Aux LN2 Tank T High. HIERARCH ESO INS4 TEMP409 ID = 'T9 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP409 NAME = 'MNT Temp 9' / Temperature sensor name. HIERARCH ESO INS4 TEMP409 VAL = 76.70 / [K] IRDIS Main LN2 Tank T Bottom. HIERARCH ESO INS4 TEMP410 ID = 'T10 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP410 NAME = 'DET I/T Temp 10' / Temperature sensor name. HIERARCH ESO INS4 TEMP410 VAL = 77.50 / [K] IRDIS Detector Thermal I/F T1. HIERARCH ESO INS4 TEMP411 ID = 'T11 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP411 NAME = 'DET I/T Temp 11' / Temperature sensor name. HIERARCH ESO INS4 TEMP411 VAL = 77.50 / [K] IRDIS Detector Thermal I/F T2. HIERARCH ESO INS4 TEMP412 ID = 'T12 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP412 NAME = 'IRFW1 Temp' / Temperature sensor name. HIERARCH ESO INS4 TEMP412 VAL = 129.00 / [K] IRDIS Filter Wheel 1 T. HIERARCH ESO INS4 TEMP413 ID = 'T13 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP413 NAME = 'IRLW Temp' / Temperature sensor name. HIERARCH ESO INS4 TEMP413 VAL = 115.00 / [K] IRDIS Lyot Wheel T. HIERARCH ESO INS4 TEMP414 ID = 'T14 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP414 NAME = 'IRFW2 Temp' / Temperature sensor name. HIERARCH ESO INS4 TEMP414 VAL = 86.10 / [K] IRDIS Filter Wheel 2 T. HIERARCH ESO INS4 TEMP415 ID = 'T1 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP415 NAME = 'ZIMPOL bench near FLCM' / Temperature sensor nHIERARCH ESO INS4 TEMP415 VAL = 10000.00 / [C] ZIMPOL bench FLCM T. HIERARCH ESO INS4 TEMP416 ID = 'T2 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP416 NAME = 'ZIMPOL bench ambient' / Temperature sensor namHIERARCH ESO INS4 TEMP416 VAL = 15.20 / [C] ZIMPOL bench ambient T. HIERARCH ESO INS4 TEMP417 ID = 'T3 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP417 NAME = 'ZIMPOL bench near cryostat' / Temperature sensHIERARCH ESO INS4 TEMP417 VAL = 15.90 / [C] ZIMPOL bench cryostat T. HIERARCH ESO INS4 TEMP418 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP418 NAME = 'CAN box 1 ambient' / Temperature sensor name. HIERARCH ESO INS4 TEMP418 VAL = 15.50 / [C] CAN box 1 ambient T. HIERARCH ESO INS4 TEMP419 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP419 NAME = 'CAN box 2 ambient' / Temperature sensor name. HIERARCH ESO INS4 TEMP419 VAL = 17.60 / [C] CAN box 2 ambient T. HIERARCH ESO INS4 TEMP420 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP420 NAME = 'CAN box 3 ambient' / Temperature sensor name. HIERARCH ESO INS4 TEMP420 VAL = 10000.00 / [C] CAN box 3 ambient T. HIERARCH ESO INS4 TEMP421 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP421 NAME = 'Enclosure ambient T' / Temperature sensor nameHIERARCH ESO INS4 TEMP421 VAL = 15.80 / [C] Air temperature in SPHERE enclosure.HIERARCH ESO INS4 TEMP422 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP422 NAME = 'HODM T sensor' / Temperature sensor name. HIERARCH ESO INS4 TEMP422 VAL = 15.40 / [C] T sensor on HODM case. HIERARCH ESO INS4 TEMP423 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP423 NAME = 'WFS external T sensor' / Temperature sensor naHIERARCH ESO INS4 TEMP423 VAL = 17.10 / [C] T sensor on WFS camera case. HIERARCH ESO INS4 TEMP424 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP424 NAME = 'ITTM T sensor' / Temperature sensor name. HIERARCH ESO INS4 TEMP424 VAL = 10000.00 / [C] T sensor on ITTM case. HIERARCH ESO INS4 TEMP425 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP425 NAME = 'T on bench near IFS' / Temperature sensor nameHIERARCH ESO INS4 TEMP425 VAL = 15.50 / [C] T sensor on bench near IFS. HIERARCH ESO INS4 TEMP426 ID = 'T ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP426 NAME = 'ZIMPOL shutter T sensor' / Temperature sensor HIERARCH ESO INS4 TEMP426 VAL = 15.70 / [C] T sensor on ZIMPOL shutter stand. HIERARCH ESO INS4 TEMP428 ID = 'Chan ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP428 NAME = 'Lakeshore Channel A' / Temperature sensor nameHIERARCH ESO INS4 TEMP428 VAL = 86.90 / [K] DTTS detector T chan. A: Detector. HIERARCH ESO INS4 TEMP429 ID = 'Chan ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP429 NAME = 'Lakeshore Channel B' / Temperature sensor nameHIERARCH ESO INS4 TEMP429 VAL = 93.65 / [K] DTTS detector T chan. B: Near detectHIERARCH ESO INS4 TEMP430 ID = 'CT1 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP430 NAME = 'T at FP1 on CFSL1' / Temperature sensor name. HIERARCH ESO INS4 TEMP430 VAL = 19.99 / [C] T in the integrating sphere (mountedHIERARCH ESO INS4 TEMP431 ID = 'CT2 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP431 NAME = 'T on lamp table' / Temperature sensor name. HIERARCH ESO INS4 TEMP431 VAL = 20.00 / [C] T on the table of the source unit. HIERARCH ESO INS4 TEMP432 ID = 'CT3 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP432 NAME = 'T QTH bulb' / Temperature sensor name. HIERARCH ESO INS4 TEMP432 VAL = 20.00 / [C] T on the cooling duct of the Quartz-HIERARCH ESO INS4 TEMP433 ID = 'CT4 ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP433 NAME = 'T signal board' / Temperature sensor name. HIERARCH ESO INS4 TEMP433 VAL = 20.00 / [C] T inside the MISC crate. HIERARCH ESO INS4 TEMP434 VAL = 200005.00 / [K] T1 DTTS - DTR . HIERARCH ESO INS4 TEMP435 VAL = 200005.00 / [K] T2 DTTS - DTR2. HIERARCH ESO INS4 TEMP436 VAL = 80.92 / [K] T3 DTTS - CPL . HIERARCH ESO INS4 TEMP437 VAL = 231.80 / [K] T4 DTTS - EXG . HIERARCH ESO INS4 TEMP438 VAL = 92.70 / [K] T5 DTTS - SPT . HIERARCH ESO INS4 TEMP439 VAL = -40.00 / [C] T1 WFS . HIERARCH ESO INS4 TEMP440 ID = 'CSCT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP440 NAME = 'Cabinet skin T' / Temperature sensor name. HIERARCH ESO INS4 TEMP440 VAL = 17.73 / [C] SMALL cabinet skin T . HIERARCH ESO INS4 TEMP441 ID = 'CSAT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP441 NAME = 'Cabinet amb. T' / Temperature sensor name. HIERARCH ESO INS4 TEMP441 VAL = 14.84 / [C] SMALL cabinet ambient T. HIERARCH ESO INS4 TEMP442 ID = 'CSIT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP442 NAME = 'Cabinet inlet T' / Temperature sensor name. HIERARCH ESO INS4 TEMP442 VAL = 14.78 / [C] SMALL cabinet inlet T . HIERARCH ESO INS4 TEMP443 ID = 'CSOT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP443 NAME = 'Cabinet outlet T' / Temperature sensor name. HIERARCH ESO INS4 TEMP443 VAL = 14.71 / [C] SMALL cabinet outlet T . HIERARCH ESO INS4 TEMP444 ID = 'CWCT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP444 NAME = 'Cabinet skin T' / Temperature sensor name. HIERARCH ESO INS4 TEMP444 VAL = 22.86 / [C] WIDE cabinet skin T . HIERARCH ESO INS4 TEMP445 ID = 'CWAT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP445 NAME = 'Cabinet amb. T' / Temperature sensor name. HIERARCH ESO INS4 TEMP445 VAL = 16.15 / [C] WIDE cabinet ambient T. HIERARCH ESO INS4 TEMP446 ID = 'CWIT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP446 NAME = 'Cabinet inlet T' / Temperature sensor name. HIERARCH ESO INS4 TEMP446 VAL = 14.36 / [C] WIDE cabinet inlet T . HIERARCH ESO INS4 TEMP447 ID = 'CWOT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP447 NAME = 'Cabinet outlet T' / Temperature sensor name. HIERARCH ESO INS4 TEMP447 VAL = 13.21 / [C] WIDE cabinet outlet T. HIERARCH ESO INS4 TEMP448 ID = 'CTCT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP448 NAME = 'Cabinet skin T' / Temperature sensor name. HIERARCH ESO INS4 TEMP448 VAL = 16.08 / [C] TALL cabinet skin T . HIERARCH ESO INS4 TEMP449 ID = 'CTAT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP449 NAME = 'Cabinet amb. T' / Temperature sensor name. HIERARCH ESO INS4 TEMP449 VAL = 13.29 / [C] TALL cabinet ambient T. HIERARCH ESO INS4 TEMP450 ID = 'CTIT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP450 NAME = 'Cabinet inlet T' / Temperature sensor name. HIERARCH ESO INS4 TEMP450 VAL = 17.95 / [C] TALL cabinet inlet T . HIERARCH ESO INS4 TEMP451 ID = 'CTOT ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP451 NAME = 'Cabinet outlet T' / Temperature sensor name. HIERARCH ESO INS4 TEMP451 VAL = 17.55 / [C] TALL cabinet outlet T. HIERARCH ESO INS4 TEMP452 VAL = 13.17 / [C] T2 WFS . HIERARCH ESO INS4 TEMP453 VAL = -40.00 / [C] T1 WFS set point. HIERARCH ESO INS4 TEMP454 VAL = 80.80 / [K] DTTS CPL set point . HIERARCH ESO INS4 TEMP455 ID = 'Chan ' / Temperature sensor ID. HIERARCH ESO INS4 TEMP455 NAME = 'Channel A set point' / Temperature sensor nameHIERARCH ESO INS4 TEMP455 VAL = 87.00 / [K] DTTS T chan. A: Detector set point. HIERARCH ESO INS4 TILT1 ENC = 446355 / [Enc] Camera tilt absolute position. HIERARCH ESO INS4 TILT1 FRML= 'ENC=OFFSET+RESOL*asin(2*POS-(MAX+MIN)/(MAX-MIN))'HIERARCH ESO INS4 TILT1 OFFSET = 16325 / [Enc] Tilt reference position. HIERARCH ESO INS4 TILT1 POS = 2.0 / [mm] IRDIS-IFS selector tilt1. HIERARCH ESO INS4 TILT1 POSMAX = 153.4 / [pixel] Maximum camera tilt. HIERARCH ESO INS4 TILT1 POSMIN = -153.4 / [pixel] Minimum camera tilt. HIERARCH ESO INS4 TILT1 RESOL = -100.0 / [Enc/deg] Encoder resolution. HIERARCH ESO INS4 TILT2 ENC = 361649 / [Enc] Camera tilt absolute position. HIERARCH ESO INS4 TILT2 FRML= 'ENC=OFFSET+RESOL*asin(2*POS-(MAX+MIN)/(MAX-MIN))'HIERARCH ESO INS4 TILT2 OFFSET = 16325 / [Enc] Tilt reference position. HIERARCH ESO INS4 TILT2 POS = 1.6 / [mm] IRDIS-IFS selector tilt2. HIERARCH ESO INS4 TILT2 POSMAX = 153.4 / [pixel] Maximum camera tilt. HIERARCH ESO INS4 TILT2 POSMIN = -153.4 / [pixel] Minimum camera tilt. HIERARCH ESO INS4 TILT2 RESOL = -100.0 / [Enc/deg] Encoder resolution. HIERARCH ESO INS4 TILT3 ENC = 227705 / [Enc] Camera tilt absolute position. HIERARCH ESO INS4 TILT3 FRML= 'ENC=OFFSET+RESOL*asin(2*POS-(MAX+MIN)/(MAX-MIN))'HIERARCH ESO INS4 TILT3 OFFSET = 16325 / [Enc] Tilt reference position. HIERARCH ESO INS4 TILT3 POS = 1.0 / [mm] IRDIS-IFS selector tilt3. HIERARCH ESO INS4 TILT3 POSMAX = 153.4 / [pixel] Maximum camera tilt. HIERARCH ESO INS4 TILT3 POSMIN = -153.4 / [pixel] Minimum camera tilt. HIERARCH ESO INS4 TILT3 RESOL = -100.0 / [Enc/deg] Encoder resolution. HIERARCH ESO OBS AIRM = 2.0 / Req. max. airmass HIERARCH ESO OBS AMBI TRANS = '2CLR ' / Req. sky transparency HIERARCH ESO OBS AOMODE = 'FULL_AO ' / Req. AO mode HIERARCH ESO OBS CONTAINER ID = 4101303 / Scheduling container ID HIERARCH ESO OBS CONTAINER PARENT ID = -999 / Scheduling parent container ID HIERARCH ESO OBS CONTAINER PARENT TYPE = 'U ' / Scheduling parent containeHIERARCH ESO OBS CONTAINER TYPE = 'C ' / Scheduling container type HIERARCH ESO OBS DID = 'ESO-VLT-DIC.OBS-2.2' / OBS Dictionary HIERARCH ESO OBS EXECTIME = 1820 / Expected execution time HIERARCH ESO OBS GRP = '0 ' / linked blocks HIERARCH ESO OBS ID = 4101305 / Observation block ID HIERARCH ESO OBS MOON DIST = 30 / Req. min. angular dist. from moon [deg] HIERARCH ESO OBS MOON FLI = 1.0 / Req. max. fractional lunar illum. HIERARCH ESO OBS NAME = 'Betelgeuse_ZPL_P1_V_NI' / OB name HIERARCH ESO OBS NTPL = 2 / Number of templates within OB HIERARCH ESO OBS OBSERVER = 'UNKNOWN ' / Observer Name HIERARCH ESO OBS PI-COI ID = 77013 / ESO internal PI-COI ID HIERARCH ESO OBS PI-COI NAME = 'UNKNOWN ' / PI-COI name HIERARCH ESO OBS PROG ID = '114.28H9.001' / ESO program identification HIERARCH ESO OBS START = '2024-12-03T04:47:11' / OB start time HIERARCH ESO OBS TARG NAME = 'Betelgeuse' / OB target name HIERARCH ESO OBS TPLNO = 2 / Template number within OB HIERARCH ESO OBS TURB = '70% ' / Req. Atmospheric Turbulence Category HIERARCH ESO OBS TWILIGHT = 0 / Req. twilight HIERARCH ESO OBS WATERVAPOUR = 30.0 / Req. water vapour HIERARCH ESO OCS DET1 IMGNAME = 'SPHERE_ZIMPOL_OBS' / Data File Name. HIERARCH ESO OCS OBSTYPE LIST = 'O ' / User defined list of observ. types HIERARCH ESO OCS WAFFLE AMPL = 0.020 / User defined waffle amplitude HIERARCH ESO OCS WAFFLE ORIENT = '+ ' / User defined waffle orientation HIERARCH ESO OCS3 ZIMPOL FIELD POS = 'O0 ' / SOS hierarchical Keyword HIERARCH ESO OCS3 ZIMPOL FIELD POSANG = 0 / SOS hierarchical Keyword HIERARCH ESO OCS3 ZIMPOL POL GAMMA = 0.0 / SOS hierarchical Keyword HIERARCH ESO OCS3 ZIMPOL POL STOKES = 'Qplus ' / SOS hierarchical Keyword HIERARCH ESO OCS3 ZIMPOL SHUTTER = F / SOS hierarchical Keyword HIERARCH ESO SEQ ARM = 'ZIMPOL ' / Name of the sub-system. HIERARCH ESO SEQ CORO CPI_ND = 'OPEN ' / ND filter used in the centring proc.HIERARCH ESO SEQ CORO DATE = '2024-12-02T12:06:18' / Date and time of centring. HIERARCH ESO SEQ CORO XC = 255.489 / Coronagraph position at the end of centringHIERARCH ESO SEQ CORO YC = 257.719 / Coronagraph position at the end of centringHIERARCH ESO TEL AIRM END = 1.223 / Airmass at end HIERARCH ESO TEL AIRM START = 1.224 / Airmass at start HIERARCH ESO TEL ALT = 54.732 / [deg] Alt angle at start HIERARCH ESO TEL AMBI FWHM END = 0.51 / [arcsec] Observatory Seeing queried fromHIERARCH ESO TEL AMBI FWHM START = 0.51 / [arcsec] Observatory Seeing queried frHIERARCH ESO TEL AMBI IRSKY TEMP = -94.9 / Temperature of the IR sky, from radioHIERARCH ESO TEL AMBI IWV END = 2.17 / Integrated Water Vapor HIERARCH ESO TEL AMBI IWV START = 2.17 / Integrated Water Vapor HIERARCH ESO TEL AMBI IWV30D END = 2.31 / IWV at 30deg elev. HIERARCH ESO TEL AMBI IWV30D START = 2.31 / IWV at 30deg elev. HIERARCH ESO TEL AMBI IWV30DSTD END = 0.01 / IWV at 30deg elev. HIERARCH ESO TEL AMBI IWV30DSTD START = 0.01 / IWV at 30deg elev. HIERARCH ESO TEL AMBI IWVSTD END = 0.04 / Standard Deviation of Integrated WaterHIERARCH ESO TEL AMBI IWVSTD START = 0.04 / Standard Deviation of Integrated WatHIERARCH ESO TEL AMBI PRES END = 744.00 / [hPa] Observatory ambient air pressureHIERARCH ESO TEL AMBI PRES START = 744.00 / [hPa] Observatory ambient air pressuHIERARCH ESO TEL AMBI RHUM = 8.5 / [%] Observatory ambient relative humidity queHIERARCH ESO TEL AMBI TAU0 = 0.0048 / [s] Average coherence time HIERARCH ESO TEL AMBI TEMP = 13.77 / [deg C] Observatory ambient temperature queHIERARCH ESO TEL AMBI WINDDIR = 301.0 / [deg] Observatory ambient wind directionHIERARCH ESO TEL AMBI WINDSP = 2.23 / [m/s] Observatory ambient wind speed queriHIERARCH ESO TEL AZ = 206.646 / [deg] Az angle at start S=0,W=90 HIERARCH ESO TEL CHOP ST = F / True when chopping is active HIERARCH ESO TEL DATE = '2000-01-01T00:00:00' / TCS installation date HIERARCH ESO TEL DID = 'ESO-VLT-DIC.TCS' / Data dictionary for TEL HIERARCH ESO TEL DOME STATUS = 'FULLY-OPEN' / Dome status HIERARCH ESO TEL FO POS = 19.012000 / Adapter focus position during tracking HIERARCH ESO TEL FOCU ID = 'NA ' / Telescope focus station ID HIERARCH ESO TEL FOCU LEN = 120.000 / [m] Focal length HIERARCH ESO TEL FOCU SCALE = 1.718 / [arcsec/mm] Focal scale HIERARCH ESO TEL FOCU VALUE = -31.009 / [mm] M2 setting HIERARCH ESO TEL GEOELEV = 2648.0 / [m] Elevation above sea level HIERARCH ESO TEL GEOLAT = -24.6268 / [deg] Tel geo latitute (+=North) HIERARCH ESO TEL GEOLON = -70.4045 / [deg] Tel geo longitude (+=East) HIERARCH ESO TEL IA FWHM = 0.86 / [arcsec] Delivered seeing corrected by airmassHIERARCH ESO TEL IA FWHMLIN = 0.92 / Delivered seeing on IA detector (linear fitHIERARCH ESO TEL IA FWHMLINOBS = 1.04 / Delivered seeing on IA detector (linear HIERARCH ESO TEL ID = 'v 394345' / TCS version number HIERARCH ESO TEL MOON DEC = -27.69440 / [deg] -27:41:39.8 DEC (J2000) HIERARCH ESO TEL MOON RA = 274.280536 / [deg] 18:17:07.3 RA (J2000) HIERARCH ESO TEL OPER = 'I, Condor' / Telescope Operator HIERARCH ESO TEL PARANG END = -155.900 / [deg] Parallactic angle at end HIERARCH ESO TEL PARANG START = -155.724 / [deg] Parallactic angle at start HIERARCH ESO TEL TARG ALPHA = 55510.305 / Alpha coordinate for the target HIERARCH ESO TEL TARG COORDTYPE = 'M ' / Coordinate type (M=mean A=apparenHIERARCH ESO TEL TARG DELTA = 72425.430 / Delta coordinate for the target HIERARCH ESO TEL TARG EPOCH = 2000.000 / [yr] Epoch 1950 or 2000 HIERARCH ESO TEL TARG EPOCHSYSTEM = 'J ' / Epoch system (J=Julian / B=BessHIERARCH ESO TEL TARG EQUINOX = 2000.000 / [yr] Equinox HIERARCH ESO TEL TARG PARALLAX = 0.000 / [arcsec] Parallax HIERARCH ESO TEL TARG PMA = 0.027540 / [arcsec/yr] Proper Motion Alpha HIERARCH ESO TEL TARG PMD = 0.011300 / [arcsec/yr] Proper motion Delta HIERARCH ESO TEL TARG RADVEL = 0.000 / [km/s] Radial velocity HIERARCH ESO TEL TH M1 TEMP = 12.96 / [deg C] M1 superficial temperature HIERARCH ESO TEL TRAK STATUS = 'NORMAL ' / Tracking status HIERARCH ESO TPL DID = 'ESO-VLT-DIC.TPL-1.9' / Data dictionary for TPL HIERARCH ESO TPL EXPNO = 1 / Exposure number within template HIERARCH ESO TPL ID = 'SPHERE_zimpol_p1_obs' / Template signature ID HIERARCH ESO TPL NAME = 'Scientific obs in ZIMPOL_P1 mode' / Template name HIERARCH ESO TPL NEXP = 96 / Number of exposures within template HIERARCH ESO TPL PRESEQ = 'SPHERE_zimpol_p_obs.seq' / Sequencer script HIERARCH ESO TPL START = '2024-12-03T04:47:11' / TPL start time HIERARCH ESO TPL VERSION = '$Revision: 332745 $' / Version of the template PIXSCAL = 6.80 / [mas/pixel] ZIMPOL pixel scale ARCFILE = 'ADP.2026-08-19T13:19:07.647.fits' / Archive File Name HIERARCH SPHEREDC ZPL PIP VERS = '2021-02-01' / Version of the Zimpol pipeline HIERARCH SPHEREDC ZPL PIP CEN = T / Image is centered (TRUE/FALSE) CRVAL1 = 88.79293874999999 CRVAL2 = 7.40706388888889 CUNIT1 = 'deg ' CUNIT2 = 'deg ' CTYPE1 = 'RA---TAN' CTYPE2 = 'DEC--TAN' CRPIX1 = 90.0 CRPIX2 = 90.0 CD1_1 = -1E-06 CD2_2 = 1E-06 CD1_2 = 0.0 CD2_1 = 0.0 BZERO = 0.0 / Offset for flux conversion BSCALE = 4.2661191496747595 / Flux conversion factor BUNIT = 'W.m**(-2).um**(-1).sr**(-1)' BTYPE = 'Intensity' FILTER = 'N_I ' PRODCATG= 'SCIENCE.IMAGE' / ESO data product categories TEXPTIME= 24.76298 / s OBSTECH = 'POLARIMETRY,P1' PROCSOFT= 'spher 0.56.0' FLUXCAL = 'ABSOLUTE' MJD-END = 60647.241378056664 PROG_ID = '114.28H9.001' OBID1 = 4101305 PHOTZP = 19.319035 / N/A PHOTSYS = 'N/A ' ABMAGLIM= 8.338 / N/A ABMAGSAT= -3.058 / N/A PSF_FWHM= 0.016 / arcsec NCOMBINE= 96 PROV1 = 'SPHER.2024-12-03T04:57:29.126' / RAW data file REFERENC= 'doi:10.1051/0004-6361/202661023' ASSON1 = 'SPHERE_ZIMPOL_Betelgeuse_P1_N_I_DOLP.fits' / Degree of linear polarizPROV2 = 'SPHER.2024-12-03T04:57:45.876' PROV3 = 'SPHER.2024-12-03T04:57:57.869' PROV4 = 'SPHER.2024-12-03T04:58:12.891' PROV5 = 'SPHER.2024-12-03T04:58:27.108' PROV6 = 'SPHER.2024-12-03T04:58:41.904' PROV7 = 'SPHER.2024-12-03T04:58:57.895' PROV8 = 'SPHER.2024-12-03T04:59:11.853' PROV9 = 'SPHER.2024-12-03T04:59:26.186' PROV10 = 'SPHER.2024-12-03T04:59:40.886' PROV11 = 'SPHER.2024-12-03T04:59:53.887' PROV12 = 'SPHER.2024-12-03T05:00:08.891' PROV13 = 'SPHER.2024-12-03T05:00:23.062' PROV14 = 'SPHER.2024-12-03T05:00:36.900' PROV15 = 'SPHER.2024-12-03T05:00:49.899' PROV16 = 'SPHER.2024-12-03T05:01:04.889' PROV17 = 'SPHER.2024-12-03T05:01:19.070' PROV18 = 'SPHER.2024-12-03T05:01:33.899' PROV19 = 'SPHER.2024-12-03T05:01:46.895' PROV20 = 'SPHER.2024-12-03T05:02:01.893' PROV21 = 'SPHER.2024-12-03T05:02:19.069' PROV22 = 'SPHER.2024-12-03T05:02:32.918' PROV23 = 'SPHER.2024-12-03T05:02:45.870' PROV24 = 'SPHER.2024-12-03T05:03:00.888' PROV25 = 'SPHER.2024-12-03T05:03:18.049' PROV26 = 'SPHER.2024-12-03T05:03:31.906' PROV27 = 'SPHER.2024-12-03T05:03:45.881' PROV28 = 'SPHER.2024-12-03T05:03:59.882' PROV29 = 'SPHER.2024-12-03T05:04:14.060' PROV30 = 'SPHER.2024-12-03T05:04:30.880' PROV31 = 'SPHER.2024-12-03T05:04:43.838' PROV32 = 'SPHER.2024-12-03T05:04:56.860' PROV33 = 'SPHER.2024-12-03T05:05:16.508' PROV34 = 'SPHER.2024-12-03T05:05:31.891' PROV35 = 'SPHER.2024-12-03T05:05:43.841' PROV36 = 'SPHER.2024-12-03T05:05:58.903' PROV37 = 'SPHER.2024-12-03T05:06:13.038' PROV38 = 'SPHER.2024-12-03T05:06:27.839' PROV39 = 'SPHER.2024-12-03T05:06:40.830' PROV40 = 'SPHER.2024-12-03T05:06:55.825' PROV41 = 'SPHER.2024-12-03T05:07:10.053' PROV42 = 'SPHER.2024-12-03T05:07:24.890' PROV43 = 'SPHER.2024-12-03T05:07:36.837' PROV44 = 'SPHER.2024-12-03T05:07:50.914' PROV45 = 'SPHER.2024-12-03T05:08:05.976' PROV46 = 'SPHER.2024-12-03T05:08:19.889' PROV47 = 'SPHER.2024-12-03T05:08:33.880' PROV48 = 'SPHER.2024-12-03T05:08:47.887' PROV49 = 'SPHER.2024-12-03T05:09:02.990' PROV50 = 'SPHER.2024-12-03T05:09:15.896' PROV51 = 'SPHER.2024-12-03T05:09:29.885' PROV52 = 'SPHER.2024-12-03T05:09:43.842' PROV53 = 'SPHER.2024-12-03T05:09:59.064' PROV54 = 'SPHER.2024-12-03T05:10:12.901' PROV55 = 'SPHER.2024-12-03T05:10:25.907' PROV56 = 'SPHER.2024-12-03T05:10:38.904' PROV57 = 'SPHER.2024-12-03T05:10:56.145' PROV58 = 'SPHER.2024-12-03T05:11:09.898' PROV59 = 'SPHER.2024-12-03T05:11:22.834' PROV60 = 'SPHER.2024-12-03T05:11:36.896' PROV61 = 'SPHER.2024-12-03T05:11:50.986' PROV62 = 'SPHER.2024-12-03T05:12:05.895' PROV63 = 'SPHER.2024-12-03T05:12:18.941' PROV64 = 'SPHER.2024-12-03T05:12:32.890' PROV65 = 'SPHER.2024-12-03T05:12:51.722' PROV66 = 'SPHER.2024-12-03T05:13:06.888' PROV67 = 'SPHER.2024-12-03T05:13:20.897' PROV68 = 'SPHER.2024-12-03T05:13:34.833' PROV69 = 'SPHER.2024-12-03T05:13:49.981' PROV70 = 'SPHER.2024-12-03T05:14:03.901' PROV71 = 'SPHER.2024-12-03T05:14:20.864' PROV72 = 'SPHER.2024-12-03T05:14:33.843' PROV73 = 'SPHER.2024-12-03T05:14:49.070' PROV74 = 'SPHER.2024-12-03T05:15:01.911' PROV75 = 'SPHER.2024-12-03T05:15:15.898' PROV76 = 'SPHER.2024-12-03T05:15:32.892' PROV77 = 'SPHER.2024-12-03T05:15:47.050' PROV78 = 'SPHER.2024-12-03T05:16:00.892' PROV79 = 'SPHER.2024-12-03T05:16:14.898' PROV80 = 'SPHER.2024-12-03T05:16:28.857' PROV81 = 'SPHER.2024-12-03T05:16:44.011' PROV82 = 'SPHER.2024-12-03T05:16:56.873' PROV83 = 'SPHER.2024-12-03T05:17:09.880' PROV84 = 'SPHER.2024-12-03T05:17:24.891' PROV85 = 'SPHER.2024-12-03T05:17:39.113' PROV86 = 'SPHER.2024-12-03T05:17:55.881' PROV87 = 'SPHER.2024-12-03T05:18:08.915' PROV88 = 'SPHER.2024-12-03T05:18:22.886' PROV89 = 'SPHER.2024-12-03T05:18:39.034' PROV90 = 'SPHER.2024-12-03T05:18:54.891' PROV91 = 'SPHER.2024-12-03T05:19:07.872' PROV92 = 'SPHER.2024-12-03T05:19:21.824' PROV93 = 'SPHER.2024-12-03T05:19:36.039' PROV94 = 'SPHER.2024-12-03T05:19:49.892' PROV95 = 'SPHER.2024-12-03T05:20:03.833' PROV96 = 'SPHER.2024-12-03T05:20:17.907' HISTORY File modified by user 'mmontarges' with fv on 2026-07-25T23:04:46 HISTORY This file has been generated by the HISTORY ESO Science Archive Facility Data Product Cutout Service HISTORY Version 1.0. HISTORY Please refer to the keywords CUTARCID, CUTCIRCL, CUTBAND, HISTORY and CUTPREV to know out of which file and extension, HISTORY and with which input parameters this cutout was generated. PHOTZPER= 0.5 CHECKSUM= 'MkBAMkA7MkAAMkA7' / HDU checksum updated 2026-09-20T13:23:32 DATASUM = '265794786' / data unit checksum updated 2026-09-20T13:23:32 P3ORIG = 'EDP ' / ESO external data product CUTARCID= 'ADP.2026-08-19T13:19:07.647' CUTCIRCL= '(ra, dec) in deg (88.792938, 7.407063) 8.33333333e-05 deg' END @Q+E@Q*@PƩ@P֏@P? @P@Q+y@Q^@Q @R- @Rt@Sʹ@Tt@Uѣ@VW`@WbS@Y `@[2P@Za`@Z@@Z@[T@\M͠@]_@_r@a 8p@bAqp@dGP@f!p@f@g@g}@g@gE@h)_@h)@i$@i @k @l@mx8`@o>'@pƠ@q2@r@tZ@v}@wT@yژ@{Y`@~bM@j @Ǭ@ذ@@!p@L~@@U@H @σ @Q@@*@^@@E`@@@ @@[@)@@Ȣ@nR@) @@ݰ@g@6@K @pf@ e`@vf`@2@0@ @:@@/@"`@K @@J0@L@̥@Js@™@@@@@Ÿ@^e@c@@E@'`@`@Z@y@%@$@I @U@{@@xR@uG@s>@qM@o,@mǐ@l9@k@j0@h@@g @fl@et@dEP@b`@ai@`z@_`@]0@\%@[0@[cp@[ }@@Z@@Zpa@Z|p@Z@Z=`@ZL>@Y#@Xݐ@W@WM@Wp@WO@@V@Vd@Up@U/@T&p@S瞐@S8 @RxT@Q@P @Pe(@O@N(@@NF@M@M(`@L`@L@Lc@Q@Q6b@@Qp@Q0@Qm @Pڕ@P@Q&}@Q~@@Q0@R@S[@T@U݀@U@V@X@Y֠@Y @X@Yz@Z@@Z@\`@]ӷ@` @ao@c@d@e`@e@eW@fc@f@g@g@@h @iڠ@k +#0@lAޠ@mP0@o+ @p|#P@qg @re @s@u'P@v\@xY@z@} a@NN@0@; @P@ P@@ @`@\@&@@\@D@a@XR@@@ @$@P@"@M_0@Ҏ @@@@Y @@Vi@@#@l[@@Y@K@(p@@@@@ P@K:@D-@Bp@@F@ȱa@øc0@V@$ @ +E0@\w@֐@@'B`@ɱ @Z`@@Pl@.RP@- P@!@@-@@{0@wG@uOn @sh@@qW0@p@n> @@ld@kY@jP@ia0@h嬀@g1@ft@e @cp@b+`@`i@_&@^`@] $@@\^@[@[@[N`@[n@[a`@[m@@[ @Z(@ZgH@Ybp@Y@Xץ`@X0@W@W.@VQp@VҐ@Ui@T @T 60@Stm@R<@Q`@Q@@PyN`@P N@O;o@NP@N;@M@Mw`@Myv`@MK@@P@Q!P@Q83@Q&$P@P&P@P@Pˑ0@P@Q-P@Q7@R,@R@S @Tk @@U%K@U8@V@W|Q@W{`@Wk`@XzB@@Y@Y`@Z @\D2@^, @`C0@a@b @cTP@d @dP@e@e0@fK@gD@hq1P@ic@k0@lW>@m +@@o`@pH@q5@q@@s +@tXH@uO@woi @y`@|Kn@@\w@@c@F>@@Op@F@~@[@ @?k@@df@@p@l@@`@hP@y@@@@6@:3@@1`@א@B@ސ@@f@U@c@@Dp@)@<0@/@@Dz@ @#R0@8@ٺP0@e1@@G@@n@P@2P@ @M]@}Z@e@ 4@E@M@U@@%@@@=}@~kp@z} @w}@u. +@s!9`@q@p[P@n@@m20@l@l.P@kӠ@j@hی@g@fs@dmL@bP@ao0@`H`@^{@]@\ P@\u`@\"@[╠@[@[d@@[;@[@[I@Zi@Y6@YM7@@YK2@Y @W}@WR`@V0@V@UyS@T˳@T`@T @Sxp@R0@QF@P(@P+Y`@OJ@@OA@N؀@NT`@N@N2 @M@PFP@Q@P@P@PP@P@PO@P@P)`@Q7@Q @RF@S ͠@SÉp@T]p@T@U9@V%@VU0@V@W30@X@X`@Y `@ZA@@\h@^u\@`4@a%P@b0@bİ@cZC@c@dޠ@e2@f @hr@ip@j@lX@@m@n@pX@p@qHP@r8@s@uA@v6@x- @{^@(@@]/@@@@g@[@Q @E0@j@V@@@I@@P$@@@ᘐ@d@:@f@ѐ@T`@ܫ|@8p@`@@@@G`@Z@z`@Z0@@ @@ܠ@Ko@ @.3@@0@հ@gp@o@ĝF@@t@H @@͠@1@'P@0@'N@h @%@V@@&@1@@@~75@zE @wϸ`@uno@s|@q3@pg@p>8@ot@n%@ma@@ly@ksp@j4@h̄@g4@eq`@cM`@b @`B@_0@^o@]3 @]1@\@\@@\|p@\qN@\g@\;@[u@Zp@Z@YY@Xȏ@X= @Wl@WZ@@Vʰ@V @@Uo@T0@T^40@U~@Tπ@Rg@Q0@P߉@Pf@P+P@O @O#@O$@N]0@Nk@N@Px@@P,@PP@P[0@Pbv@P^Հ@Pd@PzI@PG @P@QL@@QKP@R\@@Sg@S|P@T0[@Tw0@UR@U@V5'@VB@WC @WW@X@Y(@[A@\h@@^F@`Jp@`0@a݀@bc{@c b@c@d@f @g@@iG`@j@l0 @mq @n@op@p`@qO@r1|@sBN@t@@v5 @x #p@z@~ð@#@N@-IP@L@?W@1`@p@r@lx@K@@[@0@0 @4@k@@0@/p@|``@o@|0@U@Ԁ@:`@Sp@kp@@#@`@0@@@@@@u@Z0@w۠@/ @y0@@։ܰ@(@r0`@n@@p@@ʰ@@`@`@J@n@׆@ 0@@@`@N@~`@z0@xKo`@u@sxp@r`@q~@pȏ@pObp@oܼ @on@nx@m\@k @j^@h@f@d+@b@a}P@`o`@_t@^@^up@]٠@]V@]F@],o@\Ѡ@\@\(^`@[K@ZQ@Yo@XӀ@X(1@W@WL-`@V@V @Um`@Tn@Tb~@S҂@RP@RpK@RQ@Qu@P9@PD@Pkr@PH @P@Oī@O@O @PPm@Pt@P\@PA;`@P6@@P @P4@PJaP@PXE@PY@P @QYɐ@Q @Ra@R@@Sr.P@S`@T0@U@Uk@U|@V~@@W!0@Wg@X@Z<@@[@]S@^n@` @`F0@ae@b2`@c @d=@@e@gAWp@h'@j @l @mE@nhQ@op@po@q@qߊ@r୐@t@@u@w0@zo@@~;P@)@[3@6p@z6@@@ @!`@Z`@@@#@y@_@E@!@S@L3@ʰp@Q`@P0@P@Py@PB@P=g@P$0@P.8@P5%@P': @P@Px@P h0@P"@P2;@PaH@P@P@Qu`@Q䭀@RP@R@@SL@S@@TS1@T0@U\E@U@V@WJV@XZP@Y@Z”@@\@]u@^@`-p@`@ak@b@@ce@e43@fl@h@@jBj @k;@l률@nH@oPY@pA@pe@qP@r@sK@u4P@w5@y(@@} @pG@؋@] @GOp@6z@@mIp@h@ @`@}@Mi@@@, @P@P@@Ҁ@9$@u @D@T@{k@P@@@@C@p@ɢ@@Ѻ@0@@@ @ @@'@%@ֺ@`@а@n@§H @~P@`@@%`@i@@tN@;@@@ @ @ @5l@@~Ę@{@y@@wi @u@@t2;@s @s @@r_-@rӠ@q7@q1p@pC@ozP@n@kR0@irP@g40@e@c<@a#@`@`: @_I@@_x`@^U @^q@^Sְ@^p@];f@\E`@[E@Z8Ɛ@YVk@X@X@W`@Wǐ@V+@V@Uy@T$@T@T\ @S@Rq@Rz@@R_O@R r@@R +@Q@@Q@Qk.@Q'@PG@P{`@O#@O@O0@Ou@Oج0@O3p@O@@P @PO0@PF40@PA@P@Q630@Q`@Qz@RN@R @S58@S3`@Ta@TӠ@Ujp@V3@V@W@Y"p@Z4p@[@@\o@@^@_zZ@@`w_P@a6p@b#@cg@dѠ@fY@hM@i0@kS@l@m @nܰ@p G`@p]@q]>@@rHj@s~r@t(`@vúp@yt @|@@P@ @p@o@/@pH@@d`@R@0@p;@@l`@`@Cp@p@B@?0@γ̀@Ӑ`@~*@F@=@R@t@@@@R@P@R@ˀ@@@ݠ@B @{@|`@VP@@@-ƀ@޴@%@@ϟ @:@@u@ [ @@@ @f@1zp@p@@:,P@Q0@ `@I'0@4@]&@f|@|@@zm0@xN@vCp@u7@tb @t9%`@s)p@s' @r @rk@q@q `@p!@mـ@k0@hv@@f$@d+@@b@a@`@`:@_0@_\-p@_% @^@^F@]ok@\f@[R@Z/@Y5@Xp@@X@W@W@@V@@VP@U@U@T@T@TVc@Sp@S@S2@Re@Rɂ @R@R%@RGm@Q$@Q @Qn@NF]@NՐ@OG @O4@OPL@@O`@O:p@P@P@PM@P @Pľ@Q@Qqi@Q_@Rd@RN`@R` @Se@@S0`@Tbp@U8p@U@V^@W)@@X\@Y@[#@\`)@]@@_@`'@@`0@a.0@cS@dY@f9$P@g@iXp@jU@l5O@mQҐ@nqH@@o@@pj4`@q@q@s @td_@v<@x@|)@KD0@0@@mL@`@"{`@VƐ@X`@O@*^@Ma@@0`@V*@D@k@$@ϳ@@7D`@m@@EU`@Z<@lĐ@@`@-@Ѡ@@p@ `@@@⽐@p@z@_@d@h@Y@Ԯ @gP@ŷW@i`@醐@m@@{@@#@6/P@N@ y@M@A@@vp@y`@@P@}]@{.I@y/@wk@v2P@uo# @tw@tY9@tZ^P@t @@s)@sP@rMp@q:`@oY@l@j @g^`@e/@@c@@b9>@aKZ@@`@`: @_Y @_v @^p@^'0@]]P@\M@[E @Z:0@YF @Xx@WH`@W`@W7@@@V@V@Uް@U@`@Tɠ@T@T@Tc @Tf@@Sʧ@S`@S@S@SM 0@SP@R@RzG@R;@MO @M/@@N@N@N@Od@OE@P/@PUP@P0@P `@PS`@Q*U@Qo0@QSP@Q0@R)@@R@S +)@S`@Tc@U @U@V_@Wp@X@Y @Z2 @\/@]^ @^}@` D@`p@a+P@b2@dB0@eS@g@i3@j @kغ@lȀ@n @o( @p(X@pT`@qS @r~P@@s@u@x0@{<@S3p@@!@P@އ@ @ 2@%p@@@P@@-@@T@@ˡ@%@@v`@@`@i@@`@ @:fp@p@@Đ@@`@V@ö@SP@@@@ @N0@@@7 @@ @fP@{o@˃C @Ú,@@`@ͪ@C@@@@9@@B@n@S@"@`@Sx@!P@i@~ P@{ܞ@y@xM.@w+@v@v!@uk@uq7`@uk6@t@t`@sjp@rn@p@n@k@h@f4@da@b@a]@a,@`V@@`FM@_f@_%p@^6X@@]>@\G@@[D0@ZO7@Y^@X @X,@W@Wp"@V@VW@@Uc@@U{0@U+@TP@T@Trp@T @TQd@@T!@T !@T*@T @S @S@SB @S6@L[p @Lı@M;@M @N^@@O$p@O`@PCڰ@Pf @PЋ@Qp@Q8Ϡ@QaQ@@Q@Q@@Q@R`@Ri.@Rt@S@T2o@U I@Uw@Vn@W@@X@Y@Z0@\s@]M@^@_t@`p@a@b@@cY`@ec @g;@@hn@jAm@k@l@m5@n@o@pxbP@q@rE@sY@uu@wk`@z?J@} @EB@:p@[:@p@I@ӎ@Ñ@_@@_@@x@@ƀ@ @r@7|`@϶@" @و@z@@@m@@e@<@@ @@2@@Ĥ@|@ڬ@U@5@@콁 @`@ὀ@@5`@щc@@ @4z`@@)@@&Ȁ@:`@;@To@@ @٪@R@o;@K!@HP@m@ @~`@|^@ze@y i@xŠ@wq@w0@w!@vV @v0@v4@u@tÓ`@s@@qc @p@I@m +0@i@gZ @eH @c @b`@a@a,^ @`@`7*p@_u@^l'@]`@\^r@@[ho@Z|`@YT@Y|@X@X5=P@W(@@W2ܠ@VC0@VHx@Uc @Uxj`@UA@U@Tp@T3p@T@T@T1`@TZ@T+@T̐@T_%@T@S @Kp@KӠ@L0@M3@NP@O@Px@@PZҀ@P@Qpp@Qj@QI@Q`@Q/@Qm@Q@Ri@Re @R@@Smo@T;0@U@@U@V@W@Xt@Y1@[e@\:Yp@]o @^`@_@`S @auw@b@c@e7*@f @hw:@j@kA@lb,0@mj @nh @od@p4@p@q @s@t`@vpp@y$+ @|gI@[ @ภ@@K0@qI@Ҁ@kn`@f/@@@#.0@3@HP@/@@8@or@Ȕ@@@0=@D,@&@R@á@p@@p@@E@N`@P@ @P@@@^`@@y@]t@@%@`@m(@cf@P@ϼ@`@g @10@q`@ @`@ @6@@]@w8@Ɛ@d`@0@~ޖ @|@zt`@y@x@xBh@xl@xC@wݐ@w@@wHa@v_@u@@t@rp@q" @nw@kB@hҰ@fb@dЁ`@cհ@b@@a̯@@a-u @`s`@_߃@^@]@\@@[ @Z@Z @Yp @Y e@X@X>@@W@@VC@V֐@V@U@Ut)@UM'@U1~@U(@T썀@T+0@U0@U!P@U>@U(Ap@T@T @Ta`@J_`@K UP@KU@L @M˯ @NG@P.@P @Qp@Qb@QO@R Ġ@R98@R9`@R+@R2@RAV@RwQ@R1@S=@T[@U+10@V5k@W"@WSp@Xj@Y@[P@\X@]@^m@_} @`#@ahu@bbr@c @d`@fs@h ې@i-0@k @l +@m"#@n@n@o@pE0@qt@ri@t@u @xtp@{?@~@@q@$@`.@,@@ @@@[`@/@@OH@@~ @ ـ@̸@e@1@ @sp@+@͠@q@,@O@@@w@@, @@Ҕ@-P@@tP@W@@'@ @@@h@ęa@@5@@@ڟ@X`@f@Q @? @wP@@6@+@ @p@۳@@/@~@|@{CL@z@y><@U@@U@U +`@U4`@U_@@U@U@T!0@SN@Tܫ0@I/@J] @K3I@L9@Mk@O1@P!@QV`@Q`@ @Q:`@R@Rhd@R @Rh @Rs@Rh`@R~@R@S @S @T @U0@Vy@WoY@Xe``@YW`@Z^`@[aG@\r @]Vp@^@_C @`@ahP@bO@ccY@d@@f  @g@i + @jB9@k @l@mP@ny@ot@pQ@q@r61@s@u"[`@w\@y~@}!Π@P@`@@hM`@#@D@pP@P@K@X@9k@@Ӡ@)`@ŏp@ʔ @*pp@@@(L @y @)`@J@S<@h@0@@Yp@ + @߻@@;@b@@*@݀@׵@200@Z`@G0@@{@@@N@} @- @H@: @90@̇@0@!@R^ @@mp@?`@~`@|P@{R@z+@yap@y@@y+ @y {@y)@xŻ`@xp@w`@w'@u_@@ty@r@pP@np@kBp@iP@gj@f p@dߵ0@c @br@a#@@`@_t@^nk@]p2@\@@[@@[`@Z@Z=Đ@Yi @YK@@Xu @W@V@V\@U @U@UI @U*p@UҠ@U @U@UE@@U@U`@Uf@U@Sn@T(@I)6@I·@J`@Kz@MS@@O,@P@Q5@Q[@@R1Q@R;@R_@R@S`@R6@Rȗ@Rb@RX`@S`&p@S&@T@@Uވ@@V`@WD@XP@Y@Z@@[F@\ @]@_ @` +E8@`@an `@b@@cB @di@eB@g@he@i}`@j@l @mo@@mE@n˄P@o@pP@qƁ @r + @tbT@v)@xpw@{@T@@@@li0@ @@@>Z@{@F0@@FP@@\[`@Қ@,@p`@A@&@ѧ@P@ +}@.@ +@Ώ@@7/ @eP@h@.@k@@ @%. @% @행@FҠ@m@@;@T@ә0@΋t@ǎ@D@@Ȓ@@ @@9@@P@l@@@`@@@%;`@0@?A@@n4@~lP@| @{6@zA @@y@yq@ya@yC@y%@x"@xM@xP@wcp@vd8@t@s!7p@qQ*@oh1@@lřp@j@iz@g@@fG"@d0@cV@bE@a,@`:@_d@^p@]S@\zq@[ p@[L~0@Z9p@Zp>@YĢ@X@W @W $@VN@@Uu`@Uz+`@U@T@T\ @T@U,@UE @U*P@UX@U@U~@Vn@W4`@HI @I@@JtX@K@@M@N@PP@Q@Q@Rr@R @S @@S^X@@Sh@@SF@S3[p@S'@S9j@SL@TC@U,@Vk@W +`@X @Y@@Z|P@[wH@@\fP@]h=@^YϠ@_w`@`,@`.@aj@b7@c'@d>@evy@f@g@iP@jHO@kP@ltw@m=0@n@o@@pJ@q7@rRp@s`@uH@wOp@z"`@}/@@p@k@V@}]@dP@U@ @<@nT@@GP@ij @5@@&@Uɠ@;@+`@ۗ`@$/@S"`@@%p@}p@'@@і@r @\ހ@1@꺁@NP@U@a@Մ̰@y @yS0@# @@ @@@P@@%@@@9>@q@j@@ @c@YC@F`@S_@r@@@F@@}ˀ@|E@{ @z)p@y`@y`\@@yG@y/@yp@xX@xp@x `@wa @@vgM@uu@sj$@q@pKǰ@nDY@lM+@jY@iu`@gm@fm@@dֺp@c./@al@` @@` e@_@^<@]k@@\"P@\@[@@[#@Z=~ @Y)@Wd@V@@Vc @U&@U\`@TC@TK@Ts@@T@@TW0@U8ߐ@U8@U9@U@U. @U`@W @H@It0@Jb_@@K@M@N@P~6@Q}`@Q@R8@@S@Sd@Sp@S3@SE@S@S@S5@S@T@@U2@Vp@X7@Ye4@ZNB0@[3C@\+*@]%P@^ڠ@^ɷ@_p@`@^P@` +@ag@@b3@@c`@d"@eB@fpx@@g`@h`@i0@jץ@kl0@l@@mFF@n7@o@p@q@@r@ts`@vG @xí`@{`@}@@= @; @g@@@@P@B@z @@s@R~@߫@1@ŜiP@ @l3@bp@j @ؚh@@@@S@@mk@i@9`@ @@@b@E@|D@Q@?@Pmp@×@ڛ@0@b@\"@Sr@'P@@@3@@C"@H@)`@ @@>`@ p@'] @{N0@}}O`@{@z[P@yP@y @y7@y @xս @x0@xL`@xC@wP@ws@v<@uN@@s`@qA0@pCR@ouP@n @lʮ@@k|@ia@h =`@f;P@dX@b +@akq@`@` p@_2@@^gtP@]y`@\@\}@[ҕ`@Zچ@Y@Xx@WP@Vw @U@U4@@T@T@Tjj@T|y@T@UWP@Uar@U@Ul@UƊ`@UP@U@H@IiĀ@JU@K`@M @Nf@Pt@QX@R8`@R `@S3@S\@SF@@S@T _P@S@Sw@S @TE[@Tk@V 7@W~@X@Z$@@[-P@[@\@]@^D@_iv@` @`U@`@aY@b% @c@d@e c0@f2π@g=}@h;"@iMD@j*yp@k@kȶ @lg8@mRQ@n.@p,C@qf@@rPP@sP@u|`@w@z@}c@8 @_@DP@Զp@@x@@E@@ `@@`@@]@+@Z@\@ @Ԏ@b@P@S@:@ޡ@.@Q@Y@8@_ @C@eP@$~@Z@@p@\@3@R@(@@ P@v`@k@ @N @͐@#:@@@`@Jp@mp@b@@40@t3p@ @M @}@{@z@y@yQ@x @x; @x7?@w뷰@wgp@w=I @v @vh0@u3p@t@sp@r-x@pA@pmT@o0@nB@mY=@k @i@g!@e%p@cn8P@a@ @aH@`@@`+>@@_w @^Y@^@]o@\@[_@Z @X@X74@V@U\P@U`@T@TWM @T$!@T6`@TzU@Tb@U-@U_)@@U@@Ux`@U@U @H9@ITP@J[m@K0@L@N@PX@Qal@RG @R @SJ)@S=@@S@T$`@TI@TL +@TBY@Tf@T@UQ @V @Xn @Yxw@ZԿ@[W@@\P@]P@^ @_[@` ]@`^@`q~`@`-0@aD`@bA@cp@d@et@f @g@gM@h{`@i^@jM@k@@k P@lW0@mJ@o#O@p9@q1P@s+ @tӴ@v@yB@{p@ @X<@/@5@@)@l@@` @P@>`@jc@@_@@̀@`@`@@Q@tð@`@΍P@=@i@@u@@:L@|`@ٿ @خ!@מ9@և `@Լ@_0@<@P`@;P@ön@ @p@oP@- @P@"@@@x<@f"@-JP@£0@@@ @`@yZ@P@ @yK@~@|ݬ@{~@zal@@y@x@xW@@wP@wk@@@vE0@v1@vH@vP@ugP@uÀ@tS@@sfB@r{@@rg@@q@p@p0H@o;p@md@k0@iS0@g`p@d70@c@b `@aP@`P@``"`@_M@_5> @^c@]@@[t@Z_@@XW@W@V`@U@U P@T`@T1s@@St0@T^p@TL@@T@T균@U'P@UNN@UP@U +@Usv@IMW @I@JzR@K@Lmp@Nu@P4`@QD?@R7wP@R@SEg @Sx)@S0@Tv@T] @TL0@T`@T@U.(@U@W8|@X@ZV@[r-@\\P@]@^aP@_@@_@`Ej@`g@`p@`V@a< @b@cp@dz @e @f 0@fe@ga@h`@i5 @ie@jSa0@j!@kpP@lvW@m@o`@q. P@r@t0P@v 0@x40@z@}Ҁ@`@@g@k@@`@$@@n @u`@h@@8P@@@۠@@˜@OŰ@G@o} @ΐ@Ю@/P@@m@@$P@)@A&@ί@үk@Ep@ϯv@M@@ȋ}@ݰ@kq@JG`@@%P@@}@_@wp@y@P@@ @U@^]@@~Y@@@s0@#+P@~b$@|@{c%@z8@y3@@xA@wW @w#@vepp@uۛ@ur@u$ @tv@t@tU)@s֋@s4@r}}@q@r!\@q@p@pr @o@`@mK0@j܎@@hO7P@e,@db_`@c=/@b @az/@a@`s@`"C`@_LB@^b@@\b@Z@YE@W@@V`@Uՠ@Up@Tl4@T! @S @S @T0@Ti0@T@T[p@U`@UW)@Uw@UVp@I@J @J@K`@LU@NK/@P@Q$X@@RD@R@S)`@St +@S_@Sg @TW`@T`@T0@Uo`@UP@VT`@WX@X@ZrB@[@@]R@^V @_@_`@`" @`h@`@@` `@`#@a=@a%@cӠ@dC@@e`@@f0@f`@gƑ @hh`@h@@iVlp@i@j&MP@j@kk3`@ls@nu @p`@q@sI@u^0@w-@x)@@{1@}F@o @@P@|@ܗ@@@H@=`@`@cp@u@ @`@&P@K@@P@@ŏR@1@J`@6k @@@{N?p@z!-@y0@w׼p@v@v ;@uI@t@t/S@s@s0@sp@sP@sCH @rݷ @ry^@r#@q@q`P@qu@qQ@pWn@@np@l#0@ioh`@g@e@d @@@b-@bp@a@a;`@`k`@`"@^@@\@[7P@Y @Xy@VƔ@U!@U( @T@@T%@S@S`@T8@Ty`@TY@T6@@U A@U= @U_5@UC@J!@@Jb`@K@K`@LD @N,@@O @Q`@Q@Rh @R@SG@@Sv@SM0@TB@T@T`@U2@U@V@WԽ@Y#@Zp@\C@] @^Y@_@`@`\@`P@`:@`@`۞`@aF@at@b;@d +@e@@f}@f@gR@h}%@hܰ@i"`@iTT @iz@i^p@jg@@k0@mf`@o@qF`@r @t~@v4(P@w@@yt @{@~ Mp@K @Z,@0@ @{@s@@@@{@@P@@=@.2@FZ @@K0@R`@@@@N@p@݀@Z@ @Ӏ@F@^@Vl@2 @E`@g|0@ @v0@׀@@B@}-@||@{G@y@x@wTk@v!JP@uw@tY@sQo @rҁ@r p@rM@rO @r@rY @r@rh@r29@r@q@q@qm<@pP@om[@m @j[%@g@e @d@c@b?@bj9@aX@@aKP@`q@_@] @[ο @[w +@@Y^s@W&@V@Ӑ@Uv`@T}@TY@T#@@T6P@TvD`@TL@Tp@T@U @UI@@UX@U0o@JK @Jđ@KI@K@Lq@N@O@PC@Qߛ@R\@R@@St`@SFE@Sz@T@Tt@@TХ0@UL@U9@V@XA @YRD @ZW@\q@]:`@_C@` @`EQp@``@`T@`p@`@`'`@aZ@aې@b@c%p@d7@f@g`@gQp@hL@@i)@i0@i +@h@i@iq@jw@l p@n@@ps@rz@sZ@uW\0@v@x @z!I@{gP@~`@V`@M@@@@@@@'@P@jm @3zP@Ue@ɤ @㚐@t@@ @!`@^H@@}r @ĥӰ@rP@Ũ@HB`@ĝP@Ç`@@@ +6@@@@^`@Ԑ@H@~@w`@DP@Op@Q@@K~@@ @`@@1`@%@@~/P@}iP@|9@z@yu0@x6ʠ@v@uL@@s@r!@@r @q`@qRp@qN0 @q~f@@q@q3 @rs@r%@r%`@r@r@q@q%@p4@@o`@me@jӽ@hem@fzp@e2`@d'@@c`@cpG@b@a@a b@`?@^P@\@[Fа@Zp@W@@Va@U@UX<@T3@T`@T`@U6@U1@T.@T^@U)\ @Ux@U|w@UO]@@K @K?P@K@L74@LG@N€@O@@P>@Q@Rz3@Rd@S +@S%`@St@S@T_@T @UZ3@@V +@W [@X88@Yr@Z@\p6@^@_@`,Ǡ@`X@`&@`0@`1`@`걠@a @aiހ@a\@bĿ@cP@d#`@e@@g`@ht@h0@i@p@i>y@@i 5@hV @h`@hc`@ie`@jnp@l`@oB:@q@r`@tCC@u҇@@w@x@z@{`@~^@@#R@‚@@c@B@˲@@ @@70@w@B@d @$@@,B@@@@Q@ @R^@4 @ӱ@f@$P@@*@)@{@F@f@@Yb@@ @Wp@ @@5b@`@Va@@ @.P@a`@v@t@c@ p@p@_@}6@|}@{@zt0@yn@wʀ@uZ@tQ0@r@qо@pN @pX~@p@p7Q@p}@p7@q"=@qD @q@q@q0@q@q@qr@pǼ@o@`@mOgP@jծ@h0@fp@eؠ@d'`@d@c@@c4@bw`@aY@`] @_`@]f@[@Y5ՠ@WK@Wn]@V @U @Uv @U `@Um@@V0@WH@U0@U;|@Ud-@U@UI@Um @K~@K&@L<@LB@M8$@@N3u@O@PU@Q.p@R`@@RVP@S@S6@SSp@SY@TG@T@UJ@Vѐ@W a@XH@Yy@Zё@\k@^*@_S @`@`V0@`@`@`{@`/@a@anP@b- @bt@c@d@eIp@f@gG@h @i$`@iJS@h@hN@@h/@hCP@h~@i0@k'p@m]@@p$@q{@s05p@tH@v@wjP@x~`@yȷ@{~L @~-@ @ @O@Ϳ@{@! @70@@̀@e@`@#@1@@@q0@Π@@,@ @@ +@ @`@jXp@ʠ@l@0@cP@v0@e@ @[p@@R@@gP@@0@0@h@.@,@n7@t@ `@@~WP@}c0@{k @z@y @xM@v@u@s``@r&`@p&`@o@nž@nv@n@o @o@pj=@pL`@q56@@qm@qp@q@q` @qv@@paV @n1@l@j#@h$@g@f@e^@dG@d>p@cx`@bm0@b1W @a_B@`c@^Y @\-@Z!@WUGP@W@Wh @VИ@@V=ݰ@Uǰ@U0@U@U@@U&@Uuc@U5@U@V!@U3@L8G@@L:`@L@L/@M@Nv@O&@P @Q!0@R0 @R@S@SP@SPؠ@S@T@T@U%9@UP@V`@X@YBX@Z@\4y@] @^Q0@_k@`"@`5@`a4@`}@`@a.@@ai@@aa@bB@c@l@d/@eS$p@fzoP@grp@hJ4 @h@@it@hp@hBzP@gp@gk@gƠ@hg @imp@k@nX,@p0@r@@sq@t؄@uHp@w"4`@x@@y`@{@P@~0@p@{0@ `@i@@&@ @@ơ@Ҽ@@~@@L@wp@@`@@7@@>`@Ɠ@*@|@@0 @܌@Qp@@@0@@t@"@"@0@@~p@fP@@B@I@m@T@@@@Ux@~RP@}O @|@{)@yр@x@w[p @u@@t@r|`@q@o|@n]@mo`@lH`@m@@m`@nJ@oHR@p%0@pr0@pX@p@p@@p80@p{@oƉ@n<@l@j@hIp@gQ@fQ@eY@e(0@dP @d @cWP@b@aÁ@`@_@]@[(@Z&p@Xj0@X| @W@W<=0@VP@Vg[@V @UV{@SCY@U@@@U@V @VH@V4@M @L0@M#@M}Đ@N@N @O @Pp@Q]0@RZp@R0@S@S!P@SI@SP@Sp@Tc@TY`@U@Vr@W[ @Xf@ZC@[C@]C/ @^R@_7@_|@_@_ @`7>x@`Vp@`C@@aM@a@b[ǀ@c g0@c0@d+p@eﺀ@f @g( @h{@hR@h}Q@gU@g @g8@g'<@g@h|@j<0@lo@nR@p@ro@s@@t@u<@ve@w@y_@{L@~M@T`@@@"p@p@@-u@{e0@4p@(Ap@@@c1@R[P@ @Ƿ@@ڛ@@1@@h@>@@&@_,@@5@@e@l@)@;w@@@@@ޛ@ E @}@s,@@@@r@@@P@ +@Ӆ@B@}p@| @z@y5@x @w9 @v6^ @t@@s@qˀ@pQ0@nΓ@mJ@la~@l ܀@l@@la0@ls@mx@@nW@o@@o@p@p@p +@oc/@n- @l@j1@iJ@g@fe@fM@@e۶@@eJ+@d@dL@c8@bM@b @a@`,_@^[@\@[`0@ZX@Y2@@X@XMذ@W P@WYB@V0@VM@U4E@U2@Ue@V/@Va@VZ@M@@M@MP@N#S@Nw @O$p@P4 @P)@Q-@Q}@RP@RLP@S;0@SJ@Sxp@S@T0@T5@UJ4p@VA@WX@Xp@Y@[M&@\@] @^z@^@^V@^6P@_t!@`%@`2@a@a@b(O@@b0@c@d@ep@fp@gO @hP@h[0@h-@gt0@g)`@ff@fL@gp@g0@h@@j@l@okq@q#:P@rv@s@t@u`@vz @w_@y`@|X4@8@F@0@6p@@N @1 @]@=c`@h(`@p@ @Mp@@@P@@@8@.( @@jp @m-@o`@q2Lp@rWh`@sO>0@tMn`@uc̀@vu0@xI@zK@~M@Y+`@P@@@ @1"@̼@@B@p@W`@)[@p@wI@`@@@x@ԋ@ @U@@!@`@"w@T @X @@@@O @s @>@@@ظ@v0@@@, @@|@&@>@~ː@|I@z @y)0@@x@@vp@u0@tP@s@rvP@q@X@pɘ@nPF@l@l`@ktm@k"@j`@j@jo0@j)@j@@j@kg`@kb@@k@@k]C@jx@j8@@iCc @h4/@gHO0@f @fE@ek@e@e6@d)@@db@cd@b@a@a!@`8H@_@] @[t@[o@[^> @Zո@Z[1@Y@X @X&@Wc@@V/ @Vp@VhBP@V^@Vgo@Vr}`@P@O@O^8@OZ @O@O @P:p@P6@@Q'Ű@Q`@R4@R@S%@So@@S@S3@SI @T U@T~V0@U' @V L`@W(`@Xv@YՐ@[u@[k@\@\ٔ@\Ƞ@\ސ@]ZL@^*D@_N@`BX@`.@a@@bK,`@b @cF@@dm@eP@e`@f@g?c`@g@@fU@f}c@f9 @fp@fP@ff@fM@gzP@iAE@@j&@m@%@o@@q@r@s#V@tJ @u`@w9Z@@y@}IP@Ű@#@ɠ@@@ 0@~@@CP@`@N@]P@<@l@0@ +@m& @P&@9@@D@6@@P@@@K@ @]@y@}1`@@g@@i@|@1C@@@q@@Ԑ@|@},@z_@y'@wf0@v@@uo @t{@s+@r0@qO @plP@o @m @lq8@kX@k&@j@jz@j@in0@i@hْ@h=`@hl@h԰@h5@hJP@hh@h&E@ge&`@f*`@fQ@e@é@e$@eI@@d8@@da@c0@c1@b}@a@@`@@`%P@^ @]ی@@] @]&(@\ڰ@[ @[ @ZfU@Y@Xɡ@X2@W]@W @V@Vp@Vv`@Vl@Pˀ@Pn@@P5t@P@P +x@P,@PW`@P~@Q|`@Q@R`@R7@@R@SS"p@S@SK@SP@S@T 0@Tـ@UZ.@VV@W`@Xp@Z+@@Z30@Z@[U@[@[N`@\>͠@]/@@^fE@_p@`w@a!@@a@b@c3 @cz@dNF@e @eԡ@fWn@f(@f].@e@e@eư@e#@@e׆P@f:`@f@gn@iM@@k P@m5@o@p5@r@s%@@t@vp@y4G0@|]kP@*@@8@@@Ԡ@7@U(@e@~@P@@p@:p@o@(Ҡ@F@ @C@+@"@ln0@4@@ P@)@N@ @p@b=@90@w@`@Ȳ@p`@Mi@@@ @CP@~ߐ@{^@y1@w6@ve @u,@@t8@sd԰@rP@qP@p@@oS@n= @l @k`@kP0@j%@jw@i߅@iJ<@h@g@gYS@g`@f`@f@fi@@f$@@fs@f/@e@e`@eNP@e.@e. @e#@d;p@dy@c`@cy@bp@b/@al@`@_@^˦@]ՠ@]"[@@]@]X[@\@@[@@Z䐠@Z,`@Y5v`@X@W@WW@@W@Vۀ@V~@@Vx@Q@Q&@PZ@P}@Pa@Pj@PP@P@Qz@Q0@Qx@RcA @Ru@S$@S[@StqP@S@SA@S0@S~@T @U7`@V@Wb@YIY@Y@ZԖ@[@[@@Z0@[B`@\!)@]ZR@^`@@_`@`k@af%@b A@bg@cp@c_@d @d@@eno@eM @eQ @e?@@ep@dp@e @e5H@e-@f@f@g@ig@kBЀ@mW`@op@q@@ru@t @v@x@{@r@@W5P@`@ئ@(@a`@ @~_@?4@ф`@d@0@2@1P@j@İ@r@@(b@@P@@@B0@ɐ@φ@@@c^@2@@@#@S@@@G@`@:@7@q\@ڠ @@}P@zư@x~@v+@u@@@sl0@s.@rZ @q_P@pFP@p>@n@ms @lU@ku@j9P@jz`@ij`@iI@h0@gP@f@e@el0@e>O`@e@ew0@dn@d)@d@d*@db@@@dU'0@dj]@dtp@d{@dH%@cY@ck@@b@@b]Ā@af@a@`M@_o@@^ @] @]4m@\aP@] C@\q)@[p@Z$@Z+Q@Yh&@Xʯ@X)`@W@W Š@V`@VnIp@Vp@R@Q@Q~z@Q  @Pϖ0@P@Pȱ@@Pݰ@Q)@Qi@QǍ0@R.@R @R@S&}`@SF`@SVB@SH` @S$@S^P@S?`@T0@Up@V@@Xf@Y7<@Z̀@[@ZZ&@@Y@@Z<@@[ @\L@]@_`@`)P@`@a~@a@bT @b @c+@c1@dM@dU@d@ds(@dH@d,À@dGp@d@d`@ei@e0@g@@h|@i @kq @m΀@pB=@qKp@sW0`@un@w@@{@~@@G@F@y@x@aP@B@Ơ@w<@@w@Ä@@@Z@0@@+@\@P@>`@ @@0@0@I@R@̺@n'@}@kÀ@@R@@İ@@`@K`@BP`@g @? @Q@|k@yP@wk @ue@twp@rȰ@q@qo@qP@pq'@o`@n?P@m @kݍ@kj@j@jp@iq@h@g`@f@eT0@dӀ@dWҠ@cY @c@c`@QZi@Qw@Q@Q^@R @RS6@R@RP@RP@RS0@R$g@RX@R@Sk@@T@U,@Vd@W@X)-@X*N @X*`@Xf@Y = @Z>@[g0@\# @]@_  @` @`p0@`@`}`@a#N@@a`@aX@b_W@bC0@bi@@b@b^@bꤰ@cM `@c@dP@e~P@f0@h3@i@km@l@n@p@rVw@td@v@y@|E@=$@*@xP@`@@@ @!P@ @ @`@ `@cP@@x@A p@@@N@=E@@`@~ @ms @@0@P@@ N@@K@\@Cn@HԐ@{`@F@@2~`@.@U@}XP@zG@xC`@v@sC@r9^@r p@r@Xp@q}p@pKx@oh@n]r@m3@l/ @k.P@j|@j@ioi0@h@g@fA@e@@d @c3@b@bw@@bQ@bA@bGP@bM @b@@b#3@be@b Q@b&' @b@aup@aS@aa[@`@`@_!@^@]@]J @\0@\y@@\:5@\}@\ `@[P@[@[@@Z4`@Y&@X@W@W]@V@V @V-z@Vp@V@U@T@T @S]w@@R@R% @Q:@Q`@@QK0@QON@QTu`@Qj@Qu@@Qʠ@Q@QF@R@Q@QH@QZ@Q @Q< @R@Sz@T@U @Vz@W' P@WR @WZ@W>@X+}0@Y/@@Z@[`@\O @]p@^=@_7`@_ @_=@`, +@`)@`o@aKؐ@a[@ap@a켠@b`@bJ3@b@cQy`@d @ey@g`@h@j:P@kꕀ@mݠ@o>@pˀ@rp@@s{@v;߰@x/@{ @{0@`@(@Z@(@l @0@y@*@A @@bC`@@u)@]@`@+3@?l@fܠ@p@@x@tԐ@@BP@@@p@MP@@@80@@@!J`@@P@?@܀@|w0`@y@w0@uՠ@te`@s[@r閰@qh@q\p@p/&@og@mP@l`@k+@l @n[@p& @q 0@r*@s@u`@w@z<@~@ @ZX@@@9`@5P@X@O @9 @b@y|@0@o@1@E@z@|<@?@H @6Ġ@@s @F@@mx@@@@s@,@c@4P0@3@DW@M`@:@}w@{ @yT@wf@u@s@rF@@rqP@qL2@pqH0@o@nǀ@m@lP@kϘ@kFb`@jO@@j +) @i"@g`@f @eQ@d$@@c-*@@brW @b7@aح@av @ap@a@@a(@ah`@a3@aV@`p@aZG@a"@`K@`?@_i@@^@@]X@\0@[@[O@ZS @Zts@@Z4@Yp@Z @Zp@Z@Yŀ@Y@WC @Wum@WnI0@VH@V]`@Uֿ@Ue@@UK0@UF@U^[@X@u@t @r0@qƫ0@p6@p9@oS@n6`@mK@l@kԬ@kU@j@i@h@gip@fYp@ef @cn@b@@bUR@a@a@@a@ak0@a]@a<@ae@`q@`yA@`W@`X0@`]X@`@_k@^p@]@@\n`@[@ZK@ZV `@Y,@@Yt`@Y R@X9@X@X@@X@X}T@W@@Vx@V`@Vֶ@Vj@U`@@U~@@U0@T؄`@T̀@T9@YXg @XSހ@W;!0@V"Ͱ@U@T?@Sa@R@Qܐ@QV@Q @P@P$@@PO@Pv@O0@P6@P<0@P9@P@O@@O6 @P.0@P@Q=`@R!@S@S> @Tp@T)@U@UzH@VN@W'@@X[@Y|@ZQ`@Z)@@[O@[a@[@[@[p@[h@@\dp@]Q@@^1y@^~ @`{X@`@a8p@b@c}@@eo@gz`@i`@l@@n<Р@p)@@pN @qȠ@r`@s@tϰ@u@w@z @|0@`@Tp@(@@90@@-@@1@wh@}O@@@$@@]p@@@0@M!@!Pp@ @us@@@CW@P@T<@ @@@d@`@@ @R @}@|0@zP@x˃@w& @u@tc@r0@q6@p܇P@p V@n@m + @m @lkp@k@k@@jPU@iE@hd@@g7 @eG@@dt @c@bi@bU- @a@a%@aiG@a;@@a@`ڱ@@`7@`Qd@_@_ć@_@^@@^ @^@]p@\K@[@Z 0@ZT@Y@Y@X@X#n@W@Wў @WVP@W @WW@Vp@V@V @V9@U@U@U&Q@Tb@T{]@TsP@Tc@@Zu @Ys @XK@W"``@Vr0@T@T@S@R;@QwW@Q{@P@PXZ@P֨@O Bp@M@O! @O{ @Ou@Om@N`@Nܠ@O/P@Pk@Ps[ @Q3@Q@R͎@So@SG@T3G@T@U#0@V@W=@Xd,p@YN@Y@ZK{@Z@Z`@Z`@Z@ZT@[i.@[@\©@]٨@^m@_@`?@b(! @c@e`@gw@iX@@kn@n:@@pH@p|P@q@r @sTg@tM`@uU@@vW;@wP@zU@|$@@x@%@@@ @'1@@@'p@20@ϖ@ @@:s@԰@p @20@`@)P@n@\i@z@@,@@Az@y@ܦ@@T@xL@=@}@|T@{e @z @x4@wp@u`@t*@r@qw@pבp@p'@n@m@l@@l`0@kP@jg@i;@h @g@@f@ev&@dy@c@bx@bH@ab@ae@aJ@a @`0@`z@`,@_5 @_P@^j@^߲0@^g@])@]@\@[0@Z@Z[ @Z;@Yp@r7!@r@sIp@t;! @u$@@v]@w@z @|k!@~@\@@6@j0@`@@m@:@9@Yc@@@ @^Ҁ@@@`@@[ @K@ P@:A`@L0@o@<@#@@`@~ߠ@}@@|`@{ڀ@z`@yH@x3I@v݀P@u@t9Ƞ@r'@q`@pP@o2@n6@m @l @krW@jz@@in@@i @h\@g`@f +@eߐ@d s@cd@b@b6@ap@ai@a-@`)@`_p@`8@_0@_ @^@^Dp@^@]!@]j@\}p@\.J@[V\@Yrl @Yj@Y5p@X0@X'@Wr\@V@V9@@U@U@U8@Tp@T@Tv@@U1@U @T`0@TEP@TS`@S) @Sɪ@S{@S@\L@[ @Z@Y_`@Xְ@V@Up@TDt`@S@@R @Q3P@P0@Pcp@Oz@NI*@M0@M˰@N" @N]@NP @N*p@NP@Ne@N2P@N@O€@PIo@P0@Qk0@R \@R|p@Rx@SY@T@@U@VUj@WKp@Xؠ@X@Y6#@Y + @XRP@Xꠀ@Yov@Z @Z@[p@\@^?@_@`@b*ŀ@cɊ@e@g@ij@k ,@l@nπ@p@pQ`@q0@r6 @r`@sZ@t!А@u@vLP@wP @y@{@}@J@@H@o@ @{0@8Ű@?G@@#@ư@N`@:0@#L@c@@@0@@`@@-@1@B@@ߠ@~/@}@@} @|A@@|H@@{bݰ@z@y]@xO@wޠ@v`@uiP@t1P@r@qѷ@p@ou0@mέ@l@k@j@i`@i+@@hC-@gLŐ@fc'@ej`@dt@c.@c -@@b@bq@aQ@aU*@ap@`@`gQ`@`@_+@^s@@]@]@]\ @]@\@\ @[oA@@Z`@Z!o@Zb@@YF@Xy}@@Wv@W3@VQ0@U@@U#0@Tc@Tc@T [0@S@S@Sɵ@T0@S~@@Sj @SV'@SQ8@S<7@So @Sd@]@\@[@Zl@YQ`@WF@V<@T@S @Ru@QI @PS@O8P@N@Nr@M@MiP@M@N &@ND@NK@MP@M@Mр@N0@Nq0@Ow;`@P8@P @Q.@Qπ@R`@RJ@S' @S@U@V7;@W ܠ@WX@W@W%`@X" @X@@XC@@Y@Z~0@[@@\@^Mǐ@_@`}@b:0@c-@e4@gs@iG`@jʐ@l@mo@n|p@p +E@p@qF@q@rMFP@r$ @sa`@tر@v8@w @yӍ0@{}@@|&`@~i@~@~Ǿ @~Ϗ@~i*@@~eg@~R@@}{P@}c`@} @~`@~3@@~@;@y@p@?n@~@~,@}'P@} @|Z@|m`@{Ã0@{_@z@z@zJ@@z@@y@x-c@wF@vEh @u0 0@t @rΐ@qˋ@pl@o0`@muT @l7@j/P@iP@iC@hL@g@f@e`@d@d @cvL@b݅@b]@a@a@aA'@@`0@`@`FrP@_@^Ɏp@]x@]fW @\@\@\Pp@[W @[E@Z@ZF.@@Y@Z#@Y@X p@W[@VIp@U @U?5@Tw@T9~@S^@S_0@S2ޠ@S0@R@@R@Q@Ro@R`@Rp@R@@Rt@Rh@^>@]̀@\@@[`@Z p@Xp@W0@UP@T1n@R@Qe@PƜ@@O@N@@N"@M0@Mgn @M@M@N: @N8r@N`@M@@M9@M@@NA8 @Ng@O#A@O@@Pfg@Px@Q=X @Qs @RY @S @S@U/ @V@V2@@W)@W>@W@X]@Xn@YH0@ZV1@[,@\0@^f@_M@@` @b,@c@ep@gh`@h@@jS@ke@lq0@mF@@n+D@o0@p@pf^P@p@q`@r]x@sL@t +@v:,@w@y@@{@|,@| @@}8P@},@@|`*`@|T@{@{@{1@@{@{'T@{QŠ@{w @{@{`@{ @{d߰@{./0@zp@zc-@zޠ@y@yG@@yz^P@yS@@y*`@xp@xՠ@x @w@wc @v6@uOp@t-@s7@rЇ`@q@p=@n@m+@@kM @j5@i&@hL0@gp@f(@e@e4 @dcp@c @c8 @b! @b4A@aĴ0@aw @a5< @`@`@`!W@_MP@^a$P@]2@\,0@\5@@\@[u@[-0@Z̰@Zk@Y @YA@X`@Y@X&z@V@V-#@Ux0@T@Tm1 @S @So@Sp@R"p@R@R[p @R$p@Qf@Q@Q@Q]@Qp@QI@Q0@_} @^@]|@\S@Zݰ@Yx+@W@V,{@@T@@SK@Rk@Q[@P3p@OQ_@Nfg@M@@Mf@M@M*p@NZA@Nv@@NL @N@@N@Nw@N@N @N}}P@Np@Ow&@P+/`@P@QO @Q @R4 +@RbP@S"`@T@U@Ve`@VK@W#P@Wr@X;`@X@@Zb@[y0@\Q@^i@`@`qP@bOJ@c$`@e]€@f@h@ir@@j*@kl@k@l{_`@l@mz@n@ow@o0@p @q%@r@tS @u`@w@@y@z6 p@{@{@{~@{L @z`@zw@@xP`@y)D@y@xó@xp@x@xu@x@x@xm@x@w;`@w& @w@@wjf@@wYg @wh@@w_`@wGR@w]@wg @wR @vpް@vX`@o@p;P@rG!@@s@@u_i@v@x 8@yP@y@y~@y܀@@y@xg@x"9@w@w'4@v@v]p@v7@v\P@vJ@up@u@u@u@usC@uV1@uc@uj@uj@u@uH@v`@v,]@vA@vA@vB%@@u`@tҠ@tE@soP@r|Qp@qƀ@p|@nǘ@l@@j@iؐ@hUD@@gÙ@f`@e۠@dh@dV]@cȱ@cD4@b.@@bWm@a@a"@@aVI@a @` @`4P@_~!@^p@]v@\P@[<@[zX@@Z,`@Zb%p@Y@Yf@@YL@Xkp@X%@X@@q@q@q@q>@r@r`@rx@s@sـ@tJ@t@t@t)P@t˩@t@t3@s@s`@rd`@q@p@o7t@m"7@kD@iv@h0D@f@eX0@d@dP@c.@cB&@@b@b~N @b@a @ab@a @@``@`(K0@_K @^-@]@\. @[s@Zu@Z;0@Y# @Y@Y)@X@X @X@X,3@Wـ@W=@V@UͶ@UB@T֌@@Tlj@T @S@SH @R@RHP@@Q@Qm@P@P@P9@O @O @OE&@O=p@O @_@_;(@^p@^9@\@[/@Yʠ@X1Ű@V{N@T@S_`@RW @Q~@Qp @Py`@P@Pi:@PXʠ@PSv@P^'@P`!@@Pfp@P_@@PCR@Ph@Pؐ@O@O8C@N|p@N@N0@O@O @P*-`@Pq@Pp@P` @Q@RnC@S@Tp@V]@W`@Y@ZP#`@\)@`5H@^0@_0@`l @a@b@cA@d@f @gϰ@g0@h@h],@hp@f`P@fpl@eɻ@eT p@e@dp@e@f@g?@i#P@kL@m@@o͙P@p@q@r0@s#.@sa@s @seh`@r @r@r$T@q)@pe@p @ph@pN@@ppX@p& @p@@p- @pKP@p@p @qEʐ@qR@r+@r@s/@s@s@@tm@@t@@t/@s׃@s~0@r8p@r_I0@qB@p{@oP@m@k@i} @ha@g2p@eo @c3@d@cs@c#v@@b*P@bU0@a`@a# @aH @` @`k@_@^@]p@\_b0@[`@Z @Z90@YP0@YWP@Y@X @X}0@X@X\@W@W@V@VVe@U? @U%ΐ@T`@To`@T @Sl @SV@Rr@R^@@Q@Q|@Q@P{@PS;`@P +@O@OX@O!@N@_T@^@^-@] +@\PY@Z@Y!@X:P@V@UP@SFP@R@Qڎ@Ql&@Q*̠@Q;@Pʀ@P +@P͚@PȻ @PϾ@P @P'@P@P(@Przh@PZ@P*P@O?@Oz8@OR@O_`@O}@Pw@Pg.@PE@P@Q[ۀ@R@3@S_@Ts0@V?@W@Yg@@Z͖p@\- @@]ꅠ@]1@_P@aX@a<@bl@ch@dP@e@f2@g9@@g@g@f@e *@e$ @d@d1@c@c`@dS@d@e疀@gtV@i.@j;@@l@n=p@p#@p{@qeE@qt@qx @qY@q`@qf@q6@p~0@p@o@@n@@n,@mF@m@m! @n0@n@o90@oՠ`@p`@p"@qU0@qݫ@rLF@r@r0@sG@sw@sT@sq1@@s60@r@rb@q@pG@p +$@n/P@lD@j|@hذ@g4`@e@dn@@d/@cQ@cZ @b@b-p@a`@aq@a@`G@` *X@_@@]p@\l0@[w @Zy@Z'@@Y@YN"0@X@XՊ@@X@@X@X\@Xŀ@W@WAw`@VQP@V@U{@U @TV`@Tio@T@S2@@SP @RP@Rs@R +@Q@Q5@P"@PP@Pb @P2@O@OX@O@^?@^ +@]v@\ߠ@[O@Z@@Yg@X@V׀@U,@S`@RË@R@Q@Qn^@QD@QQ@@Q=@Q;p`@Q-`@Q9<@Q0@@Q!@Q @Pi@P7@Pⴠ@Pݰ@Pm@PnmP@P8 @P @O'@P#p@P`͠@P@PP@Qr'@R1@SHZ@TG@V?3@W@Y@Z @\paP@]@\LQ0@_Z@a%Ƞ@b@bp@c~@d0@f  @fW0@gz@g`@g@f@e> @e8q@du@c-@@c{@bM@bp@cx@di7@e`@ge@h@jWp@k@mm@nZ@od`@pS%0@p@p`@p@pah@p/`@os@@o@np@mf@l`@l!@kp@l$W@lj@lw@mt@n9l@o%v@p@@pG@p@qa@qǖ@r7@rp@rI@@rB@s̀@r|p@r@rW+@qV`@qd @pIYP@n2@lp`@k'@@iK@g&p@fA @eG@eGP@cK@b'0@bxaP@b Cp@a@aGP@`@`r`@_%@^q{p@],P @[0@[ P@Z!@@Y~|@Y J@@XT@X0@XP@X{@@X^@X@Wŀ@W[.@V͐@VI~@U\@UO@T @T @TIr@T `@SȠ@SQ9@R7@R7@R `@Q@Qh@Q7 @QF@P@P@PGD@@Px@Op@]ŝ @],@\@\ k@Z`@Y2@Y@W@Vi0@UB@@T @R0p@RXg`@Q0@Q@Q~ @Q@Qx@Q@Q@@Q @@Q@@Q p@Qp70@Q`@@QJO@QU@QY0@QN@Q*נ@PU@P@P @PsI@@P @P@Q20@Q@R::@SHߠ@Tq`@V@WR@YsZ@[0@\@@]`@^@`"0@aHz@b@bٖ`@cW@d0 @e`@fA@@gW@g9@gH@g +}`@fE@e^@dQv@c/f@b~'@b.60@b&K`@b[ @c%N`@dܠ@eO@fp@h$0@@i`@j@l>o@m[@n3@n@oK@ol`@oA@n6@@n@ma@m@lca @k0@k @j^ @j`@j,@kk@l@l*@md @n@od+@p `@p@@p@@qI@@qP@r0@rG-@r~<@@r@rl@r:a@qƋ@q,@p[`@o @n#@k]@j@@hA @f +@et^@d_@c`@b0@b^@a}@a#@a#/P@`@`+$@_%@]@\}@[Xb@Za @Y<@Y O0@X@Xq@XTQ@X?U`@X5 @X`@W.p@W``@V@Vy@U7@U@@U@T@TZP@T # @S@Su@@SAp@RE@Rm`@R@@Q @Q@Qq@Qn @Q:L@P5@@P@Pg@P&ܰ@\@@\H`@[I@@[4 @ZsW@YM@X`@Wxe@VT@U.@T9`@SI @R`@RB@@Q @Q@Q@Q@Q@Q`@Q0@Q@Qk@Q@Q@QW@Q@Qa@Qyp@Q6@Qq@Qc@@Q"`@Pp@P@P@Q`@Q@RI@SHm@Ts@UNP@Wvg @Y,@ZP@\W@] @_i @`P@a<@a@b7 @c@@dt @eVh@f9@fP@g$@gH@f㳠@f1/@e_[@d_@c9O`@blM`@a렰@a@a@b%z`@bǾ@c`@d@@f 7@@gK@hA@is@j@k)@l@m+L@mc@mp@mR!p@m  @l@k]@k:N@jsp@i@io?`@il`@i@@j@jcP@k @la4@m3O@m̀@nup@o0@p)@p(`@p@qR@q@q|0@r!H@r@q0@q @qCE@pz@p@n@l@j\@hA@gN@eZP@d@cp@b @bTՀ@ap@aap@`o@`l,@_Fp@^ɐ@] @[y@ZZ@Yk@YG5@X@Xa8p@X1@Xr@X@W諠@WC@W]p@V0@VY@VP@Uh@U# @TŦP@TP`@T@S=@S@Sm(@S2`@Rd@Rd@RZ@R& @R`@Q@Q\@Qd@QlP@Q# +@PKp@PD@\ +@@[s/ @Z@ZF@Y@Yy@X@W:@V'S@U&@TBY@SM@R@RN`@R/@@R @Q0@Q|@Q?@QX@Q @R@R 3@RO@R +@R!@R;@RT0`@R@RP@R@R) @Qp@QW݀@P@P`@Q\p@Q@R@ap@a[%@a@bXP@c+@d$Z@@eT`@f @gs0@hMp@i@ji`@k @k_ՠ@k@@k@@kQK @k @j{@ii@ih@hU@hR@@hMw@h.@i%.@i @ja@kq@lR)`@m;p@@m@n~y @o.@oנ@pH@p@q1@qZp@q@q@q@q@@qH.@pP@p#@n @lw @ka@i@g(0@fUq@d @c @b2@b;W @aP@a3r@`ˀ@`*ň@_0p@]P@\v&@[: +@Zg@Y%@X}@Xy @X@W@W@@W@Ww `@W< @V@Vr@V&`@U@UA@Tx@Ti@T9 @SP@S|`@St5@@S:@SY@@RӋ@R@Rs@R^0@Rk@@Rd@R:@@R 0@Qcp@Q @QA: @P0@[-e`@Zs@Y@YC@@Y@XP`@W$@Va`@U@Ux@TAP@S@S+r@Rª@Ro@RK90@R-@R .@Q!@Q@Q`@R>@RA^0@RV߰@Rp0@Rp@R @RG@Ry`@S@S @R8@Rk^@@Q肠@QP. @Q0@Q ͠@QG`@Q#@Rѝ @S+@@U@Vtŀ@W@Ys@[;@] `@^@`:X@a@a@b( @cN1@d O@d]@@e'@e!@e@e @e)@e=@d7 @c{@c l @b(P@ai@`@`c@`@`@a4p@a}@br@c@d0@e +@f@gp@@hx2 @i3`@iLp@i@i90@i@iIn@h@h5p@gQ`@gyP@g1@g8@g̱@h`@iGP@j`@k@kp@l\@mO@@m|P@nIC@nȓ@o^@pp@@pd@pF@qe@@qJ@qj `@qb@@q!@pȇ`@p/@o"@m9p@kʚ`@jO @h}+@f@e+@cP@c@@b1s@a @`@`t@_p@^j@]@@[@Z0@Y-@Ynt@XΌp@X`@X?@Wr@@WO@W>@Vw @VV0@Va @U@@U@UD @T0@To@T + @S%@@St@S8a@S@R0@Ra @R"P@Ra@Ryp@R @R@R@R@Ru@R7@Q @Q"@QL @ZR@Y@@Y}0@X5@X`@WԎ@W-$@Vo@U @U%@TQDp@S`@SRA@R@R@@Ra@Rny@@RJ @R@Q@Q0@R\@RF@R4@R;@Rs@R@S%A@SK/@Stg@Sz @STj@R0@RQ\@QCP@Q2U`@PՐ@Q @Q@Rf@Sk@Tl0@UM`@WWI0@X@Zup@\f>@^;@` P@`N`@aDŽ@b@cn0@dP@d} @eaP@eBِ@eHP@e`@d* @dl3P@cy@cGR`@bz@a@`V@`q" @`@`@`bp@`9@a@ @aא@b@cs@dT@en@f`p@g/@@g`@h @h/@h5Vp@h0@g1@gQ@f`@@fǀ@fN%P@fh@fU@g2c@gڴ0@hV@iB@@jR @k@l"k@l0`@m80@m@@m_@n0p@nĒ@oq@p`@pq`@p@p @qe@p@pˀ@p)@op@mӹp@lS@jX@@hٶ@@gC`@es@d @b5@b%@a[@`@`"H@^0@]9@\Qp@[@Z=@Y@@YV@X@XK.@W@W@WEuP@Vp@V@V4q@UӞ @UQ@U9.@T @TzO @T1p@S@S\@@S,G@R0@RN0@R@R@R@R@Rڀ@R̀@RR@SA@@S@R80@R@Rd@R@Q@Zlq@Y`@Y@X@X@WP@V@V6d@U]p@T0@TS@S @S~@@S/@RM`@R@R@R;@RQ@Rg@Q7@Q@R)0@R{0@R͜@S*P@SE@Sf,`@S`@Sľ@SQ@SϠ@S=V@Rķ@Q@QZ @P"@PZ@Q-@Q@Rm@S@UFX`@V@X. @YP@[p@]@@_@`@aP@b@c@d>@dΣ0@e%`@e5@e@@d6@@d7f0@c@c@bs!@aX0@aX@@`\ 0@_b@_ 8@^oP@^`@_`@`c0@a@b@b$@c׫@d`@e@fJ̠@f?@@fg@f@fq@@f`@f|@f'@eѧ@eנ@e`@e@f%?@f@@gw `@hsP@ir @jI@k j@k`@l/@ln@lH0@lK@mP@mq@nU@n@@o~ @p@pT'@p{ @py@pL@oY@np@m@liO@@j@i+ @gb@e@`@dT@c@bp@aG@`}P@_ 0@^X@]R@@\ "@[ @Z@Y@Ydo@X>@XE6@WN@W|b@W Հ@V0@V3; @U͜@Up~@U4@TF@Tn]@T@S8@SX@@RK@Rp@R@@Rff@RI;p@R? +@RLp@RuV@R@RM@S@S3@SAvP@S `@R@R@Ru@@R@ZB @Y@@Y.@X@X@Wp @V@V@T@U@U +`@T}7@@SZ0@S@Sbɠ@S@R@R @R@R~P@RC@R @QaP@R@RN@R@S@Sg@Sv @S@S5P@S`@S@@SP0@Rc0@R-@Q0 @PT0@P@@Pm0@QH @R4@SC@@T@V@W@Y'@[kg@]ZUP@_?`@`g@a7@b@co$P@dE`@d@e=ې@e,@d^`@dn@c20@b#@bQ@a@`ݹ@`9@_8@^H@]@]S@]:@]@_z@@`@apwP@bP@c6p0@d @di@eyP@eq @e)@d_@e*@e՗@e@e/@dc@dM@@ep@eD@eS0@fE@g@gT@hį@i%p@jy`@j@k@T@@kuj@k0@k@@kϛ@l`@lO`@m1@mà@n@oP@o4@oͬ@ox@oOQ @nmp@mp@li@j@ihP@gP@f x@d@@cUV@b(%0@a@D@`m"@_n@^&@]P@\/ @[0@ZMp@Z@Yyg@XӴ`@XTP@W@@W1@VH @VV @U@U @U+a@TP@T@T@S@S@e@RSp@R`@RS@R5?@R8 @QN@Q`@Rk`@Rb @Rk@Rk @S(t@SP#p@SP~@SX@S-@@R@R@@R`@[@Z* @Yfp@X@XV"@WH@W@Vo~@Uu@@U5@T@Tz@S @Sm@S@R @Rƚ@RP@Rm@R^@R)@QZ@Q@RL@R!@S@Spg@S6@S@Sz@@S@@S@S6@R@R- @Q@P2@PwF@P_P@P@Q@@R@S@Ub`@WU@Y[b@[A_p@]4T@@_ +D`@`y@au[@bgp@cR8 @d$@d@e`@e 0@dv@@c!P@c70@bj@aO@`Ȇ@`Z@^ @]f@@\8@\u@\\ip@\@]& @^~ @_`@`@aW@b@c[T @c@d @d_@e.Ѱ@e@e@e@dU@dpD@dA@dEP@dn@@d @e$@eP@fc`@g&r@g@h@ip@iP@jN@jaʠ@jQ1@jU#@j}P@jb@j@kj(0@l2@l2@m @n"@n@nk@nw @n$^@m^@@l_ @k@i @h@f @d`P@cZ@bg@aT`@`nP@_>p@]@\@\(@@[j( @ZO@Z(@@Y@X̸p@X.@P@W@Vr@Vf@V=`@U@UW@TP@T@@T9<@SH@SQ@R@R@R>M@Q@Q?@Q0@Q@Q@Q@RA:`@R0@R @@S8@Sd@Set @SmP@S\d@@S1@R@R@[@@Zƚ@Y@YHP@Xn`@XI@Ws@V@U`@UKJ@T @TWv@Sґ@SzP@S6@S@RР@R@R@Rf@RD@Q0@Q6`@Q@Rf60@RP@SXP@S@S@@SI@SS@S}q@S@R@R@Qgp@P@PVq @P@P=8@P`@R @`@S@U9@WD 0@Yt0@[n@]F`@_9P@`@aw@bU@c@@cp@d[`@dP@d+@dp@ctoP@b@a@`d@_@^q;@],@\00@d@@dd@e @e1P@fe@g0@gձ@hyp@h @i`@iR@@i@h@h@h0@i b@ih@@j:U @kp@kp@l@m(@mQ@m@m@mK@@lM@k.x@i!@h@g&`@e@@d0@b@a@`@_l@^ +@\r@\ 0@[j00@Zr@Z/@Yu`@X@@W@Wl@Vź`@V<@U֣@@Ux@U @T5p@TEd@S殀@S|F@S@RW@RIK`@Ri@Q@Qr @Qj1p@QY@Q@Q0@RI@Rp@Rܒ@S_@SS@SyL@S`@StG@SZ`@S"p@R@\ @[& @Z]@Y@YR @XF @W@W%@V4b@U~4@T-@T@Tt @S#@SN@@S@@Rm@Rp@R@RtN@RN @R*@@Qʀ@Q@R=(@R]@S)U@S@Sp@S@SQ@SC@R.@R\@Q@Q8]@P@P;@O@PW@P@Q<`@RP@T֐@W/; @Yr~`@[/@] @_'@`՜@a@bh@@cB@c@cя0@cw@cS@cN@b@a`@``@`c@^u@]@[e @ZМ@@Z6@ZV@Za`@ZW@[^@\0@]O@^@`h@`@a;C@aՐ@b)@bx@@b@b'@b@@b/@bx@b3`@b@b @bH0@ca@cp@d]p@de@ef@f=0@f@gd@g@g&@gŰ@gy@gd@g9`@g?@ge]@g @hK@i@j +@j@@k@lb@l˧@l@l^@l2`@k{m@jk@i:@g"0@f-ߐ@d @cN @aH@`@_n@^+p@]K@\4 +P@[ip@Z?@Z$q@YW@X`@W,@@W+ @VE@V@U@U*l@Tʼn@Ti@Tp@S@S$%@R{`@R}p@R.@Q@Q@QZP@QO^@Qv@QV @Q @Q@Rb@R%`@S @SN@Stp@SX@Sp@Sg@SM@S@^,P@\`@[@Zנ@Z@@Y8@@Xz8@W@Vm@U]@U/@T!@@T(F`@S@S]@SV@@R0@RO@@RC@Rb @R90@R?Ѐ@R@R@R"V@R@RR@@SL0@SY`@S`@Saa@S `@RȠ@R':@Qf@P4`@P^|@P } @O@O+@PDڠ@Q9P@R@T@V~@Y@\ `@^kG@`F@aC@b`@b@c)h@cT@cW@c1h@b@@bIzP@aŀ@`0 @`@^@] @[rp@Z@Z,@YwP@Y@Z@Zʱ@[z[@@\IM@@]`@^g5@_F @`X@`r@`Ծ@@a @aD0@a@ak`@ay@a@a!@a@@a"@aC`@b,@bҀ@c|@cF@d7@d8@e]3@@eڤ@fD@f̠@f@@f\@f#@e @e@e@e@e@fˀ@g] @hXU@i=@jA@k4@k|@l8~@lb?p@l4u @k@j@i@@hk`@fs@eV%@c`@bm@a') @`ڨ@^{@]6ʀ@\B-@[l +@Z)0@Z`@Y7ְ@X_p@W@V~`@Va@U⯰@U]`@T0@T@T,P@St@Si`@SP@R`@Re@R@Q:@Qp@QX*@QD@QZ@QxP@QP@Q@RNxp@RB@R-@SHl@Sr]@S@S@S8P@Seh@S2@@_}@]0@\ @[ץP@ZF@Y5P@Y @X,@W:@VMp@Uwޠ@T2@T8@S6@Srj@S$@RBP@RX@RzT@RB@R@R@R,~@@R.`@R@[ @R@R`@S'@@SNd@SXG@S6G@R @Rj@Q1`@Q?w@P`@P.h@O @O@O@PC@Q@R`@T~@V٩@Y6@\0x`@^@`@a_ @b@c@cN@cf@@c6B@b1@b@aY/ @`{@_9@^@]l@['@@Zv@Z)C@YQ@Yz @Y%@ZP@ZR@[h@\l@]@^O@_/@_p@`O@`8@`\`@`t@`@`~@`p@`@`@`| @`@a1r@au@a 0@bteP@b@c|W @c@dgY@@dW@e6@eCj`@e!5@d5@dP@dU @dU@d @dB8@d@@e N0@eH@f@@g@h.`@j0@j|@k^`@kp@l@@kH@k@@j@@i@@g@f@d} @cVP@a@`t1P@^2 @]% @\_@[o]@Z@Y۟@Y `@X-,@W^ @V?@V2&P@UíP@U4w@T0@TO@S3@@S@S?@R@Ro@@RZX@R@Qѯ@Q@@Qe@QH:@QP@Q^p@Q @Q +@R4`@Rd@R^@S:P@Se`@@S@@S@S3 @S@SY@`gm0@_E@^;@\* @[M`@Z@Y@@XxP@W8@V|@Uʰ@T@@TL"@S@S{s@SA@R@RT@Rnp@RA|0@R\@R@R@RR0@Rp>@R@Rs@S@S.@@S* @RE@Rd@RRQ0@Q @QѰ@P* @P@OY @N@Os[@P@wp@P0@Ru @Ti#@Ve@YW@\ @^@`@aᯰ@bl@cFd@c`@ci@bx@@bZA @af@`p@_+@^ @\A`@[B0@ZՋ`@Z @YP@YK@Y@Z`@Zĸ@[pP@\Q@@]QW@]@^v@_+k`@_\@@_@_} @_w@_̈@_@_ @_o@_^@_p@`@`=Q@`0@`@a=@aƐ@bG-@b@@c P@@cw@cp@d@dO(@_ +܀@]4@\;@[&@Z]@Y:ٰ@XA@WBV@V((@U/@Tp@S@Sc @S`@Rm @R@Rk@R=>@R0@R@R(@R`@RP@R@R`@S+ @S(!@S@R@@R@@R%€@Q-@Q @Pn@O٣@O0@Mq`@N$@P"@P`@RV@T;`@Vh;@@X@[@^0@`AP@ak@b3@c?@cF@@c+\@b@a`@a @`"X@^7^@@\@[d)@Zm @Y)`@Yc?@YY;@YxK @Y@Zy4@[M@\% p@\@]q@^U@^@_Mo @_d1@_l @_U' @^蕰@^@^2 @^cP@^Nـ@^S@^di@@^@@_)*P@_d@`.@@`8@`r@anp@a@b'0@bMP@bj1@b@cHP@b@bh@b,p@bp@b@b&@b;@@b @c @c`@dh@@e{ @f @h;@i+J@j0@j@ky:@k p@kO @j @i@h@g`@fs@dnŀ@bf@ar9@`@@^b@\p@[p@ZsN@Y|g@X@W@W@V"@V@UD@U @T @T`@Sm0@St@SS~@R @R@R@RMv@RT@Qn@Q`@Q@Q@@Q@Q`@RP@R_ @R@S@SNd@St@@S{ @SZ@S@S @Si@aѠ@`"@`z@^@]p@\RM@[@Y0@X^@W@V`@U|@T@SЭ@SE @Ryp@Rx@R`\@RZ@R1@R`@Rp@R:{@Ru@R@R@S p@S$Y@S`@R`@R@Rm@Q9`@Q@Q@Pn @Oŏ@O=@N7@Ndq@OR0@Pp@RԐ@SΈ@U@@XvՐ@[`@]ֆ@@`+@aH@a[@bv`@b@b@@b(@aSL@`t@_@]ٰ@[5԰@Y|`@Y'I`@XR@Xb@X`@YV@@Z%@Z0@[Ư@\ @]LVp@^<@^@^͹@^@@_P@^`@^Ȁ@^@\,@]@]YP@]!@]@]B;@]@@]O@^`@^@_-0@`%@`Հ@`ߌ@a @a19@aA`@aTp@aB@`Ȍ@aJ1p@aG @aF6@a>@@a[Q@a +0@aH@@bD!@b@c/@d}p@e0@fJ@h>m@i]`@jJwP@j+@@k6@k3i @j߫@j@@i[W@h&@f@e,0@c@b`@`Y@_}@]F@@[ņ@Z?@Y@@X0@W@Wp@Vn@Ù@Ur`@U @TI`@T@@Sֿ@S00@SP@@S[@R@RJ@Ru @R+: @RD/ @RH@Q@Qv@Qݰ@R/ @RZ`@R,@Rƀ@S<2 @S @S@S0@S'@T00@T2'@@S@b-@aH@`@_(@@^D{p@\p@[t@Zn| @Y3P@W?@V}0@UP@Tg0@S@@S @R@RX@R2@R#p@R@@RW`@R@RMR@@R@Ri@@S!^@SJU @SS0@Sp@\@[J@\!@\f @\@](p@]@^@^@@_*@@_9@`=@`!0@`:@`Dp@`;@_0@`=0@`o@`m@` @`@`@a6P@a@b ʀ@b@cP@d* @e0@g= @hl@ilՀ@j/O@j@@k0@jа@j0@i@h@@gi@e<`@dTp@bt`@a4@_5`@]0@\0@Zπp@Y @X@W@W)'@V`@U@P@UX@U@T@TO@S@SP@Sk@SOJ@S7@R@R@R}@Rp @Sl+`@R@Q@RG@R30@R[H@@R@S +)@SQ@@SU@SǺ@S,@T$@T?@@T)B@Tw@T*~@b@a@` @`3`@_ +Z@@]`@\>ՠ@Z@Y@X8@V@UȐ@T@Sa@R[@Rc*@R@Q`@Q@R@RR@R$0@RWd @R@R@S<^@Sx'`@S@Sok@S/* @R^@Rh@QW@Qmݠ@Pp@Pv @OV@Nz`@N@N@O3e@@O@PP@Rz?@T\@V@Ya@[yB@@]H@_|@`YԠ@`B@`; @`_@``@^ѵ@]=@[]@@Z@Xt@W~Z@Vj@V@WR@W0@X8@Y@Z@\@\@]c`@^K@^{@^lU @^@]@]=r`@\7@\@[0@\`L0@[e@Z@Z@Z~`@[P@[@\@\b@\٠@]p@]zG@]@^'q@^R5@^q`@^,@^@^}@_x@_zI@_6p@` @`1L @`i`@`vp@a`@a_xP@b@bǰ@c@d0@f.Ӏ@gg`@hp@iy@j?^@j[@j @i`@i`@i%@gm @f@d`@cM.@a#@`w@^M@\ҋ@[T0@Z"@Y*V@X&0@WR@V>@@V19@U2@UB@T@T@T2@SȠ@S@S]`@Sf @S#mp@S @R(@Rπ@R +@RCT @Ro`@RxӀ@RDŽ @S@@SX[@S@S@T@TL@TP0@Tk@TTq`@T[P@TY@@c b@b @a)/@`r@_@^@\@[[@Y_@Xwb@WA@Uǻ@@TM`@Sd@R@R,-P@Q@Ql@Qk@R@@R@R/`@Rd@R@Rސ@SP!@S@SC@Sb @Sh@Ry@Rjv@QH`@Qc0@P0@Pl@Op@N#@M @Me@MP@Ni,@P) @@Q`@S`d@U~P@W@Z@\&6@@]3@^ͺ@_k @_a@^٢@]F@\@[9@Y@Xv@W'`@Vm@VyP@V F@V@#@@W-@XYC@Y@Z @[p@\@]ϚP@^^=@^E@^@]&@]`@\@[P@[JP@ZU@Zz3@ZS.@Y匐@Y@@Z +@ZS#@Z`@Z@[T[@[@[p@\S@\C@\p0@\?@\`@]'.@]L] @]@@]V@^2P@^o@^@_Hf@_@`"@`@`7@ae@b@bՠ@c@e#@f_ @gR@h4@ip@jgO@j@iP@Ul4 @V'/P@VE@WD@YF@@Z@[@\@@]hQ@]:@]`@]@]@`sP@`op@ar@b*gp@c @d@ef@f@gp@h@iҴ@jQ @jj`@i @ix@h@g6`@f90@dP@c*@a~@@`p@^s@\@[nX@Z)p@@Y[@X2u @W@@@V@V<@U)`@UG@Tf@T@To@Tn@TV@T##0@Sl@Sʐ@Sq@SI@SK1@S @R0@Su@Ss'@S@@T +@TC@Th@T@@T @Td@T@T @T@Tz@c:50@bFR0@af`@`,@_@@^@]- @[8@Z"Ѐ@X@WC`@UV@T@Sa@R@Q̮@@QPǀ@P @Q @RRr`@QF@Q&@Rgp@R@S 0p@SP@SĀ@S@S@S@S@@S@@S`y@S@@S8@Sy2 @S4 @R*@RCP@Q@Qp@Pl@@Op@N,@Mp@M+@L@M#@M@Oe@@P)@R@ @Sƒ@@UHp@V@@W 0@X@YT@Ya)@Y>@XB @Wu-@V@U@T@Tr@TF}`@Tju@T0@Uf@V@XC0@YC@ZEː@[/@[P@\ZF0@\V@\@\Yo@\@[Z^@Z#@Y1p@X@@XXr@W(@@W0@Wh@WOP@WW@W9 @W@XF@X@X @X۵@Xr@X @X,@X.@Y@Yb{@Y@Z `@ZWq@Z @[Q@[PP@\j@]@]{@^LJ@_1@_@`7Ip@`-@acd0@b=%@cB[@d]@eN`@g:: @hS{0@i@i`@j@ky4@i&@h|@gL @fN@d0`@cw2@b(0@`@_P@] @[@Z`@YuT`@X0@WԿ @W$- @VP@V@U~@UTd`@U> @U( @T@Ta@T@TXu`@T0@T @Te@T@T(1`@TEӐ@T_ +P@To@T@T@T@Tc@TH@T@T*@T-@Tr @T@bP@b0@aF`@`C@@_.@@^`@\@[H@Y@XH)@V&@Ur@T+r@S%`@Rq@Qb@Pp@Pܰ@P @P @P}@Ps@R#p@RtP@R_`@S @S?0@SL @SQ+@SJ @S^@R@Rmo@Q?P@QF@@P7@P@Oy@NV`@MP@Mm@@M)@N+V`@OL1p@P) @QEP@RP@TH@U~@Vr @W1@@W`@Wz`@W+@V@U@U2@TP@T @S#@S0@S0@T?@U@@VǸ@W2@@XƐ@Y @["*@[j`@[`@[@[_@[{@@[J@Z|N @Yv`@Y# @X^@W8@W% @V @V-@@VG@Ve@@V-@W0@@Wp`0@W @Wj@X + @X@XU@X@X @X@Xg @XÝ@Y@YR@Yp@Z;`@Zʪ@[fH@[@\P@]F`@]B`@^@_@_@@`X@aW2@bB@cn@d@f0@gn@h]p@i&j0@i@j0@iN@iK`@hA@gP@eŕ@@dw@c@@a@@`@^v@]P@[P`@Z=@Y/@XO`@W`@W @VG`@VC@Uj @Uu@U@U_T@U,d@Tj@T @T^p@TqP@To/p@Tx@T@@T:@T@T薰@U,@U @Up@T@T 0@T@T@T/@T@U@b< @aN@a?@`P@_B @]@\&@Z`@Y:@W@Vpذ@U(@Sg@RU@QF@QB[ @P@Pn@Ps@P`@PP@QW`@Q@RK9@Rp@R1@R@S n@@S#;`@Sq @S^@R@R~@RG@Q>@Q@Pp\@O#@NH`@N@1@NA @N[`@N @O@P@QP@R`@SP`@T$@@U~ @UD@V<@V]@UԤ@U1@Tr1@S @S@S+@R}@S`@Swp@T. @U#@@V> @WF@Xy`@Y\%P@Z+J@Z@['P@[N@Z3 @Z@Z7@Yܐ@Y8`@X@WF @WC@V0@V @VI0@V9@@V97@VM @V@Vú`@W +#@W7@WG@WR@WX0@WPl @WCP@WE- @W}`@W뷠@X@Xk7@X@Y7@Y@Z7@Z @[a @\Y`@\ @][L@]%@^ @_p@`tp@aTIp@br>@c@e@@f0@gV@h@iV@j@i@i|@h@h +j`@f @ey@@d!@b{@aq߀@`A@^U@\ؠ@[,{@@YE@YP@XC +p@W@V@V@V7@V L@U}p@UP@U @Uh> @U7J@T @T<@T0@T> @TmP@U#0@U @@U,@U='P@U1@U7c@U@TvP@T@TP@U @U&0@Uj @bB@a@`]@`t@^ p@\@[d`@YT@X @W=@V0@TȞ@S@R @Q @Q@PP@Pl&@Ph@Pq@PzP@Q@QP@RK @Rnq@R[@R +@R0@R`@Rޢ@RX@@RP@R@RAa@QΞ@QW`@Pd@@PPp@Oՠ@O8y@O@O^@@Oӄ@PUZ`@P @QZ`@R +`@S@TQ4 @T`@U.@UW@U<`@Tܓ@T4@S @S`@R`@Ra@Rclp@R}P@R|`@S@T|P@UEp@V@W\@X_@Y,7p@Y~@Yʼ@Y @YW0@YC@@YK;@@XP@X@X@Wy4@V@V@V>@U@Uu`@U7@Uz`@U༐@V `@VZ@V;`@V@VN@V@V@Vʐ@V@VC0@V@W?J@W@WĠ@X40@XD @Yv@@Y @ZY@ZE@[@\G@\:@]@^Z`@_@`E@@a\0@bs@d@@e @g9@h@h@i}ܠ@h~@i@iv(@@hč@gL@fX@ef @cҋ@bvF@@a8@` @]`@\FS@Z!@@Yd@X@XVp@WS`@Wߐ@V@V p@Vhŀ@V?Y@U @U!@U@@UR@UF@UQ,@UX;@UgV@UvC`@U@UL@U @Uhp@UVP@UHԀ@U2H@@U#`@U@@U3P@USw@U]@aa@a +@`]@_'@]@[k@@ZxP@Y#@WF @V(p@Ud@TX@SL@@RG0@QJ@P+`@P @Pr@P[i@Pz@P @P@QDC@Q*@@R*;@Reg@Rr`@R@R@R@R @RuP@Rup@R[@R @Q@Q1Y`@PÕ@PK@Ph@PĠ@P,͠@P@P @Q|q`@R@ScZ@T/Kp@T^@@T`@TK@U@T`@T:A@SN@R@RQ@@Qߐ@Q̊P@QΤ@Q@Rw@S?p@S @T@U{@@Vt@@WM@WԘ@X(0@XL@XM@XQ@Xa@WZ@W0@W~@W0 @V@V[.@U@Ui@U| @UJ@@U"@U @U:X@UV @UrϠ@UD@U@U@U'@Ug@U#@U7@U'@VΠ@Vs,@Vˠ@W@WZ@WrP@XW@X@YU@Y@Z@[<p@[@@\H`@]m @^{@_~@`@a9@c&'@d @fRo@gg-@hԀ@iZ@i}߰@i΅p@i@ixj@h`@g`@f@e'@cf`@bG@``@_R@]@\#OP@Z@YIP@Yk@XL0@WP@Wb@W&@@V`@V @Vp@VB;P@V0@UB @U@@Up@U@U@U[p@VV@V# @U<`@US @U@UyP@Udp@UV(@UHAp@U[M@U@UȐ@a?@`@_@^ @\I@[J@Y1P@X?P@V @UN @Ta0@SP@R綰@R>@Qm@p@P @PF@Px @PoX@@Pw0@Pb@Pם@@Q3@@Q_$@QA@QP@R@R7@RCP@R]P@RI@R@Ru`@R`j@R:P@Q@QrC@QU @P`@Pog @Pu@P@@P@@Qp@QP@R@S@T@T`,@TȠ@TМ@@T0@TU`@Sد0@S'@R @R9@Q@QK@Q]>@@Q@Q @R@S0X@T@Tҍ@Uy@Uwp@Vg`@VP@VЊ@V@V @V' @Vu@U@U`@UԖ@U\ @UZN@T@T @T؀@Ts@TW@TL@@T^@Tb`@T@T@TP@TסP@T0@U]`@U'k@U8JP@UCU@U\@@U@@Up@VS@@V@Wqp@W: @XO@X0@Y@Y@ZM @ZC@[:@[ɯ@\ +@^@_n@`@@bK@c@e$1@f]@h@i3@iG @jp@j@j+@iw@h@gL@fT}@d{@cyc@b$@`T@_@]^@\ @Z @Y@Y`@Xy@X@Wй@W@W])@W@VϜP@V@VqP@VQ/@@V3@V-@V*`@V@V3Y`@U@U|@@U+@ULp@U@U4@Up@UP@U@U*@V0@`a>p@_i@^N`@\@[cP@Yy@X@Wu@VS@T@T@SL@R@QѠ@QV`@P`@P$@P@P@PD@PP@PP@QLi@QGc@Q_@QG@Q] @QP@Q@R @RAC@Rqh @Rv @Rf @RCC@@R@Qx@QF@P+@P @P@Pd@@Q5@Q›@R>֠@Ra`@Sn0@Sn@SE@T0@T@@TaP@Tѐ@St@R@R>D@@Q@QS@Q C@P0@Q+4@@Q0@R@RŲ@SL@S@T@,P@TB@U4P@Ub@U'p@U@Uw@US@U,@T(P@S]`@T@TV^@T*@Sp@SXp@S@SuP@S @S @S@S9@Sˠ@S@S@So@@T*@@TVn0@T}`@T@T@T@T@UNWp@U0@V +@@Vbd@V@WB@WÙP@X:@X&@YR`@YT@ZHP@ZR@[w@@\@@^b@`9@a~op@c @dN@e`@g@h֪@i @j@ZT@\E<@^)5`@`=@av@c+@@d剠@fY@hA@i @j@kw@@k0@k@k@jǠ@iB@h @g 3p@e@d H @b@a_T@][C`@^t@]`@\@[C@Z`@Z+ @Y@Y@Yg`@X^@X @X0@X3`@W@W@Wx@W9@V{@V`@V@V6@V@Vj=@Vt`@Vn`@V@VѡP@V.@W+ @\Xj @[p@Y[@X@W, @Vn`@UgȠ@T @SrP@SL@RvP@RR@Q@Qw@QD@Q@Q@P@@P@P_@P萀@P0@PS @P @P؅@P5@P@Pj @PuӀ@P@Pt@Q$@Q~T`@Q@Q@Q`@Qp@Q4Up@P%@Pp@Pʍp@P@@Qx@@Qs@QA`@RM@R`@S7|@Sl`@Spp@SB@R@R^.@QX @QJ0@P@@P@Pe"`@P*@PV0@P!@P?@PDP@P@QE@QW @@Q\i@@S @RT@R^'@R @So@S]@REp@O@Rr@@RJ@R`@Ra0@R;g @QU@Q@Q"@QM@Q@Q@Q@Qô@@Q@Q@QI@Rc@R*@RW@@Rs@@R@Rϐ@Rl@S@SNq@S0@TDr@T@U3 @U@U@U6P@W`@WU@Wn@W`@X @Yb@ZOE0@[d@]$@_X@@a ' @b@d<@e`@g0@i-@jw@@kl@l@l@lL@k@j@@i'@hVְ@fT@e4f@c@bM@]@^@^p@]S5@\f @[@[3+@Z@ZP@ZD @YP@Y~ @YU;0@X@X0@XR`@W=@W@WkP@W5`@W$(@WC@V @Vǰ@V@VА@W @WDM`@WoC@W@@Z@@Yǹ@X@WN`@V@@Udр@T/@Sד@S6b@R*@RF@Q/@Q<@Qz @QF@QP@P-@Pà@P`@Pp@P@P@P@0@P@@P@P+ @PS @P%}@P @P1@P_v @P@@P/@Q#p@Qa@Q@P@PÐ@P&@P@Pt@P4@PO@P@@QNp@Qª@R;`@R} @R#@R@R@RL`@Qc@PQp@PX@P>W@O @Oo@O5@O@O@O`@P M@PV`@P@Pt @P-@@Qg@@QX@QEP@Q@Q@@Q@R0@Q@R@R@@Q@Q{`@Q @Q@@Q~̠@Qj @QE@@Q7K @QNP@Qdg@Qm`@QuV`@Q@Qe`@QpP@Q @Q0@Qq@@Q@R/m @R_G@R @Rb@S`@W) @W">@W1l@Whp@Wz@W@W @Yi@Xp@WZ@VM@Uh@@T@SĔp@SE@RO@Rh@@R@Q0@QB@Qtv@QL``@QP@Q`@P@Pg@P @P@P@P@@P.@@P[?@P0@P@O@Ot$p@O@P@P(V@@P\ @Pٰ@PU@PT@Pu@P]p@P9@P!@O@Po@P!@PJ@Pt @Q0@Qd@Q@Q@@Q0@Q@Q,T@PR@PC@O`@O9 @NX@Nŭ@@N$`@N@NS@OL@O@O-`@P3@PqX@P@Pݱ @P@Pg@P@PY@PZp@P`@Q@S@@R2sP@Q @QNp@Q's@Q!@Q(@PA@Pi@P0@P0@Q2@Q,9@@Q$g@QA,P@QO0@QL<@@QQ#0@QhP@Q|5p@Q@Q@@R@R +@@QS`@R#`@SU@S+ @Tb@TA`@U @@Ua!@U;p@Wj @V`@W ˀ@W;@XE@Y Y@Z5O@[`@]{'p@_7@a\p@bˠ@d@fd@hנ@iI@jئ@k@@lqP@n'@ns@l`@k @jzp@i-@@g@f!(@d@cP/@b@a2P@`@_i@@^) @]`@]R@\@\B@[@[@[`@Zqp@Zp@Y0`@Y@X @X6@@W@W4@W@W0@W@W0@Wp@Wԛ0@X@XM@X*@@X17@X>|@X@X|@Y7 @Y0@VzB@U@U!0@T@SQ@S8p@R*@RYN@Q@Q@Q@@Qx P@Q_4@Q5(P@Q.`@P@PA@Pf@PF`@P0&p@P*P@P(_@P0@O@OS0@O@N_@N@@Mɰ@O'lP@O @N @Np@NF@N @NA߀@MҕP@M`@LBP@LiZp@Kw@@KW@K@KH@L@LP@M @M1@M@@MH@MqF@MXH@MY|@MO@MH@Mel@M@MxP@M@M~P@M"@M%@N`@N@N( 0@NDP@N>`@NN$@NVk@NW @M@Mך0@Mp@ME@MC@N@N4XP@Nu>@N`@OM@@O2@O`@O}@O6p@P@Op@P!Y@PJl`@PVp@PN<@P_@PT>@Px@PP@Pqp@Pu@@Q@Q4o@@Qfy@Qi@Sk9p@T0@RX@RP@S}j@SH@TT@@T@U(@U@UJ@V@W-@W@Y7@ZN @[d0@][ܰ@_`@a O`@bv@c_@@e@gY@i>@jmܰ@k|@l`@lv@mN@m:@l@k>@jܓ@i +@h<@f@e"p@cգ@b@@a@a̐@`@`)O@_S@_ @^d@]S@]m@[@[c`@[80@Zp@Y0@Yyz@Y(@X @X@@X4@Xx@Xl)@X@X@Y40@Y-P@YS@U`@U:r@T @T@S@R]@R@R6@Q@QX@Qڀ@QQ@Q4s0@P@Pr@PV`@PPp@P|@O@@O5@OX@O@OW@OK@N(@O@NW@M@M@Oai`@PNw@Nr`@Naz@ND%@N [@Mw0@M p@LE@L@KRP@J`@J0P@J p@J'B @Jxq@J`@K|@K@KQ@Kڵ@K߰ @L@LA`@LE@L@L@MCN@Mh@M@Mۧ@Na@MF@N$1@NI@M0@M @M@M;@Mu@M?@@L @L @L @L0@Lp@M@M[@MC@L^@N0@OPF@Oa@O @O`@O㪠@O7@@Pd`@P@P01@P/@P, @P/ϰ@P70@PqZ@Po@P-@PZ@Pܠ@P0@Q,e@Qm@S @RH0@RyT@Rļ0@Rw @T+@T|Bp@T@@Up^p@U܎@V]P@W:@W@XԀ@YU @[# @\l @^ @`~@a@cCx@d@fj @h @i@k@l P@l@m7@@m`g@m(\@l܀@k @j@i+ @g0@f.`@d`@cl@b]p@a@a$p@`z`@`No@_@_F*@^t@]P@[Ԓ`@[tP@[]`@Z)@Z[%@@YM@Ym`@Y&̀@X @X@X_p@X @X_@X@Y(@YX@Yq0 @UC@Tޠ@TO @S@SR6@R;@Rd@R%@Qۯ@QZ@Q|@Q>@Q@Pp@P>`@PZȐ@P!0@O@O@Or1@O[ap@Od +@OX˰@O@Nq`@N@N4P@MX@M@Nj@@O@M@N9@N @M @M:@L@K؁P@K?H@J@@IӀ@Io @I!@I@@IN=@I@J@JT@J_@Ji@J@Jj@K@K@K@LT`@L@M0@Mg4@M@NP@N@@N5€@NW%p@M^@Mf@@M4@M7͐@LΛp@L0@L @@KԞ@Kd@K0@KΑ@LE@L{@M @JHޠ@Ms@N#@O@O9e@O}@O@Oߡ@P@P @P@P(0@P @P@P +p@P-@PJ @@PC8@PoL @PP@P@P@P@Q:@QT@Re@Rn@Rt@@Sh@@T5@T@UA@@U@V@Vy@Wue@XUp@Y[f`@ZW@@[@]|@_o@a5 +@b0@cA@ev]@g$P@hP@jQ@kV`@lE@l@m{P@mk@mDP@lQ/@kM@j@h{@g3@eP@dVep@cP@bn@a@a/T@` @`\~@@`@_`90@^@@];@\@[:@[4Pp@Z9 @Zp@Y@Yb0@Y@@Xs @Xދ`@Xʖ @X7@Xנ@Y1@Yyʀ@YV@T@TO0@Sg@Sp@S @R@R;@Ru@Qڽ@@Q@Qc@QC @PR@P`@Pn@P$@O@OP@OO.p@O@*0@OJ @O*p@O#@N0@N@Np@MP@M@NR@N@N@Kl`@N,k@M]p@MrvP@L@@L=E@KkP@J@I/@IEHP@Hk0@H]@Ha@Hj@Hj@@H_@ID`@I[ @I}@@IG@J +0@@J@@JN@KV@Kƀ@L@@L" @M@MmB`@M@MK@N@@N g@M|@Ml@M&`@L\@Lfzp@K@K}@K 0@J@@J@J!@K`@K‚@L0b@LԊ@O<@Ny@NP`@Nﭠ@O;@Og@O +`@O @P@P@P C@PHp@O&@O԰@OB@P @@P X@P5@P!q@P2p@PA`@Pig`@P0@Q@QT@RB@R @T;@T*c@TW @Tɐ@UV@Uɏ@VQ[@W@W@Xc@Z@[|z@]@^P@`?@a:@c@d@f Ԡ@g`@i,G@j@@k`@l@mJ6@@m}`@mL@l@kR0@jB@ip@h1@fO@@e8@d`@cF@bR@a @a*@` @`[T@@_P@_0@^B@]E@\O@[o@Zě @ZB@Ye @Y`@YCz@YI0@X@Xۢ@X@X(@@Y/`@Yr@Y@T9@Sb@S@SK@R`@Rm̀@R&a@Q@Q`@Q0@QR0@Q$@Pˀ@P: @P\b0@P;@O>0@O9`@O@O$I @O2@@N!@O@N@N$@NP@N60@NC @O"@O9@O:e @M@Ng@N,7@@M~@@L@L .p@K(q@JS@I@H@HhQ`@H>Ԑ@Gv@HA@H7@H4G @HE@Hcp@H= @I@Iy@@Ir0@Jh`@Ja @KU@K(@@L#0@LP@L~`@Mm@@M@Mm@M @M@M{p@L@LZW@K`@KS@J@Jwc @J)@@J`@J k0@Jf'@@Kp@K@LDj`@O,&@O0@N  @NpP@N{P@OuP@Oc5@Oy@Oc@Oۂ@O`@Oo @O@O@O} +@O@O\@O@O@O`@Ow @P@PD6p@PP@Q@Q6`@R@Sm@T`@S@@Ty @T @U`P@U@VKP@WlQ`@Xd@Y&@Z[@@\a/@^@_@a@bA@c @d@f2@h@i@j@lS@lop@m]@mfm@m#2@l#@k@jr `@i@g@f# @d߭@c@b"@b)t`@a`@a@`J@`9@_@^@]@\à@[ @Zɀ@Zcu@Ye@Y@Y]f@Y@X߀@XZ@X@Xy@Y@Y.@YK@S @Sp@@S$@RϧP@RS@RC@RŠ@Ql@QE@Q@QM@Q @P@Ps@P;ap@OQ@Oe @O~@O @O@O$T@Of@O?p@O@N@OY@O30@O8@O @P`@P @Oy@@OR@N@M@Lޤ@KX0@K"@@J@@IC@H@H @G?@G@G@GƼ0@Gs@G`@G7@H(@Hp@@I@I@I@J|V@J@K)^@K`@K @LCk0@LP@L @L@Lߐ0@L@L0@La@K@K/@J@J+@I@@Itp@Ib0@Ip@I@J)@J&@Kiu @L?ʐ@Me`@Mi @M@N@Nj`@O\@O@@Ob@Oe@@Otz@Ol@Oq@@O<7@OD@ON@O[@ON @OQ@Nr@N] @O8@O @Ph@Pf(@PI @QoX`@Q@RN@Sb@S@T^p@Td@U~b@V*@V@W;@Y@Z_@[A@]c@_@`s@a:@b5@d$@e~@g@h@@jFp@kF@lK`@l)`@mZO@mU@l"P@l @k!F@i@hp@gV@e@d`@cw@b@a@ai@`@`n:@_w@^y@]>0@\W@@[@[Z@ZbP@Y@Yp@Y<@Y@X̴@X& @X@@Xx@X@X@@X:@SYq@S8@R|@R @Rl@R@@R"@Qް@Qp@Q`@QP@@Q @P@P@PDð@Pp@OM@O4t@O @O'@O>@OLڐ@ODp@OfP@O@O @ON@P@PRe@PP@PyF0@P9 @Oѝ @N0@MP@M-@L%0@K9@J#P@I-K@Ht@Gю@@G,@G_P@G}@G~Ap@Gv0@G\`@Ga@G@H @H`@IA@IT@@J @Jl\@J0@K@K{@K0@L@L2}@L@K @K͇@@KD@K 5@@J}@J5@I@I@H @HC@HX@@H=@I@Ib@J @J# @KX`@K0@LFɠ@L @M9@@M@M @NB'@N@Nư@N2p@N@O`@O'@O@O# @O@NӲp@N @NC4p@N>)@Nb@No@N@OI@O@Pe@QX8@Q@R@@SO?@Sϣ`@TW@@T20@U`@V@WvL`@X@@Y@[B0@\і@^n@`p`@`W@bv @c9@dI@f `@g@iP@jl@k< @l{ @m@m<@m@l@kP@j0@iR@g@f@e=@dɐ@c)u@b`D@aH@a(@`Z@`%S@_H@^1 @].I`@\@[0`@Zf@Y @Y`@@Y@@@X¾ @X@Xp&@XW`@X;@XH@X8ɠ@X4P@S(@R`@RsP@Rm@@R_-@RE@R0@R +p@QOp@QN@Qv@Q@PZ@P_@Pf +@@P) @O̮`@Odk@O>> @O]@Okm@@Oz@OîP@O@P΀@P2@P^U@@Pf@Pm@Pڀ@PU@P@P5@Oc@Nd1@@M}P@L@P@L"@JP@IA@HtM @GJP@Gv@Gkj@@GvF@Gdg@GX0 @G'@G2p@G{@G@H(k@@IE`@It@@J\@J@JC_@Jy@K g@K&@Kt@K@@Ka@K@J;@J|а@JH@Ik`@Hp@HmӠ@H`@GǙ@Gް@G@Gӡ@HN[@H@IA`@I@JZ @J\@K;٠@Ka@L,P@L@L~@MgP@M @N@NG@NN@@N @O*@N@N@N@Nd`@N,@N-0@Mi@@MV@MH@N6]@N1@O@O @P]`@Q]/@R @Rm@SJ@S`@Tz`@U<@V +@V`@X/@Yg@Z@\B@]H@_d!@`@@amZ@bCp@c@ex@@f@h$0@i@jp@ka@lb@m@m@la@l@k' @j@h̼@gYw@e\@@d@c@b' @a@a\@`԰@`M0@_@^w @]b@\T40@[T:P@Zr@Yp@Y7`@XZ`@X`@X4@X @W@Wⷰ@W@W@W@S @Rl@R@Rc@@RY@@R\.@RO@@R3`@R@Q@Q`@QD`@Q2@PC@P`@P@p@P@Ot@O@O@O@Oɟ@P""@P!l@PV@P@P@P @Q + @Q`@Q +>0@Pg@@PP8@O @N @MЭ@Lh@N _@L@@It@@H`@H/@G@G߀@G@G~j@GZ@GQ@G@GQ@GȀ@H9j@Hο@Iff@I`@I@IƲ@@J:@Jp@J@J@J̠@JÁ0@Jm%`@J@I@I@Hm@G@GWڀ@G*@FY@F`@Fҵ@G`@Gx@G2p@HO@I4@I@I# @JnP@Jg@K >@K4@L $p@L@L @MN^@M@M @N&]@N@NF@@NP@N @N@M@M@M@Ml@My@@MP@NaP@NI@PHX@PM@Pӳ@@Qly @R"t`@Rٰ@S^*`@T{0@T]@U@Vi@Wv @Y0|@Z{`@[p@]uˀ@^@`$W@`@a0@b*@d>@e)@g5p@h@j@kR@lD`@l @m`@lV`@lv%@k0@j@izP@h. @f@er @d>@cT@bik@a@@`@@`w~@_r`@^@]*@\zp@[w +@Z@Y @Y@@X@X;@W0@WP@Wr6@WH@W+s @W +@VZ@S +A@R]@R@Rh@R>@R0@RR`@Rc@RBz@R]p@Q=@Qd@QTL@QP@P@Pu@P8s@P`@O@OÐ@O۠@PX@PDV@Pr@P@P/`@QY@QJ\@Q]"`@QO@Q0@P@Pe0@P@O&@N@L@LY@J`@Hw`@H+@@H6@H^y@H+@Gȋ`@G3@G@G@Gzj@G+@G( @HM@H0@I;@I]@H`@H@I*@Iؠ@I`@J!@J"+`@J @If @IC@H΅P@H='@GI@G$@F`@Fb^@F. @F@@F@T@F|`@F5@G@H 2`@H*@I(@In@@I0@JO @JfQ @JJ@KXB@K*@L>@L @L@MU0@M@M@@N>@N0@N4np@NR@M@@Mg @Mfː@M> 0@M4@Mq@M @Nh"`@N@Pڠ@P@P @QŠ@RN7@Rep@Sc@@T~7@U|@@V`@W@X@Zhk@[@@]8R@^ +@_@`m@arp@bqU@c@du@@fJ@gϲ@iJ@jl`@kȂ@l@l@m +0@lĞ@l`@k4@j"I@hl@g@f:t`@dӀ@c<@b@a@a@@`@` +G@^P@]ԛ@\0@[@Zx@Yj@Y@Xp@@W8@W9@W:@@W@V +`@V@V@Vd@S/@S$@R@R?@R߭@Rp@RΠ@R @R'@Ri3@R,@Q@Q@Qg@Q@PP@P` @P;@P@P@P{x@PBVX@PoK@P@P@@Q$@QgCP@Q@Q@Qr@Q10@Pۃ@Puh@P@@Og@Ns`@MO@L2@@Il@Gh@IY`@H@HP@H@@G_@G@p@H8@G@G(@H 0@H7@H{3@Hڛ`@I-` @IB@I$@@H@H<@H@I@I40@I6W`@IϠ@H%@HI^@G/@Gzt@Fp@F|@FO @E0@EΧ@E @E@F2@F@@G%b0@GV۠@Gkf0@H@I@I>z`@Id`@I@J:@JѬ@@K,P@K @Kձ@LO@L@M @M\@MŦ@Mp@MG@@M@p@Mp@MjӠ@MA5`@M'w@MF@Mg_@M @N L@N`@O @P و@P@Q!@QC@R@Sa}@TCD@UK@Vb@@W@X@@ZTn@[C@]0@^x@_@`wT@@a2@b2p@c@dp@ey@@g@h@ia@@k20@l' @l_@m@m@l|r @kC@j)@iJ`@hk|@g!@e8@di@cB@bH@av\@`@`+5@_2n@^@@\@@[Ɖ@Z躠@Yظ`@Y@X]@WcP@WC@Vݑ@V`@VX@@VK@V@Uܣ@SzÀ@SY @@S:e@S3P@S?@SDp@S0@@S P@R@@R˰@Rp@RO@@R]@Q-@Q[@Pp@PK@P[0@P>@P-0@PG@Pp@PP@Pa`@Q) @Q~0@Q@Ql0@QL@@Q@Q\@P5`@Pu%0@Pf(@Oq@NS@Mf@@LiP@Klp@H@I@@I30@Ic@IC5`@H@Gr"@HXg @H3H@H9@H_!@H @H,@I ?p@I@H @Hp@H@HqX@HK9P@H,U`@H;1@H+`@G@Gf@GGP@FQ@F@@F-d@E7@E@E@Eq@Es@E;@FlP@FZհ@F@FI @DO@G@H@@H{@IH7 @I@@I(P@JG@Jנ@J @KYM@Ku@L.@@L@L@MHP@M}@M @M@M@M_ +@MQ@MGϐ@MX"@Mn@Mհ@M`@NcH@O 0@ON@Pf@@Pz@Q^@R{@SD|P@T@UDА@V@W>@YP@Zcp@[@@]+@@^v[@_5@@`d@a @a=p@b8@c2@d@f/8@g@i/`@j@k0@ljbP@lڧ0@m@lw@@l=z@kiG @jc0@i56@@h F@f@e-p@c@@b@a @a O@`Xu@_sU0@^U@]7zp@\]@[y@Zh@Y @X[@W@@W @V +@VG@Up@UǠ@U@Uv@S@SqP@S @SMp@Sb@SP@S@S>p@SuV @S0@S)@RƇ@Rd3@R0@Q @Q/@Pԙ@@P@Piް@Pm`@Pl@PP@Pp@@QLp@Q^x@Q@Q{ @Rp@RG@Q@QU @Q@P@PGY@O0@@NS@Mު @Lbw @Jz@K+)@JgP@J|H@J @I@I@I=`@I@@Hx@Hk@@HKP@H@H@Ip@HP@H@H0@H4R@G@G@@G@G?<@@G@@F@Fй@Fy@F@Es`@ES0@Ey@EA@E'2`@EA@@Ek@@E7@Eo@F*_@FYL@FY@@F\'@GV@H @H"@HP@II|0@I@J%N @Js@JQ@JV@@K[@Kc@LD@Ls @L@M @MfO@Mz @M~P@Mk @M6@M^p@Mh@Mby@M@M@NKe@N}@O@P?@P`@Q~ @RM͠@S8@T@@Up@V@W@YI`@Z@[R`@]7`@^@_ @`X @`@az@b(`@cJ@d& @eg`@f@hY1@i=@k*P@kc@@l0@m@l@l"0@k@k @if@@h8@gm@f @dP@cV@b;@aR @`q@_@^@]w @\S @[J@@ZM@YD@XgP@W*@V @VX`@U@UK@UdP@UH`@U[@TUP@TM@TL~@TM@T=@T=@T5@T"@T@S@S|ؐ@S2@Rϥ@RcuP@Q@0@Qrq@Q 4`@P`@P`@PT@P@P@Qՠ@QWp@Q@R @RG`@Ra@R`P@R5p@Q@Qx@Q@P7@@P%`@O/@Nc=@M*M@KTP@L @Kj@Km>`@K 0@J@J*@I@Jˠ@Ia@@H@Ho@H@HP@I`@H۽@Hq@@H @@Gl@Gz?@G;r@FT@F@Fj @F_ @F @EO @Ek@EF@E$``@E@EM@@E +@D@E-P@E@Ep@E@F=@Ff@@F@IP@H) @H5P@H@H@IrS@@I@I@I`@J @K,d@K @L^@LW @L@L4@@M +@M``@M.@M@MAc@Mh@Mr@Mo0@M@N@NB@N`@O@PC@PK@Q{J@RK`@SJ@TU@U'@V"@XG@Y@@Z䚠@\*@]gٰ@^@@_"@`NI@`@aEv@aJ@b`@c@d@f K@gwX`@hP@jQ}@k`@lG7@l0@m@l"`@lb(@k3@j2@i @h[@fܖ@eO@@c@b`@a(@`@@`@^@@]j@\b@[y%p@Z|P@Y{3@X @Wp@V㴀@V8Ȁ@U1@@U{v@U*@T @T@Tg@T@T @T @T3`@T@T@T@T_ @TU@T J0@S @S3@R @RI*@Qp@QRn`@Q @P+ @P߂@PF@Q%@Q{ @Qȥ@R@R @R٬`@Rp@R@R0`@Ra@R.@Qp@Q@P|P@O@O @NW@MR@NG`@Lw0@LG0@K!@KI@@J@I `@Ip @H@H@@H@Ho@H`@H@Hk3@HDy@G=@G_@G@F@Fsg@F13P@E@E^@E`@EY`@E(@Ds@DR@Dʂ@DV@D`@D@@E P@E`=p@E@EP@F>@F`eP@Fp@G2@H,`@H +Y@HE@Hp@IhJ @Iϐ@I@I@@J@K]`@K݀@K`@L @L{@L@L0@M,@Mq @M@Mx@M9@M@MP@M @N`@N0@N @O@P@f @g@igL@j@k`@l}@ld@l`@l@l)@k_`@jU@i"*0@gw@f%'@d"@ca@b,.0@a&I`@``@_Eh@]h@[<@@[@Z@Y# @X2@WG@V@VJ +`@U~@UR0@U@Tн`@T+ @U/@U;€@U=@@U^`@UnZ@UaP@US"`@U:6@U) `@T@T@T)@S>@S(f@R"@R)}@Q@QY/@Q3@Q:@@QYv@Q@Q@RO@R`@SN0@Suv@S@S`@Ss @S`@R @Rgn@Q +@@Q8l@P@P"#@Om`@NH@O6 @Mx@L@L @K @J@J?-@I@HP@H @@Hqv@H5@H `@H @@Gݏ@G@GB@F`@Fk@FK@E@E$@E@EFS`@E6@Eq@Dȧ @D[@D@DP@DE@DP@D @@DQ @EF`@E>@E`@F/`@FC@@F@G(-@Ge@H& @HZ@I^p@I@J&@@Jx@J @Jp@KA@KP@K@LP@LSՀ@L@L@L@MV}@Mz@Ms&@@M@M,@M@@N@N[@NyP@O,@@O@PoQ0@P @Qj@Ro@ST"@@Ty@@U@W@Xn@Yނ0@[R0@\i@]@^sp@_y@`,@`@`ؠ@aP@@a\@bf7@cLP@dhv@e@gp`@hz @i0@j-@k'@lx@l@m/@@l@k4 @k?`@iңp@hj +@f +`@ej@d @b P@a@`@_`@^ @\@[p@Z9@Y&@@X@W߃@W"-@V_`@UՋ@U_נ@U `@T`@Twl@Uy*@UV@U[@U@U@@V- @U~@U_@U%@U@@U"@T@T$ @S@@SJ@R[@@RW@Q'@Q @Q@Qט@@R% +@RJ@S%$@S(@Sn @TT.@T`@Tp@Ty`@TH<@S9@Som@RB @R*<@Q`@Q@P@P68@Oq@N@M@L}@L@K)@JWn@I @I`@H@HE@G @G@G@GP0@F`@F@F6@El@E@E[w@E8@E#@D@DI@D͠@Dz@D]@Ds:@DHp@D_rp@D}°@DP@D@E5 @Ep@@E@F5@F@F0@GRU@H@H@I@I6`@JE@@J@JL@K*ذ@KH`@K$@K@L@L+p`@L%@L@L@Lڴ@M-ǐ@Mg3@Me@M @M(@M0@NQ@Nē@O; @O0 @PV@P@Q +@Qž@R @@S[U0@Tq@U@W&@X@Y@[>`@\f@]@^<@^E0@_@`b@` @`GP@aS`@a +@b0@c|@dĨ@f@gq*@h.@@j@k;X@l@@l@m@m)"@lQ'p@k[ @j@i8@g1@f;<@d`@cX@b@a?@`@^`@]7:@]@[(7@Y2 @Y@X@WG0@VK@Up@U@U@T@Tf@Uap@U@V @V8-@Vp`@Vp@ViZ@Vo @Vo@VQ@UĨ`@UEԀ@T@T$ @@S@RR@R\`@RTVP@R2P@RM0@R@S +p@S@T)P@TǤ@U=y@U@UP0@Uˠ@U 0@U@US @TΚ @T&r@S^)@R!p@RQx@Q @Q$L@P4@P@O@@NW`@Ld@Ks@J[@I`@I8F0@H @H6q@G`@GyP@G=@F@Fu @F@E0@ELp@E@D@@D@DP@DqP@D^-@Dh(@@D-@D@D.`@D&@D$8@D\@D`@D0@E.P`@Ex@@Eˡ@FHװ@F@GP^`@G#@Hg\p@Ir@IY@J;@@J @KQ@K@@K@KӰ@KG@@L`@LHi @Lc'@LC@LXp@L@L50@M(p@MP@@M@M@ND`@Ne@NZ@O@O.@P]@PWq`@P5@Q@C@Q?@R@SZM@Td@U7@W @Xq`@Y+@[3~@\F @]<@^&N@^k @^~@` @`dQp@`r@`)@@aN@a`@bC@cǐ@d@fZ[@g?@i"-@jg\@k{@l5P@l @m3@l@l)D@kE@j0@hP@gP@e,`@dP@b`P@a{>@`g@_ ŀ@]0@]^@[@@Z@@Y@X,@WyĠ@VȐ@V 0@U`@U(]@@T~@Tq@@UP@V@VX0@Vk@Vl@V@V0@V@V @VA@Vd@UY@UI`@T@Tg@Tr@T?0@Su@S`@SU@T@T@@@Uc@VA@W:+@X6@X~@Yh @YI @Yz@Zٰ@Y@Y9@X}@@W{@V}0@V@UC@T @S@R@R +@P3@P@N@M))@KVP@J @J@I\@H@Gꆐ@G@G:@FP@Fp@EqP@EG@E +@D`@D/P@DjD@D)(@D@D@D,@D@C@C| @Cɵ@Ce@D@D`@D@E$ @EL`@F @F{@@G=@H@@H.@IJ@IR@J@Kð@L0!@Lڐ@L +p@M30@M@L@L[P@Le`@L@Lv@Lgq@LdF@L`@MP@Mk~@M +p@NIڀ@Nƣ@O$@O]0@O@P+@Pa@P@@Q@QV1@Q0@R@SX@TA@UN @V @Wb@Y)P@ZS@[u@@\W۰@]J@]K@^m@`i\p@_( @_@_P@`>,0@`@@a=N@b@b +@d"@@e@g +@hr@i@k#@l )@lP@m>`@l@lC@k;"@@j9@hĦ@g!@e`@cp@b +@a5@`e@^G@\@[c?@Z@Y`@XX @WD!0@Vj@Uƭ@U @U:l @T @T@U@VKp@V>@WZ@WtmP@W@W~@W0@X@W۱@Wx@V@VyH@U,@@UJ0@Tَ@T`@TZ@Tϰ@U @Uޫ@@V@W~@Y!@Z>@[@[ @\\`@\`@\D@\i`@[@Z@YP@Yp@X`@W@V2P@UE@T:P@S(@QS@P@OY@Na@@L@K#@J@I@IB @H`@G{@G@F@F`@F4P@Ep@E@@D @D(@@D @DC#`@D+B@D h@D@D @C@C@C@C8@D8@DDp@D@E ) @E҄@F5p@F{@G@@Ha@I@I@J`@@Kn@L,/ @L,0@M_}@M@M p@M@M=@MF`@Mq@LF@Lư@LmwP@L]0@L@Mu@MS@M@@NsĠ@Op@Ogϰ@O( @P&@P^@P׀@P@QA(@Q@Qjp@Rx@SG @TN@Up@V @WZ@X@Yŗ @Z@@[@\ML@]c6p@^@_Ho@`@^yV@^X0@_G@` r@@`Đ@a&6@@b +@c@dbeP@e{@g|t@h @jZQ@k]@lf<@@lP@m0@l@k @j@@i@gep@fQl@@d@c@a@`p@^`@\@[S0@Z @Y @X@W&@VYr@U0@Up@U)`@T`@T0@ZM@@Y*@@X`@V @U@Tn{@SB@Q2`@Pp@OG@NQ[@@L@K@J @I@@I&1@Ho@@HP@G@Gސ@F#@F,h@EJ@E@EC@E@D@@Dw @DR<@DV@C(`@Cum@C*@@Cs@C @D2`@D_i@Dp@E0:@E?@FJ@@G +@G@H0@IT@J @K]@LP@L@M@N/@N\@NfQ@NGP@N>0@Mn@MUf@L@L&v@LA6@LY@L@M.;@Mo@MԜ@Nr7@O&@O@PX @PS@PԐ@Pr@Q@Qg8 @Q@R@R4@S?0@SuP@T7@UX@V@Wp@Yz@Yy@Z@[Rq@\Hc@_@]0@] @\B0@]@^ @^`@_0@`G`@a0@b!Š@c[װ@d@fgC`@gp@i@@j`@k@@l@m@@lk @lm@k`@jkp@h@@g`@@el@cP@b@`3@_54@@]B@\@ZI@Ya@X +p@WA@VG @UP@UW\ @U @Tؿ@TP@UY@VLP@V7@WCD0@W@X?E@X @Y@Y`@XR@X@X7@W@@WP@Vm@V@U@V!ހ@V@WB@X@@Zr5@\hP@] @_n<@@`dD@`^0@a:@aj`@ab@a+^@`Ⱦp@`?Z@_u@^<_ @\`@[kP@Z2q@X0@WN@UP@Ti @S@Qt@Pǒ@O7@N`@L`@K3@J`@I`@I3@@H@H68@G@G^@GU}@F=@FJ@FO@F@@EP@E>@@DB@Dz5@C@@C|P@C}@C@D ߀@DMK @D@ES@E@F*`@FP@GF0@H;@IA@JSU@@K;@L+@LH@M +@@Np@@Nц@O@O"@NҒ@Nj@@N%@Mf@MI@L1 @L@L@L`@Ma@Mj@Mh @N@OjϠ@OH@PCp@P@PZ@P@QB @Q@@QI @R$,@R7@S+p@S +@T`@UGD`@V@W!p@X4|@Y@Y@ZD`@Zd@[7@Zs@[P@Xh7@\ @\@]=@^(p@_ @`5p@a.y@bUG@@c@eFF@f.@h@j@kf@la @lP@l@lJ@lI`@k@iǠ@g=@f'0@dc@br@a*Q@_?0@]L@\T@ZP@X= @Wٍ @VF@V E@U@U%`@T*@T @TtU@@U*@VIA0@V֨ @W@XW@@XP@Y. @Ytv@YUP@Y~@Y$`@XǍ@X3@W,`@V@VP@V@@VP@W; @YP@Z@\xP@^@`M{`@a.r @a$@b@bl@@cp@c0@b@bL@a@a `@`Zh@_(@]@\.@Zu@Y @@Wt@U@THP@S`@Q?`@P׋`@O@Nf@L߫ @K|@J0@I@@Iuw@IE`@I.@Ht@H@H@Hcl@H/@G@G@FtP@Et@E>@DP@DO@Do@D@@Do@D3@D @EN@F#)@Fu @Gt@H>6@H@IΈ@J @KOp@Lp@M؁@Nk@O4@O@O׫@OȂ@OqF`@O@N@M܏@@Mo@M\@Mt@M8G0@LJ0@M@Mo @N[(@O0@O@P5,@@Pa@P{@Q +:@Q;`@Q'@QP@Qհ@R5@R@R#p@Sz@T@Tދ@U1@VTp@@W6@WW@X`@Y)@@U<@Tٿ0@T`@Ttx @T1 @U@VJ@V&@WC@Xt @Y*@Yf@Y@Za`@Zu@YR@YN@X-0@X +p@W0@WH10@W] @W@Y@@Zhm0@\P@^`@`@à@b`@c@d1`@d@@d@@d @d\@c؈@cP@bI40@am@` @_S@@]@\w@Z%@Xܵ @W.p@U@T?<@R@QF@P_@O3@N49`@L @Kn @K @J@J|°@Jِ@J@J&p@J@JRP@J( @I@I@H] @G2:@FV@E@E9@D|@D@DЙ@E> @E@F6@F@G*@H7'0@H@@I%@J@K@@L@Ms@Np@O5@0@O @P90@P0@P39@P |@O @N?`@N]@M@MT @@M@Ms@M@Mb`@M, @NL@O}@P!@P@P+@Ql@@QW@@Q| @QÏ@Qɐ@R`@RG@Rj`@R@SJ@Su @Ttp@U4 @Ue@VPݠ@VP@WB@X@XW @XD@W9@X[0@Y6@Yu@Z6P@Zٰ@[``@[?P@])p@^ɦ@`kH@a@c W@d @fcQ`@h @i@@k(@@k͠@lq @l@m-rP@m@jt:@i @gy@e|0@c$@b6 @`@@^`@\m @Y(@W@WP@V@UH@T@T@TT @T+@S@Uր@VN@W c@W@Xp@YG@Z'3 @Zy@@Z. @Z @Zf@Y@Y4@X@XX@WP@X)@@Xm@Z.@@\ `@^O0@`s@a@c D@d;N@e!e@e—@f*@fHQ@f#Q@ej@e@dSY@c{@b`@a@`* @_@]@@\7@@Z(@X@V]@U| @T+o@RD@Q@PԠ@Ovې@N@L@LL0@L@LG@Lx@M@M@M&0@MN @L°@L@Kb0@J,@I&@@G`@G +@Fa@E@D@E @E@F`@G$K@G@H]P@I%k@I[@@Jؠ@Kli@Ly @MA0@N8@O8@P@PCM@P{ @PȠ@Pt@PTN@O߀@OE@N @N4@M@Ms @MkE @M @Mp@NHm@O@Oe@PUMx@P,@Q @Qc@Q7@@Qp@Q梀@RU@RF@RT%0@R@R@@Sp@Sd0@S*@Tm0@Td@UuJ`@V`@V`@V@W(6 @WPP@W0@W@W`@X8|@X@YE@Ya@ZC@[@])@_@`p@a<0@cx@e. @f2@h@jP@kp@kZp@lJ-@l6@ksP@hs@is@@h @fbo@d,@bw@a@@_LP@\@Ze@X6@W@Vv@U`@Tr0@TEh@T@S!@S@U\@V>]0@W'`@X.@@Y@Y@@Z@Z`@[0@@[+"@@Zp@Zgs@Yڐ@Y@X@X]@Xȍ@@Y@@[Z@]Q@_O @at,@ciP@d@e`@fg@g5@g`@g@gg@f8@f9 @eJ@dEw@cL0@bX@aV @@`X@^F@]@&`@[f@Y @X. @V@@UN0@S@R@@Q[@P@@O @Nmp@M@@M@@NP@N@OP@P@Pp@P +@O@O P@N@L@K@͠@I@H @@G @G<@E@D<@G@G8@HGT@Hp@II@JB P@Jɐ@K@L0@M`@N@O @PT0@P@P1 @P@Qՠ@P@PQ@@PM @Or@O7@N0@N=p@N-@N@N@Nv@OϠ@O!@P;@P@Qh0@Qpi`@Qm@Q @R`@R&Q@R>u@RIu@R0m@R{F@R@@Rޮ@S04@S @SH@TM@T@U @U%@@U簐@V@V% @VSz@V8 @V?`@V%P@Wop@XQ@Xl@Yi`@Zz@[ @]Y0@_[@`@bYF@@c @e`@g`A @h@@j=@@kb0@k@k`@k&c@hp@iP@h~@f@e6`@cc٠@aA@`Ѱ@]w0@[1@Y u @Wa@Vg@@U\Z@T@T@SĿ@SP@SX@Ux`@VO@WP@XlP@Yf)@Z>M@Z @[lD0@[@[@[\ @Z)@Z#@Ym @YN@X@YP@Z@\q70@^P@`a0@bkP@d@e;@fѠ@gM@h°@h@hַ@h@gM@gp@e@dP@c@by@aE@`@_k@^P@\j@@Z: @YIop@W9P@V^@TT`@SP@RMv@QS@P~@PMp@O`@OҖ@PA@P0@Qn@@Q@@Q0@QнP@Qg@Q<@PD @OE @M•@L*p@J @Iq@H@H,@E @H0@H@IP@Iy@G5@Km.@Lp@M@M@No@@OV@Pz@Q`@Q]@Q@Q@Q[@Q@QBe@Pp@P|0@P8@O@@O7@NW@Nޕ @Oɀ@O^q@O~@PII(@P@Q.p@Qp @QO@R +WP@R2 @RO@@Rn@Rx[@R~t@@Rc@Rz5@Rr @R@RP@S, @Sg0@S@T/@@TI@T@T@U@U5=@UJ @Ui @U @Uy@VR@VՇ @W @XM@YK@Z@[0@]r@_[0@a=,@b_`@d`0@fp@g@i[`@j + @jL@k.@k"@j4 @i@hP@gf@e@d/@b?@`@@^+&@[@Y@@WJ@VW@@UCp@Tq@@S@S} @SD+@S@U@V@W @XH@Y@@Z @[ZW`@[o`@\ @\P@[@[>@@Z@Y`@Y @Yd@ZNp@[@]~P@`)`@a@c4װ@d@fx`@g^@h@iڠ@i@i@iD60@hu @g0@fc@e'@c敀@bX@a*@`G@@`v @^0P@].@@[m @Z0@X*P@WJL@Um @Tp>@S@R@Qb@@P@P‹@Q!@QF`@Rw<@S C@S@@S0@S׉@S\@S(@Ra`@Q`40@P]dP@Nŀ@L @Kg>@Je@Jz@J@@M@Jw@K@KI @HA@@Kh@M5p@Nm@Oj@P5@P#@QA`@Qܰ@R1E@@RoP@R/@Rb7@R<@Q<@QT@Q@P@P@@P!@Oۛ @O/@P@P6@Pvf@PԆp@Q,/@Q+@Qp@R+8@Re,@R`@R 0@R@R@R0@Rs@R @R\@R@R@R@S9@S6݀@Shf @S @S@S @T5@TRL@Thc@Th@Tx@T@UP@U`@Vp@WA-`@X@YN@Zh@\*rP@^@`*p@a,p@c@@d@fY@gր@h@ip@jfѐ@j@@js@i`@h@g@f?j@d@b@a*k@_/E@\tɐ@Z@@X +8p@V~@UE6@TZ;@Sq@S9@@RK@@RW@U,`@V@W,p@X@Z @[P@[@\Oc @\I@\ @\BH`@[`@Z' @Zl @Y@Z8_0@[ @@\@^@@`3@b0m@cu@eUP@g.l@hN@iq@jFM@jq@j8`@i @h2@g\@f` @eP@c[0@bޱ@a@`%`@`#-@^B@@]@\5=@Z0`@Yqv @W@Ve@U0-@S/@R~@@R+0@Q@@Rΰ@R @S2@T1@U@Up@VV@V@UT`@U1@Tb @S P@Q(@Ph@Ox @MƸ0@L@K@Kh@Nqˠ@M@@L<@M/@N=u@P@P-@P@Pp@Q@Q@R!u@R>@S[@SH@Sf @SI@S@R@R[bp@Qȶ@QLmP@PP@P;@P@P|@@PP@Pɟ@Q +0@QaB@@Q;@R @RN@R@@R]@R"@@R)@R@R`@R@R@@S8@@R@R@Rp@R{`@R> @R݀@R@S @S5@SOW@SZi@S @S6@SP@S@@T@Tw` @TI@U@Vq[ @WO@X*@Y]`@Z2`@\Yd@^k@`l@a@c^@dQ @fz^ @g«@h+@@i@i@j"@i@i@go`@f@e(@c}*@aǎ@`'@]f?@Zʒ@XQ0@V@UhU`@TS@S5@S@R@R{M@Uǐ@W^0@XL`@Yq_@Z@[_@@\f@\p@]6Ȑ@] @\ؑ@@\#@[hs@Z`@Zv0@Z? @[˲@]QB @__@aY@b&@dU@el@goF@h@i@jp@j~R`@j;@if@hmP@ee @e@@dH @cm@b@a@`@`px@^ +@] @\v0@[-@Yܓ@X~`@WP@UY@T 0@Sq@S@RS@S%P@SͰ@T=@V@Wr@@W⇠@XY~@Xe@X`@WL@VD@T@@S@R)5@Qr@PA@Nζ @NA@M@@N60@Nl@O/ @@Oͦ @P8A@Qݍ@RD'@QB@@Q@Q@RR@Sp@S@T@TF@TP@T,@T@SV@S)@R@Q3@Q @Q@w0@Q"@Q*$@QD@@Qf:0@Q@Q3@RT@@R@R?@@Sn@@S+@S+h@S8d@S0@S$`@SZ @SA@S@S@R)@R@R@RR@R @R@@R@@R@RCp@R`@R@R@S@S8t@SY@SF@T) @TT@Ug@V@WP@@Xa@Y`@Z@\@^@`T@b @c@e@@fnk@g@h@i3@i?p@i|s@hK@hM@f`@e@d@bx0@`ͩ@^G@[`@Y40@W,A@UY@T~@S!@RbP@R{R@RC`@V8@We@Xt@@Z@[;D@@\G{@]"o@]p@]@]Ο@]fƠ@\`@[@[j0@[Iw@[@\`@^%|@`@aHր@cB @d @fu@g@h0@i@j;T@jN@i쌰@i@h@e"@do@dxP@bJ`@ap@a3@@`,8P@_T@^^S@@]x1 @\e@@[Vs@@ZF`@Xʖ@Wu@V#l@U_ @TJu@Sg@@So@T~@@U{@V^@@W@@Y"`@Z@Z@Z%@Z'V@Y^a@XW @V&@U@SP@RjĀ@Q]@P^@PH'@P@PC@P}@P @Q$p@QvW0@Q@Rf@R2@R @S d@@Sh`@T-@Tw@U@Ue @Uo`@UN@UH@T @T@Sk@R¨@@R30@Q&@Q@Qӣ@Q@R#D@@RYS@Rp@R0@S0@Sv2`@Tfn@S@SW@S8@Sn @S^ @SR3 @S5.@S,@S$@R@RP@RP@Rv@Rg@RW@RM@@RY0@RWY@RL@@RY@Rho@R@R@R@S @S @SP@T-@U`@V@W|V@XP@Y^@[4@]A0@_Iހ@`@bO|@c\@e&bp@fg @gu @hI@h؇@i`@hM@h`@g:@fl@@dB@@c+@ap@_F@\@Z'I@W @V"4`@T@@S`@S@Rh@R `@V@Wk@YB@@Zp@\(@]@]@^t@^@^@^yP@]f{P@\s@\ϰ@[H@\f@]a@^@@`@@a@c+ @d`@fp@gb@@hai@iT@i @i @i4@h^@gB@@e@et@d(̀@b}@a @`8R@^<@^f̐@]@]sp@\(@@[( @Z&E@X@Wl@VR@@U2@TT@TV @U@UX @V߀@XZ @YŦ@[ @\ @\p@\.@\-5P@[X@Z`@X`@V@U$@SӀ@RP@QT@Qj@Q_@Qx@Q|@R?p@Ry@Rep@Rp@S,@Sa@@SD@T)`@T@UC`@U`@VH@V@V`@VN@V?=@U@U@TWq@S@R@RQ@R{:@R# @R@R0@S@ST@S0@Sͩ@Sp@T>@@S@@SV@SP@S*@S@S@S~L @S`0@SR@S5'p@R @R@R@@R7k@R0,@R@R?@Q@Q@QX@QV@Rp@R*8p@RY6@@Sw@@Sép@Sc @S@@Tů@U@V +`@W@X@Z@[]@]@@_Z@@a-Ā@b'@c:@e/@@f];p@gW@g#`@h^@ho0@h!@go8@fr@eAb@c> @bP@`p@^F@[O@X`@V@UD3@Sŀ@S#@RP@R@V@Xuh@@Y@[q$@@\ܝ@]@^'@_w@_@_4@^y@@^#@]f@\U@\J@@]:@^2@_`@`@a7@co@d@e@@g +@g@h~C`@hi@h@@h s@g1@fP@d@d7k @d@ac@`=@@_A`@]@][@@\ @\@@[uP@Z@Y @X@WX@V@U @Uƪ@UԠ@V@W 40@Xd@YC@@[|@\zp@]@^]1@^s@]5@\Љ@@[@Y} @XN@Vh9@Ta@Sޮ@S=@S @Sw@@S2p@Sl@S@SȀ@T'@TFLp@Tv+@Td@T @UjO@U@@Vh@W @Wz@WȚ@WE`@Wɐ@WY@V@V @U@@Tt-@S0@SDQ@S j@S9@@Sa@SY@SΌ@T @TK@Tw @T4@TÀ@S@@T@Te@TK0@TP@Sv@S/@S@S=`@SsD@S*y@Rʐ@R(@@R@P@R @QP@Q@Qղ@Q@QK@Qi@Q p@QF@Q@RI @R]@R@SiZP@T @@U@@Uk@V@W1 @X @Z_@\@^`@`0@aq`@b6@d*P@eJ\@fV@g30@g@hx@h @g@fP@ea@d `@c w @a @_@\p@Za@WО@U@T|t`@SpmP@R@R%i@Wj0@XM0@Z,@\/9`@]@^@@_M@`<@`Xa @`=8@_@_@^EP@]1@@]Ƞ@^@^@`Y@`@av@@c/`@d^~@ep @fk@g&f@g& @gP@gO@@f @eq`@du@c~@b@ @aG@@`0}@^`@]@]<@\0@\T@@[@[M@Z@Y3@X@Xp@Wao`@V'p@VP@V@WA@Xb8 @Y@[8`@\Q@@]@^ @_ɟ@_{-@^@^ ^@@\E@ZW@Y"@W)p@VҀ@U +M@T},@TYB@Tor @T@T߁@U1,@UdF@U@U@Up@UF@V-@V @W@W@X00@X>p@X`@Y @X @X@W@W@V<@UM @T@T#%@S@Sܠ@TZ`@TU@Tt@T@@T`@U`@U5@@UC#@U[@Tª@T@T.@TB@Tb@TC@T5P@S@SŖ@Sx@S +`@R1@Ro͐@Q@Q@Q5`@Q@Q@@QU:`@QChp@QR<@Q^@Q@Q_@Q @QW<@R@S@TT@UB@V%(0@Wd@X@YRP@Z9 @\?@^n @`Pʠ@a@bb`@d*@e_@fc0@g%@g@ge`@g0@g@fF#0@e:@c]0@boK@``@^f@[l`@X@@V(@U-@S8@R0@R@@ @W @Y,@[6^P@\ @^m.@_ @`l/@`@` @`@`e@_z@_ .@^ń@^ؐ@_&`@_Z@`q@a@a`@b@@c(@dT@e@f5(@fˀ@fZq@e @e@@@diF@cd@b8p@a@`(@^0@]]z@\`@\0@[,@[E@[Fp@Z8@Z8d@Y# @X @XfP@W @W0@Wu`@Wx@X@YP@[ +P@\@@]@@_ $@_̘ @` @`C@_@^@]15 @[Y@Yv@XVP@V@U@U"@@U P@Um@V@VY+@VEp@Vs@V@WC@V@W$@Wep@W`@X@d@@X!@YX!p@Yǡ @Zb`@Z&@Y@@Y~@@X\ @X3@W@U@Uu@T@T@T@TL@U @UB@UzP@UF@Uc@U̠@Uc@U@U9@Uc0@U)f@U+@@TŌ@Tc@T@T{3P@T5@S限@S@SY@R@R.~@Qp@QV`@Q@Qi@Q3@Q @P@QL@Q&p@Qbp@Q^@QF0@ReT@SD@S @T@Up@Vpu@W`~@@Xi@YG0@[@@\|@^ѐ@`@a^@c@dR@e}@fz@g-@g@g$0@gZ@f^P@e@d@ @cRw`@a@`@] @ZN`@W@V `@T@Sl@R|P@@XK@Ze@[A@]@@_@@`I@`<@a5@aR@a/@`״@`iap@`~@__@_ @_)@`,P@`@a40@a @b7`@c{@dMaP@dP@e3"P@eQ*@e @dI@c@c@b + @a@`@^R@\|`@[@@[S@[G@[ +@ZJp@Z@ZM@YP@Yj @Y-@X`@Xm@Xj\@Xw@X0@Y@[0@\>@@]@@^0@_D@`A@@`c߰@`6 @_c@^0@]w@@[uP@ZdҰ@X0@W@V>@@V@V@V@WT@Wȸ@X@XG2 @XT@XY@XP\@XM@X@X۷@Y]80@Y@Z9D@ZӴ@[0@[ۀ@Zѯ@Zo2@Ya@Yp@XM@UX@@Ud@U@UZP@UPJ@U@U_@V@VG@VQ @VN`@VYR@VR\@@V@V@U`@U2@T-@SN@UI@UBO@Um@TҤ@Tl@@S@Se@R @R`S@R +r@QC@Q@QL@Q @Pd@@PV@P @Pl@Q@QF`@Q@R@RCP@SC @T @T@U@VȩP@WE@X(@Y@[M@@\ @_@`@a=@cIp@dH@e6@f@@g?`@gz@g@@g@fup@ew@@d$ @bP@a +0@^I@[ @YK)@W@@UY0@T 0@Rm@X@Zv!@\Jp@^@_<@`@aL9@aV@ap@a@a1P@`@`p@`N@`B@_U @_V7@`@a7@a@bK@c]@@c@d`@d>@d6tP@c@cY@b@a֤ @``@_f @^#P@\İ@[&@Z@Z@Zi'`@Za@ZN@@Yw@Y @Yp@Y|@YN{p@YD@@YG`@YfC@@YF@Z;@[(@@\5t@]@4@^_ @_^{@`@`f}p@`W@`@_P@^[@]s@\@Z[@Y] @Xs<@WTp@Wz @W^@X*P@X@Yq@Y`J@Y`@Y@YI@Yz1@Yq@Yؐ@YR @Zʠ@Z"`@[ b@[@[@[i`@[~@[ @@Zu@Ye@Y@WI@W p@Vi@U@U@V/@Vv@V+@V@V@Vbp@V~@Vw@Vp@VWP@Vm@VG@Ui`@R@U@Za@Z@Z{@@Z 0@Z@@Zz @Z~P@Z`@Zx`@[-@[]@@[m@\80@\/`@[Л@[` @ZӁ@Z3p@YW@Xa2P@X?0@W/@V@V@Vr@VP @W,@WL@W{+@Ws@W`@WG@W-`@W΀@Wv@VL0@VY@Vp@XJ@WNP`@Vq9@Va@U`@T@T.PP@S`@R`@RN @QX`@Qqa@@Q.H@P@@P0@Pq@PW@Pl@@Pp@P`@Q @QFm@@Q@@RCP@R`@S=@Tx@UO@V(D@V@W@X@Z@[@]@_C@a:p@b@dp@e7@f7q@g~P@g@gt@gbp@f¢@e@d @b0@aY@@_na@\]0@Y`@Wl@U@T. @Y @[ N@]`@_V @`ne`@a9h@@aP@bIDp@bh @bKP0@a?@a" +@a\ @aP@`O@`@@`$p@a@`2@a2 @a@au@b\!@b| @b^$@bn @a@a!`@`{@_@^,P@\͟@[A@Zs@Y(@Ye;`@Y0@Y"@Y4@YC@YF@We@Y>@Yn@ZR|@Z@@[3@[@\<@\0@]`@^_<@_<0@_p@_x@`'@`+X @_@_a@^@]@\@[k@ZP@Z B@Yb@Y'@YKѠ@Y!@Z67p@Z/@[L:@[P@[@[ʁ@[g@[x@[^@[\@[i`@[|`@[fP@[*@\B0@\f0@\JP@[l@[@[ @ZmB`@Y@@X@X1`@W@W6 @W@W-z@W_@W@W@@WI`@Wڅ@@W֖@Wp@@WY@Wƺ@W@W0@W@W@@YĔ@Y%@W0`@Vk`@V!r@Uc0@Tt@S @S@Rf+`@Qۃ@Qg @Ql@P̮ @P|@PF@@P2@P.@@PL @P@P-0@P[@QP@Q3@Rc@S(@SЀ@Tp@Uo=@V#@V@Wʡ@XP@Z@[}@] @`>x@a`@c@dn)p@e`@f[@gb@g> @ge`@g`@R}m@Q@@Qb}@P`@P@PfP@P/P@P@P?@P o0@P;p@Pry @P@P@Qn0@Qk@R@@SLP@S@T݀@Ud@Uٰ@V@W@YRb@Zx@\>P@^0@`Q@b @cO@d@f`@g .@g@g\P@g%@f@@f @dq@cJ@a#@` @]@@Zr@X>'@V9Q@YhZ@[q @]@_B@`׎@a@bO@b@b@bK@bo@bV(@b @ak@aw@@`@@`wr@`u@` @`@` +@`敠@a C@a @@`h0@`5P@_A@_P@^s`@] 9@[@Z@Z@Yc,@Xx@X@@X@Xm@X@XL`@Y}@[@\@[@[۹@\@] @^d@_@_m@`@` + @`'@`9@`p@_ @_X@^ƾ@^3@]@\@\a@[gEP@Z=0@Z\@Z`@Z# @Zqj@Z@[@\;@\ȭ@]0=@]a@]\/P@]0@]WP@\Р@\F@\`@\G @\wD@\zp@\6@\@\@\Q@\1@[|@Z2@Z$@Yw.@@X@XO`@W@W`@Wq@W @W@XP@X@XL,@XX@Xeb@@XP@Y@Z/@Y!@Y`@YK@YL;@X@X~p@WP@W<@V@UP@TXX0@SnP@RTP@Q @QT> @P0@P @PB0@P `@O@@O`@O@O@P,G@Pe@P @Q}@Q@Q@Rn@SDQ@S@T`@U +@U@@Vo@X@@Z@Z=T@\d@_@``@b0@d@ea5@f@@g\~0@g!@g=p@gs' @f@ee@@d-d@bi@a UP@^X@\ x@@Y@WZ@Yp=@[}h@]0@_2@`oP@aޠ@blW @b@@b@b߆@bW@b*^ @b@as{@`Ѐ@`U@`&ި@` @`0@`p@`GL@`\ @`hڰ@`_ @@`&8@_W@^`@^ @]2@\=<@[tI@ZQ@Yw@Y. @X@@Xp@XP@X^p @Xy@X(P@Y"#@Y @Z)K@[@@\!@]>@^F@_0@`*p@`e@`@`{@`k~@`E0@` @_|j@@^р@^$ @]y`@\`@\Zn @[@[A@Zp@Z+@Zv@Zp@Z`@[RM@[D@\@]W@]y@]@]s @]@@]P@]_ @]s@]`@\ֆ@\@\J@\@\$@\@\j@\@[`@ZV@@ZZ@YU@YX@X}@XB8@W쀠@W@W@W@X +]@X!3@XC@XpD@X`@XY @Y@Y@YhQ @Y @YP@Y@@Y`@Y6@Xn@X{e@Y_@V?@T@S?@R p@Q@QI@P@Pf@P!@O=@OnV@O:@O>r`@Oh@OE@P!@@Pa@P@Q@Q{ @RӰ@R@SaP@S,@T)@T0@U>d@V= @Xc+0@X/@@Zsޠ@\Z@_ @a@c@dQ@eڮ@f@@g@g!@@g @g@f)@d<@cp@a@`g/@]?@Z@X@Y^=@[7@]s`@_잰@`@aj@bp@b# @bg@b@@b"@b'@a@a.G@@`@@`;0@_#P@_;t@@_K@_P9P@_`@_@_@_@_#A@^@]@]Cg@\{ @[@[ @ZXnP@Y/`@YM@Xk@@X\ @XMM0@XHϰ@XtP@X-@YX@Z@Ynp@Z0@] @_@`CP@`l@`!@`p@`@`@@`@`bA0@_p@_r@^H@]@\@\@[@@[@[? @[]@Z@ZC@ZԴ@[ @[l@\0)@@\,@]! @]$p@] +`@^@]`@]`@]@@]+p@]`@@]A @\@\Ѐ@\„p@\7@\6@\o\@\ @[ @[+@ZP@YА@YVN@X@X} @X/@W`@W@Wf@Wp@X YP@X/U@@Xi @Xݠ@X0@Yj@@Y5@X3@Z#d@ZV`@Zu@ZP @Y@X@X&b@Y@VFK@TS@S@@R,@Qlp@Q;P@P@PN\0@P<@O@O%@N@N2`@Nژ@O-@Oz@P@PO"p@P@Qr@Qdp@Q֕@RP@R&@SZ:P@SѠ0@T80@TХ@@T@T @X{vP@Zp@]@`w@b `@c@eu@fJq@g2@g@g@gr`@f|@e8@dVo@bZ @aP\@@_~6 @\ @Y@Y P@[Np@]@_ī@`@a`@bJx@bL@b@b@b`@a@akK`@`P@`M@@_@^}@^Yw@^TP@^{*@@^ @^`@^@^ly@^o@]k @];5@\(@\ɀ@[h`@Zt @Z>q@Y@Y`@XH@XP>@X[=@XA@Xyf@X,@@Y|@Zkz@[@[ڰ@^I6@_ʠ@`x@`yp@a)@aE@a; +@`@`ͽ@`kS @_`@^0@]@@] @\|0@\@[`@[Q@[Pk@[(4 @ZP@Z,@@[0@[^W0@[@\3 @\`@]%7 @]xް@]O @^@^&@^ @^p@]e@]ݐ@]K @]@\@\q`@\@\S`@\o@@\2@[0@[u@Zڋ@ZD@Y`@Y'@X@XG@W@W`@Wʠ@W@@Wr@X@X]s0@X@Y@Y +@Z!`@Z<@Z@[8mp@Z⸠@Z~@Y!@X@W1@VG@S4@Td@S`@RO @QG@Q.Jp@P @P="`@O`@OQ8@ND@N@NO@Nz0@N@O @O@O5@P&f @Pqk@P@Q#@Qw@R3@R @R@S=@Sd`@TW`@S@V@Y9@[@@^@ab@@b@d+p@e(p@f@gf2@g:@gc@g +P@f.5@e +0@c?@b1ʐ@`G @^8@@[W@X@Z_0@]({@_Z@@`r@aK@b:@bS@@bhU@bP%@@b@a@aN@`sq@_@@^Ґ@]@]}@]:~ @]C`@]0@]@]5`@]j@]@@] @\@\W@[ь@[L{ @Z@@Z;Ԑ@Y@Y@@XW@X;@X4i@XP@Xt}p@Y@Y̰@ZA@\s @]tP@^P@`2V @`Ұ@a4:P@ai@a@ap @a)"@`@`Z0@_@^Y@@]]T@\b @\ @[@@[<@[z@[^ep@[S@[T) @[?@[F@[ @[ @\;@@\J@]@]^W`@]ȥ@@^%P@^& @^( @^ 0@]۠@]`@]<0@\7@\@@\@\@\@\^@\a@[B@[@[9@Z@Z @YkV @Xw@Xl@X'@W@@W@W @W@W@XH@@X@Y6@@Y0@ZS#@@ZV@[>p@[e]@[#@Z@@Y~@X@WuP@V@S!@S)@SP@R @Q@Qp@P@P' +@OF`@O&@Ni@N@Z@Z-p@Y@Y @Xi@X/`@X@X:h0@X@Y͠@Y`@Zΰ@\RX@@]@_^.@`c@`P@a>A@ao@@a@@ai0@ah @`@@`R@_+ݰ@]x`@\`@\7P@[ܠ@[E@@[^݀@[}@[@[x@[@@[~`@[J@[@[`@\8# @\`@\C@@])@]@]߇@^.@^)t @^a`@]P@]y%@],H@@\"@\Ǻ@@\B@\ @\-@@\@\@\=@[VP@[@Z軠@Z6A@Y[@Y @Xx @W@W@W`(@WX@Wxp@Wo`@X:@XL@Y-@YqP@Zr0@Z@[;P@[{P@[J`@Z@Y`@Xա@W@V@U|@T^@S@@R@QP@PP@Ps! @PCp@O`@O%=@Nx$p@Nq @M,@M@M`@Me@NN@N@NU0@OS @O~@@O@P(S@Pvy@P@QY @Qb@Q@Rn@@SO@Tq@@U@X1@Z@]}@`j@b @cP@e4@fOP@g +I@ga @@gH:@f# @f@e@@cl@bPp@`ʠ@^lw@WlY@Yp@[:@^mp@_@`S@a?v@a@a@a-`@a.6@`p@`@@^ɀ@]@\@[g @[n@[=@@[(`@Z`@[0@[{0@[@[ @[ @[@[@[dB@[@Z3@Z&~@Y@@X/@Xx@X#K@X0@X"D`@Xp@Y*E@Y@[@@\t@]P@_Sm@`YP@`@a2;@a[@aeH@aE"`@aY@`@`@_Z@]K@\@@\@[`0@[u@[@[ @[ @[`@[@@[&@[.@@[@[W`@\@\q%@\5@\ڨ@](@]@]e@]P@]>@]Ȕ@]H'@]P@\0P@\!@\t P@\[s@\h@\@\u@\M!@\S @[&@[e`@Z] +@@YW@Y`@X[8p@W@WpE@@W&gp@WԐ@WA@W@X~@XP@YE@Yn0@ZS0@Zɀ@[?c@[4 @[@Zs @Y}@XB@Wo@Va@UZ@TR +@SV@@RYp`@Qn @Px@Pab@O`@O8Z@N @NB|@M>0@M @MT@MB@MX 0@M0@Mo@N3π@Ng,@Nl@NL@OF@O @P Z@@PN @P@P @Qp@RL@SHN@TP@V`@XP@[|Ѐ@^$ @`!@b @dG@e@ft@gx@g1@f@f]@e~{@dk\@c!@ap@_@V"p@XP@[ [`@]0@_l`@`7@`@a-@aP@`P@` @` p@^{@]@\;@[U@Z@ZA&@Zp@Zk@Z@Z͠@Z9@Zߐ@Zq@[u`@[#@[f@@[@Z@ZZ-@Y@@YU@X@XK@W@Wː@X}P@XuƠ@Y&6@Z@["E@\_@]Ș@_,T@`4|P@`% @`/P@`׀@a@`u@`@`CM@_@_M@@]Aɠ@\P@[@[ @[}@[0@[Б@[`@[Ѱ@[@[Ó@[@[@@[`@[{0@\@\^6@@\0@\w @].`@]@]@]f@]gɠ@]/@\@\v@\90@\- @\g@\(`@\R@\Sp@@\8@[Ġ@[3@[@Zr@@Y@Y@XD`@W`@W*@Vڰ@Vsp@V@W8@Wi@XE@X@@Yk0@Z@ZS@Z- @Z@@Z`@Z@Y0@Xp@W?@Uj @T@S@@Sj`@R' @Qf@P@PDy@O@@O@N(@N`@M@@MaU@M @L@MS@@M @MJt@@M<@M‚@M@MF@N7p@N`@Nս@OR@O"@PEH@P`@Ql@P @P\0@Qx@RgP@S@U@WK@Z@]@`p@b!a@@c^@eL@e@f@fz@fH@f 4P@eD@dG@cP@a-@U@W6@Y@[|K @]b@^Bf@_`@_} @_s @_@^B3p@]Ey@\@ZĒ@Y@X@X @@W(@W"@W +@@WZ@@W@Xj@X@@Y@Yu@YA0@Y\@Yӿp@Ye@Y`@Y@X@W@W&p@WJ>@WNt@W`@@X DP@XH @Y@Z@@[@\=@^{`@_0@_4 @`/@`$ +p@`0@_ @_y-@@^Rp@^\@] C@[0 @[=@[đ @[`@[0@\@@\-@@\%@@\>@[ @[8@[uX@[@@[9@[6a @[i>@[J@[& @\+@\X0@\@\P@\`@\@\-b@[@[d@[3t@[2@[P@[@@[a @[@[?@[] @[ +.p@Z@Z4p@Y~%@Xp@We@W2p @V0@VH@V2@@V-@Vv@VP@Wm@W@@X{@Y@Y/@YƟp@Y7@Yp@X@X@Wp`@V@T@S@Sj@@R0 @QkP@P00@Px@Mg@NP@N@N8@M΀P@MJ@M1@LK@L.@Lr@LpY@@Lk@Lo@L4@L@L|c@@LL@Ln`@L @LK@Mk@NP@N{@Oz@Pp@Q@R@@T" + @V3@X`@[rP@^`@a@be`@d8@eU\@fU5@f|@f4p@f@@e@d@cB@@b[ `@Ux@WN`@YG`@Z@\@]&0@]&P@^ +E@]@]|`@\;@[@Z@Y:@X @Ww@Vj@V~@Vlt@V{\@V@V@W?0@Wk @X!@X&0@XJ`@XO@@YP@X`@Xs @Xdh@W@Wf'0@W @@Vޏ@VS@@W-@@Ws@XS `@YH7`@Z+8@[&@\G@]W(@^#@^@_ݐ@_# @_p@^@^W @]@]-@\`@[`@[,@[@[@[W@\ p@\!@\@[@[@[@[@@Zg@Z@Zץ@Z@@[7@[W@@[LP@\W@\@@@\=@\'P@[@[w @[)5@@Z@Zg@Za@@Zi' @Z@Z@ZX@Z@Z @Zz@Z3o@YQ@Y, @X_d@@W90@V@V@>`@U4@Uy@U(@U@@VXv @V3@WPp@W@XP@X`@X6@X@XP@X @W\i@Vg @Um@TaF@Sr @R3@@Q{@Q@PkP@O)@O@Oİ@N<@N(6@MM@M@M `@Lp@L@@LY@@LG@L@@K<@Kr@Kѐ@K0@K@Kظ @L$y@L5D`@LwD@L@M@Nv@PO8@PL`@Q @Sn@@Tɠ@V?0@Y@\c0@_`@aqs`@c@d}@f>| @f8 @fU5@@fPP@e2@e0@dD@c@@T@Vb0@X^T@Yƍ@[@\ @\@\@\s @\@[!P@ZP@X&@W`@U,@U@U@UW@UFP@UP@@Ub@U @V<"@@V @WC@W%@@X`@X<@X4@X/Ґ@W@WP@W@]`@V^@Vf2@VLn@Vk8 @VK@W9n@W@X `@Y0@Z`@[Ap@\Z@@] @](@]؏@]F@@]s0@]^@]3@\ @\C @[N@@[zE@[]@[gU@[#@[@[C@[@[@[@[hp@[K$@[_@Z`@Zg@Z__@Zu@ZK@[0@[F@[P@[#@[@[D@[56@@ZE@Zy @Z@@Y@YM`@Yp@Y`@Y@YV@Z)'0@Zt@Y``@Y@YC@X@X"@WG @V@U@U@UH@@UP'@U[(0@U^@@V"@V.@W@Ww`@Wڭ@XJ@W +@W@W*^@V|^@US@@T@S@Rz`@RM@Q2@P@P0A`@O@O: @PuZp@ODQ@N0@M0@MŐ@MZ@L@Lк@Lr @Ll@KJ@K@Ky @K^P0@KA@KC@K9@K9@@Kt`@Ko@L@Lw@M,@NJ@P$Y@Q@@R,K@S@U-@Wd @Za@]@@_u p@a @c@0@e'@@e_P@e5@f,K0@fp@e`@d@c@TI@V,@W@Xd`@ZN@ZP@[EL@[L8@Z@Zg@Y`@Xn@Wg@Va`@T@TP@Tbf@@T+ @T.@TD!p@Tz@T @U6[@@UZ@VNn@V`@W @WN@@Wd@WZ̀@W$j0@V5@@V~u@V" @Up@U@U@V0*@Va@WP 0@X @X@Z+(@\ @[y@[d@\Z܀@\}?@\n4@\8s@\^@[@[[@[P@ZO@Zt @ZmP@[3`@[?;@[|`@[~@[p @[,@[s@[ @Z0@ZVW@Z@YrP@Y-@@Y枀@Z0@Zp @Z&@Z`@[@@Z@@Z؀@ZV@Y`@YY@Y&/@Xĝ@X@X@X@XP@Y@@Y=1@YSW@Y@ @Yp@X@@X>@@W @V}`@VD" @U@U6 @T͐@T:@T1@Tp@UQ@U@V)P0@Vp@VՁ@V @V@V@V9@U#0@T]`@S @S0@R=Qp@Ql@P +p@P+@O"0@O@@N\@N`@ND@NBiP@MX`@M@p@M@M20@L.@Lyj`@L@K0@Kh@K@Jߐ@J@J @J3@J`@J@@J[p@KNt@K4@Lh@@MI@OZ@PI@QN@R@TM@@VS@X+@[@\@`5 @b@c>@c@@e3*@eA`@f @e-@e0@d<]0@T_@UT@Wm0@X$@Y +0@Y@Y@@Yπ@Y@X`@W@V @Up@T @T30@S@@S@Su@S@S@@Z+#@Zw`@Zp@[`@[?@[R@[H3@[n0@ZR@Z18@Y @YZ@YH@Y5@Y>Z@Yq@Y< @Y@Z @Z'`@ZI@@YՀ@Yo(`@Y@X@X1`@W@W~@@W@@WՍ@W/@@X+@XFm@XwB@X{@Xfy@X"@Wˠ@WAx@V:@@U@@UV/@@T@Tw @T6@T4@T/5@Tc@TP@U0 @U@U@U@Uڽ@U @U2@T@S{0@S3.@R`@Q@P?@PP@OX@N@N@No*@N00@N @Np@M`@Mω @M̐@Mr@M@L>@L&`@K@K(d @JҎ`@Jr`@J%Y@J*D@J0 @J`@J +4@JL`@JT0@K6 @K@L@N@Oz`@P.@Qm@S!`@T@W<ð@Yذ@\(@@` yH@b@9@b0@c) @d@e`@e @e@el@d=@T-@UT#p@Vd3 @WS@X @X@X^@X`@Wp@WG)@Vi@Ur@T7@SP0@RW`@RP@S0@R@@R9@RF @R@Rm0@SE@S0@TC`@T@U82@@Un0@U@ @U@Uf@U9`@T@TM@@T@@Tw +@Tr@Ui@U@V A@V@W4T@W @Xe`@W 0@Zp@Z?P@Y{@Yp@Y* @YA/@Y P@X7@X@X@X @YK @Y@ZP@Zryp@Zu@Z @Z@@ZrϠ@Z@Y@YGt @Xlp@X@X_@Xs@X@X``@Y@YA@YF@Y4$@X@Xw@X8@WA@W$`@V@V@Vp@V`@V@@W0`@WP@W@W@W@W@WIh @VC@V?|@U @U @To@T*@S0@S@Sz@S @S`@T$@@T@TUp@T[@@T@@Tn@T! @S@Rxp@RD @Q +@Q @Pfa@@O@O@NQ@NCe@N@N'@N@N*a`@M@M`@MY@@M@MP`@Lk@LR@Kf@K +@J`@J4p@IP@I0@I`@Ih@@I=@I @J*`@J`@KX+@L3yp@MO @N@@Ol@P㮠@R+Sp@S@U@X%v@ZY@_i@a@b'>@cAA`@dh@eFP@e@@en @eQ @eR@T +@@UZ@U@V@W,"@W}@W@W8p@V@U?@T@Tϰ@S@@Rt@Qz@Q@Q@Q=d @Q1@QI@@Q@Qb`@Rdp@RR@Si^@SW@@TLqp@Tr @T@T@Tg`@TdP@T3@T +/@SԠ@S@T @Tpi@T@Uhv@U@Vl3@V0@WuI@VP@UP@X2@@X@X@@XW@X =@We@WѰ@W@W @X @Xr`@XP@Y;tP@Y@ZP@Z*@Z@Yj@Y@Y!@@XF@XT@X1@X @W @WP@X!@@XK@Xsa @Xoa@XNp@X`@W@W@V`@V 0@UD@U0@U@Uݼ@Up@V/@Vq0@@V0@V`@V@V@V@Vc@Uὠ@UUc@TF@@T< @SO@SLp@RE @Rɼ0@Rǟ@R@S$ @SnV@So@S@S,@S~e@@S@R@R*h@Q@QP@PpԀ@O#@O$@N@N4 @MTP@M*@N@N0@NJ00@NY@N=Y@N@Mi@M|&@M@Lr@K1@J@JS@J@I@IR@@I<@ID_@I^@Im@I2`@J<ߠ@J @K@L4@M@OP@P7 @Qb @RP@Tt30@V@YD@\,0@_7@a @bz`@c!@d@eu@e @ep@eM@S`@Tr@@Ui@V CP@Vh@V@V_p@U`@UB @Tp(@S&@@Rˢ@R0@Qz @P@@P\p@PzK@PU>@Pc@Pt@P@Q@Q`@R@R@RW@SK @S@S@S @S\@Sѐ@So@SJ@@S;s`@S^O@S@S@Tc@Tٛ0@USU@U͟@V-f@Vp@V7@VE@V+\0@V@WX@W @V@V`@Ve@V@V@W/p@Wp@XR!@Xn0@Xy0@YL\@Y@Y@Y<`@X1@X@X()`@Wȁ@W@Wp@W6@WAg0@W\@W}{ @W`@WL@Wp1@W)@V@V"L@UGp@U 0@T§@T,@@T p@T{@T @U2@Up@Uɐ0@Uz`@V4v@V\@V+@Uᜠ@U~`@Tu@Tq)@S@SZk@RӀ@Ry@R*|@@R n0@RP@R8P@Rq@Re@Rop@R@Rw@@RHR@Qo@Qsp@P@Pny@@P @O=P@N @N  @N@Mi@M@N@NRF@N`h@N*@Nx@Nh @N8`@Mۨ@M.p@L@K@K@JP@I)@Iq@I=@@Ig@H @I@I @I0@J @@J@K=c@LP@M"@NP`@OI@P@Q\@Sc@U7@@Ws[0@Y@@\g @_0@a@b @d@dʠ@ewS`@e|@ewӠ@SV`@T90@U. @U@U( @Up@UM@Tv@TyP@SI@@R&@@Qv@QP@Pr@P2 @O8 @O0@Oo @O=y`@O @OY@PN8@P<@Q-5`@QP@R<@@R=@Rf@@R @R @RG@R1@R@R`@R:@R0@Sq @SHP@SR@Tc@T @U`@Rɀ@Rs@S@Su @S΢`@Tk0@T-C@TP@TEzp@T60@T@S"`@S@SE@RL0@R@R$`@RA@S;X@SJ@T@U@UwO@@Uw@U`p@U@V:`@U`@U@@U}@UFa@@U@T*@T`@TC@@TNd0@T[\`@T`T0@Tj @TN@TY@S՞ @Sw@S @R@R$R0@Q@QY@QW% @QF4`@Qt@Q0@R$g@R@S @S @T@T@T*B`@T +@S@S8_@@R]@R(@QSl@P'P@P@OQ^p@N@@NjT @NT @Nm@N@@Nr@@N @NFP@NJː@N@M:@M'@@M@MZp@MC#@MZ@Mmސ@M@N6u@@N@N`@N=`@N:@@NT@ND@Nm8@N`@MX@L`@K@@J׭@I慰@ID@H(@Hj@HJ@H(o@HU"@H@Hr@IdA`@I @JJ@Jo@KY@KQ @LN@M~@@O$@PY0@Qg@R; @TE@VC@X0@[CC@^2@`p@b`@b@dp@ef`@TG`@T=@Tj@@T?< @SK @R@R$+@Qs@Pp@O4@N?@M@MH@M@Lۆ`@L @Lk0@L4S@K$@L@Lp@L@M @Mw$@Np@N@N@O3@OcA @O@@OQ@P@PW@P@P@Qp `@QI@RZ @R'@S[]@S@S@S@T@T@SǦ0@S@SVP@Rʠ@Rp@RB@@R@@R@R@RdO@R@S)2@T<@U{@T2@T̀@U&@U2X@U.İ@U0@T@TP@TC@T@S@S3@S@S@S@SB@@S-@Sn@S5p@R`@Rg@Q@Q@QMo@Q@PW @P>@P@Q)@Q@R@@Rl@S +@S@Rδ@S3@@Ss@S^@R^`@R^ @Q~`@Q`@PHp@O\@NM@@M@MV@@My@M-@MI@@ME,@MAAP@MV|@MW/`@M3@M*`@L^@L۔@M@L@Mx@MH_`@My@@Ng@N@N$0@N@N0@N@N@NIˠ@M`@M! @LL@Kn@J @I΀@I @Hx2@H@H")P@H" @H8L@H@H@IPW@IӾ`@J @J0@J@K @L65@@Mb@@NV@@O@P2@Q `@S/@@Tp@V@Ymn@\"j@_!Ҡ@a @@bb@do +@e`@SJ0@T& @T-.p@Sop@S. @R} p@Q0@PŇ@P@Nfp@MK@M@L @Lb@L| @L:Z`@L@K0@K;@K`@K&P@L.@Ly P@Lc@M&@M}`0@M@MP @NQ@N`@N8@Oi@P>P@Pd@P0@QVP@Q@@R\I@Rw@SWϠ@Sa@S@@S @Sr@S͌@S@S2@Rʀ@RP@R@Q@Q{% @QhC@Qu׀@Q-@Q@RN@R&@@SX@Sԝ@T @TE@Th1p@Tg@TVkp@T$@S +`@SD`@Se@SA@S @R @S @Sp@S +S@@S XP@R @R@Ro@R @Q@QLĠ@PP@PH@PAp@Pm` @P@P`@Q$E@Q`@R"p@R@S @QC@Q +i@S#ʠ@S m@@Rk@RN`@QZ@PP@O@N@MĀ@L@LV`@L<@L8@L*@@L.+ @LU݀@Lp?@LI`@LLZ@LYT@LEO@L@Ld@Lp@L@LP@MO@M`@N3S@N@N@N@NJ@NQX`@MP@Mv@L-@LM@K!@@Jg!@I@Ho@HUP@Gu@Gp@Gà@H/@H@H. @IE@I7@J D0@JI`@JP@K[ @K?@Ln@M@N @P)L`@Q"@R@@S`@U@Wp@ZH?@]P@`@af@b@c@@Q@R0@S-@Sop@R*@@R@Q/@P;@O$@NF@M$@LU@LO@L*F@L7\@L@K@K-@@K@@KjC@Kp@K @Kܠ@L3&@Ly@L p@Lף@M@Ms@M춰@Nc@N@OP@PZ@P @Qe@Q@R=@S +@Sp@S'@@S~@S@S.@S`@S]B @RN`@R(@R6@@Q@QIn@Q @P9@Px@P@Q9Ƞ@Q@Q@R\@R@S1 @S\#0@SҀ@S@@SP@S_@SQ@Sp@Rɉ@R@@R@RP@Rxp@R5@Rx@R@R}up@RM@@Rc@@Q"@Q]@QA @P@P^@P@@P20@P8-@P@P @Q3@Qp@R9i@@RP@R'@S@Su@R͐@RD@Q@@PT@PE@O5@Mͥ@@L\0@L"Р@K@K@K@KX@K\@K}6p@K#@K @K{R`@Kc@K0@K@K@Kp@L +@L{@M U@Mw@Mz@N@@N0 @N1@NGD@M@M@M`@Lm)p@Kp@Jc @J@IS@@H@G@GP@G/@@Gי`@Hp@Hq@H@I/J@@I@I @IҠ@JF@@JX@K*@L.P@LD@N`@O]M@Pm@@QfP@R@T2@V) @Xp@["S@@]@`_@@a(@b~@SK\@Rf@S`@S+_@R?O@QzZ@P0@O” @NI@M!@L@L@`@L@K@K@L@K1@K;@@K@Ko%@K^R@Kl7@K@KV@Ku@K @L-K`@Lt0@L1@M]b@@N@@ND@Ot@Pj@QG@Q @R:0@R̠@S?@S@S@Sܵ@SE@S@S@S:@R @RoW`@R +u@Q?@@P@P`@Pp@PWQ@Pu@P@P[P@Q=@Q@Rw @RcI@R@R@R@@R@R[`@R3@R@@RZP@RA:@R5`@R+Ѡ@RE@RQ @R[@RR@@R?e@RQ@Q@QO0@Q8@P`@P{q@P,4@PR@Pv@P@P3@P`@Pܽ@QX@QΊ@R)/P@Rr@S `@U&`@R`@QH@QL@PP@OƟ`@N|`@M@L@Kq`@J@J @J%@@J@J@J + @J@JD@JC`@JM@@J@JŰ@K7@K\^P@Ks@L @L`@M Ұ@MV`@MP@M2p@M@M@0@M@M@L`@K=@K7@JL@I-@HT@@HE@G@GZ@GH@@G&@H@HV@H@0@I p@IZ0@IC@I@Ib@JH<0@Ji@Kx@Lh@MD@@Na>@O@@P`@Qn@S@T@V@Y>p@[.@^@`P@b P@SP@Sw7@SI @R@Q@QP@P>&@O`@M @L@L@LT@K*@K{P@K @KdP@K7@K@@Kp@KjF@@KM9@K;V`@K?P@KJk@KjF@KeB@KVP@L@LU"P@Lh@Mڸ@N@OQ@@Pp@QY]@@Q@R@S(@Su@Swp@Tf@T@@T9@SP@S}f@S"%@R@RJ@Q @QN@P>p@P`Ӑ@P%@O0@P@P* @@PY`@P"@P轠@QL@Q@Qy0@R) +@R^b@Rc@Reɐ@RX@R= @R4`@Qe`@Q@R@R +@R@R# @R'@R@Qp@Q@Qy@Q3x@PS@Pt@P0;@O@O@@OP@P@PQ,P@P@@P@Qp p@Q<@R@R:b@RU`@R*`@Q@@Pu@P<@O@M`@L@Ky@JĿ@Jo3@J.@IP@Iw@J @I̐@J @J_`@J4P@J6Y@J0@JI=@J||P@J@K @Kz@K`@L~ @Lp@M @MIt@MO@MUtp@M"а@Lk @K@KY@@J@Iߔ@I!7p@H`@HH`@Gs@Gj@Gf@Ga0@G΀@HG@H@H @I<@Ia@Ix@IV @I0@JLA@J@K @L#@M@Ns@P p@P@R`@SJ@Un@W@Z@\[ @_@a+@S@SrP@S @Rcp@Qj@PY@O" @NTj@@M`S`@L @L_@LҀ@K@LV`@Kⱐ@K@K@K@@KF@Kw@K. @K3@Ka0@J@K @K@K?p@K@LY @L$@Mj@N @P(B@P# @Qo@Rcp@SX@S@TT@TD@TQp@T[w@@T2b@S&`@S@SӰ@R@R*!`@Q@Q n@P@P.\@O@Om@Og@Oap@OP@PO@PZ@@PyP@Q@QM`@Q@Qڮ@Qu@Q@R`@Qp@Q+p@Q@Q0@Q@Q^0@R @R @R @Q @Qm`@Q@Qu`@Q@@P؀ @P`@P88@OP@O@@O@P@P&7@Ppѐ@P{@Qhp@Q`(@Q@Q@Q- @Ql @Q@Pɠ@O@NeP@L@Kx@@K 0@J_s @J@I@IV @IZ@I@I{z@IK@Ip@I@I0@I&@I'@@I郀@I@JY@JV@KL@K녀@LH`@Lv@LM@L@LE@LP@L(@KY@@Jp@IP@IL@H @H)@Ij0@G@G@^@Gm+@Gb@G8@HKŀ@H@Hʤ@HY`@I3(@I7 @Iu^@I@Im0@J@KH@K[@LMp@M?@O@P=@Q( @Ro@TS@U@X* @Zͪ@]@`B@Ss @Sp@R@R]@Q @Pg@Pbx@N'`@MP@H|@Hѐ@H@H@IDP@If@IG@JIa@K@ @K@L@M@OK@@Pe`@Q{@S@Tݰ@W/%@Yf@\@@R2@@R@R U@@QZm@Pǰ@Oo`@NB@MD@@L@Ld +@LCf@L@L>r@L  @L +f@Kݽp@KM@Kn @KNL@J@Jɺ@J@JP @JR@JT@@Jn @J@Koƀ@LG`@Mk~@N@@PDt@Q60@R5@Rv@S@TQ@Tg@Tx@Ux@@Uvp@T@Tp@T @Sp@S$P@Rh@RF@Q'@Q `@Pr:@Oq@Os@@N@N6P@Mq@M@M@N@NdG@Nl@Oh@PK@Pp@P@Q&@QL@@Q0@Q`@R@Sx(0@Rh"@RTL@@Ri @Ru @Rq^@R_6@@R;y`@R S@Q@QxP@Q@PǠ@P\@O @Oq@O0@N3@Oi@Op@O:@O@O@@P2@@PO@@PPmP@P$@Ox@N@M.p@L|@KU@Jp@IcP@I7@I@H@H@@H{ @HN@Hy"@@HUŀ@HXi`@H]@H;bp@H:ܠ@HL@HQ@Hp@H߱P@I7`@Iv@@J@J`@J`@J@J@Jh@Jj @I4@Ik@HŤ@H:P@GR@@G5 @F@FȐ@E@FPȠ@G/`@G"@G%@HEp@H}@H@H @H> @HP@HiP@Hh@Hh@HV@@I.$@@I6@JS@Kp@K(@Lɝ@N +@@O{@P 0@Qf@Sɠ@UG@Wt@Z @RJ@R>@QG0@Q  @PLٰ@O + @Mx@L`@Lpg0@L@4@L0@Lp@K@L @K@K@K]@Kf@JI@J@Ji@Jm@J<2@J@J+ @J@@Jك@K#@L @M@@OU @P0@Q@@R0@SY0@TA@@T7 @U1@U40@U9p@U@T@@Tk0@T {@S@S(@R@@R(0@Q@Q @Pmp@O@O@NT@Mu@M@@Mg@MH`@Mv0@M}@NB@N@O@P>@PH@Pm@Q)@Qa@Q@Q@R2@Rx @Ru`@R]@@R0@R0@Rl@Rj`@R2gP@Q@QK@Q)@Pʀ@Pc@@Oˠ@O8@@N@N] @NTI@N^@N`@N p@O@ODg@Ogp@OT @Ohy @N @M%@Lb@@K@JM@J  @IzP@H@HP@H{}@HMې@H_P@Hf`@H9@H,`@G?@G+@G@GDZ@G0@G㥠@H8P@H`@H0@I,P@I@@I@J @JU>@JO3@J@I@IWi@H@H2@Gn@G;@F"@FmsP@F\p@Fue @F+@G90 @G`@Hp@Hc@@H@H0@H@Hf@H@G͠@G@H &p@H4M@H@H@Im@J@J7@K@L1@M@@Oʰ@P @RS@T@@V90@X0@R[|@Q׀@QX`@P@PƘ@N@Mv@LA@LXQ@L"q`@K @KMP@K@Ka@Ke3@K. @JǠ@Jp@JHg@JBO@Js@J \@Jk@Jg @MRd`@KIհ@K @L@M9@Nc@PP@Q J@Q @RI@S`@Tc@T`@U/d @UUx@U;R@U%@T0@Tu@TS@S@@S%@RS0@R3!@@Q@Q&O@P @P@@O(P@Nm3P@M@MTC @M@LÏ @L @M@MX@NLLP@N0@O@PAk`@PՀ@Qp@QL@Q@R +@R@Q@RvE@Sq@Skp@Rs`@Rޕ`@R@RYP@@R@Q@Q1@P͛@P`@O-@Nv@NS@M@Mg@M@Mz @M@Nܰ@N9 @Ng9P@N@@N~@Mb@M-r@LIp@K@J @ILJ@I>b`@Hq @H@H@@H$0@H:`@Ht@H @Gp@GN@Gq@Gj `@Gu @Gn]@Gm&@@G@Gvp@HoU`@H`@I@I0@I@Iʗp@I1@I`@Ij@HN@HDP @G@G @F@Fd@F@E@F\ @Fʀ@G1@G@H@Hg@H;@H@Hn@@H@GH0@Ge@H @G@G @G@H8=@H@I0@Is@J@KzP0@Leр@Mے@O@Q$@R@T @W@Qw@@Qp@Q @Pi@Oz&@N'@MAgP@Lvڀ@L,e@Kv@K@@K~@KHP@Kg@J;@J\p@J{ @JQE@I@@I`@I@I@I] @Ip@K@LJP0@KuO@LJAP@M @N㥀@P[V@QO@@RA@S5@S @T@UI@U<@U[@UBk@U`@T-P@Tb@@S@S@S` @RՐ@RȐ@Q>@Q5TP@P @P1@O;@NY@MФ@@M2@Lǭ@LsA@LT-p@Lp@L@M4@Nn@N@O @PlP@P@QI @Q3@R!k@R@R<`@RUp@S^@S_V @SM @S%@Rۋ @Rw{0@R@Q@ @Q6T@P @P1y@Oz@N-@Mm@ME@L`@LA@Lg@Lu @Lm@MA@Mp`@M"@@M@M3@LP @Kπ@K @JAː@I@I@H@Hi*@H+n@H@G@Gb@G@G@GK@GX @G>@G1@G @G H@@G*X@Gt@@GϠ@HfP@H@I2P@I>0@I>@IF @I#p@H`@Hbp@Gˁ@G<@F@FM`@E @E<@Eæ@F1p@F߀@G2(@G@H5@Hi@Hy@H@H7@G؞@G"@GH@Hqd@Gp@G>@GL@G+@G8@H0 @H*p@I@JV@K60@LCq @M@P8@Qn`@S3J@Uw @Q@Q>=@@P@P"S0@O +@M`@Lٰ@LNP@K`@K@KN}@K[@J@J @Jk}@JIn@J@I'@I`@Iqp@Ih0@Io]@IǠ@I0@JG@HP@JP@L@N@O0@Po`@Q@@RB@@Sp @T* @T>@Up@U:@UJ+@UA@T @Tz@T@SĠ@SKEP@Rsp@R(0@R @@Q@Q>@Py@P;p@OH0@Na_@@M%@L @Lu@LV@LC@L)_@L^p@M7p@MA@No1 @OK@@P8@P̀@Q5R`@Q0@RCE@RĨ@SAR`@S~ @S@SΉ@S{@Sp@S@R-@R#@QҠ@Q"V@PJ@@P[P@N柠@Ne@M2P@L `@L=0@KXP@Kq @K0@Kp@LED@Lw@L@L @Lj@Kޮ@@KBT`@J@Ik`@IAp@H?@HhJ@@H6@H @Gj@G@G~@GQH@Gb@G;N@F+@@GH@F@F@F#@F@@G@G@GL@HS@H|@H= @Hw@HG@H@@H_(@G殠@Gm2 @F`@FX@EW@ED@E5 @Eѐ@E&@F@G@G1@H9.p@Hq`@Hw@HU/@H@G>p@G6@Fd@G> p@Fo@F( @F@@F@GY@G^@@Ge@H@I%@J@K@Lp@Nf@@PS@Qp@S5@Q@P⥐@P`c@Op@N|@MV@@L[ @L@Kw|@K4gp@J@J@JCt0@J\ @I`@I@IV@@I9P@I@@I@I4@I&٠@It@I\@JT@Io@I$ɀ@LOp@N> @OT@Po@Q0@R@S{U@T(\0@T@Uj`@U%@U?@T*@TtZ @T @SƖ@Sx@@SB`@Rg@Rx`@R-`@Q@QCP@Ph`@P90@Ol@NY@M@L@L2Q@K`@KÇP@K^@K @LWL @M@M޽@N'@O@P@Qݐ@Q@Rdp@R@SsR`@S@T@T,@T +}p@S@SE@Rrp@R*'@Q@Q R@P`@O,@N;`@MF@LrX@@Kp@K^ @J֕@J0@J'@K Y@KTc @K@K; @Kp@K`@Kf@J@J-@I9 @I&a@H@H6@G΀@Ge@@G)@Gj @GC@Gǀ@G `@G@Fp@F@FuG@@F-E@FV9@F@F'@G!l0@G~%P@GI`@HBp@H}@H@Hz @HVy@G@G@F|@Fנ@F@E@E}@Eb0@EE@Ep@FU@G Z@G8@H +@Hj@Hr@H*h@G;@GU @Fư@FG@E @Dt@EG@Ff'@Fev0@Fp@F?@FU@@G|0@H@HW @I=`@K&Р@Lԏ@NP@P@RP@P@Pj +P@P@N@@MP @L9@KL@Kup@K2@J@Jj@JI@I@IS@I@Hփ@@H@H~> @H @H`@H@HT`@Ic@I@Jlc@K@Kj@Lg@NH@O~@P@Qv@R@Sd9p@S@Ts#@T@T @T +@Thq`@T1@S @S^`@S0@R`@RP@RJN@Ra`@Q @QH@PP@P9`@Of@Nr:@M@L`@L.@K{@K\@K;B@@K|@K^@Lt@M<@N/@OK0@P[@QV@Qp@R}(@S 9@S`@T@TT @Tb@T@@S@SZ`@R @RP@Q@Pp@@P @NF@M!p@L}@K@J@JU @JC @Iݎ @I!P@J?@@J@Jֲ@K! @K5@K%e@J@J@@J2@Iyt@I֐@H@H1m0@G@G@Gt<@G4"@G@F@F@F@F\E@F!@F@E@Em@F%=@Fkp0@Fʶ@G@Gl @G@H@H&x@@H:5@H@@G @G;G@F@F<@E0@EjO@E7/@E3@ET@@E@F8@Fշ@G{@GD@H*@H/@H + @G!@G`@F@EJ@J @Jh@JP@KV~@K/ @L@M€@N@P@P@Q@Rw @S1@S@T4>@Tu1P@T@TW@S@S[P@R@Q`@Q!+@Ps@OTp@N&@LV@K@@JӠ@J <`@Iq`@I@@H:@I@I] @I @J)@JK@J{p@J@JeӠ@J&@IS@I~@H\`@H@H/@G@G@@Gg<@G@Fޑ`@F@@F_@F*@EĐ@Eʳ@@E!@E0@E<@Eܐ@F i@FiP@F/@G;T @G|@G%@G`@GJ@Gӧ@G@G$@@F@F i@Ep@E>7@@E@E3@@EI@E0@F"@F.@GJ @Gp@GP@GÀ@Gu@G^`@F@F2@E@E8̀@DP@EP@EV0P@DP@Dr@E\@E@EԐ@F@FA0@GKP@H9n@Ip@K:@MT @P G@@O@Np@N)@@MU%p@Lk @K@JU@J_@I$p@I[@Ig@Hڜ@HP@H@Gq@G@GqM@Gqc@G @GӀ`@H.@HB@I#@I @J@@KI @LGP@MCL@N|`@Oe@@P@Ql@@R@R@S@SP@S@@@S @S#@SS@@S`@R/ @RF@RƐ@QV@@Q붐@Qƴ@Q@@QiW`@Q@P0@PD{@Or@NSP@M=@Lrn@K/@@K`@Jg@JN@JG@JP@KS@L@M4@Nub@OT@P~@Q`@RyC@S0@S4@TG@T0@T@TGR@@Sܱ@SFѐ@Ru@Q0@@P@Pb@Nǐ@M7@K@@J͏ @I @I @HP@HCe@H0P@HBp@Ho@Hp@I?@Iq@I@ID@I@@I@I`@IGfp@H@Hm@@H@G@@G]; @GQ`@Fd @Fɀ@FL@F"@Ec@@Etp@En +@EDb0@EY@@EK@Ex@E@F@F{@FP@Gh@Gt@@G@G@G@GH@@F@Fp@@E @E@@E@Dۯ`@Dp@E @E`0@E֕@Fi@G@G@G7@@Gb @Gp@G@Fv€@E@E+@Dՠ@D @Dy0@DhB@C@DM@Dؐ@DS@DP@E5`@EWp@F@Bp@G:@Hр@I@K{@M@N@N @MF@@Lv@KDp@JUp@JC @I<@I\@I@H @H@G&0@Gp%@G&r@@FM@@F@G@GP@@GwE@H@H@I(`@I֧@J@@KT&p@L7j@M6@NJ@O`@PQ @Pp@Q@Q@Rw@R׏@RK@R'`@R~@@R@Ri@RR@Qhp@QP@Q@QK@Q @Qc@Q-%@P`@P@P@OD@@N,@MKP@LdԀ@K{@J7@JE@I'0@I2@@J Ҁ@J+0@K @L@NP@On1`@P:@Q? @R]P@S @S«@T@@TbB`@Tk@T;@Sv@R؀@R!`@Q:@Pb@O p@M8@LS @K ;@I @H@H<@G@G1P@G_p@G@GS@H"?@Hs@HT@@I @I= @IFv@IZu@I8.`@I1@HА@HKo@G:@@G:@GT/@F @F0@FU@E@E@@Ex@EQ@E @D" @Dx`@D@@EGP@Es7@EL@F'@F@G)J@G@G@G@Ge0@G@@FB@FP`@Eǰ@Es @E@D&@D@D@@E.v@E@F&O@F`@G9+ @GfO@Gq@G#&@F@F +)p@Exv0@D0@DX| @DU@DP@C@C@C@C@D0@DH`@D|f@D\@EJ@EĐ@F@H + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647https://dataportal.eso.org/dataPortal/file/ADP.2026-08-19T13:19:07.647 + + #thisRequested fileimage/fits8458560SPHERE_ZIMPOL_Betelgeuse_P1_N_I_phase3.fitsSCIENCE.IMAGEhttps://archive.eso.org/datalink/links?ID=ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647 + ADP.2026-08-19T13:19:07.647_soda + #cutoutCutout of #this (SODA-sync)image/fits + + + +
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647https://archive.eso.org/previews/v1/files/ADP.2026-08-19T13:19:07.647/preview?format=DOWNLOAD + + #previewPDF preview of the requested fileapplication/pdf1000000 + + +
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647https://archive.eso.org/dataset/ADP.2026-08-19T13:19:07.647 + + #previewHTML representation of the datasettext/html1000000 + + +
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647https://dataportal.eso.org/dataPortal/file/ADP.2026-08-19T13:19:07.648 + + #auxiliaryAncillary file associated to the requested fileapplication/fits8455680SPHERE_ZIMPOL_Betelgeuse_P1_N_I_DOLP.fitsANCILLARY.IMAGEhttps://archive.eso.org/datalink/links?ID=ivo://eso.org/ID?ADP.2026-08-19T13:19:07.648
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647https://archive.eso.org/datalink/links?ID=ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647&EXCLUDE=ALL&INCLUDE=progenitor + + #progenitorLink that brings to the actual list of progenitors (processing provenance)application/x-votable+xml + + + +
ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647https://archive.eso.org/datalink/links?ID=ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647&eso_download=data_documentation + + http://archive.eso.org/rdf/datalink/eso#data_documentationRelease description of the collection the requested file belongs toapplication/pdf1000000 + + +
+
+ + + + + + + + The publisher DID of the dataset of interest + + + + + A circle (as a flattened array of ra, dec, radius) that should be + covered by the cutout. + + + + + + + +
diff --git a/tests/fixtures/telescope-vo/eso-obscore.xml b/tests/fixtures/telescope-vo/eso-obscore.xml new file mode 100644 index 0000000000..c4e9353f61 --- /dev/null +++ b/tests/fixtures/telescope-vo/eso-obscore.xml @@ -0,0 +1,234 @@ + + + + + + +ESO-specific field not present in the standard ObsCore. 5-sigma limiting AB (Oke) magnitude. The quoted magnitude should refer to the total flux of a point source. Applicable to the following data product type: cube, image, and measurements. + + +Estimated size of the downloaded file in KBytes. + + +The format of the downloaded file. + + +A URL that points to the DataLink (VO Standard) service that is used to download the dataset, associated files, their provenance or derived products, etc. + + +URL or bibcode to the main publication that refers to the data set. + + +Calibration level: 0-instrument (raw) data in non-standard format, 1-instrumental (raw) data in standard format, 2-science ready data with instrument signature removed, 3-more highly processed data. Ref. PRODLVL keyword in ESO SDP standard if present, otherwise: set to 3 when dataproduct_type contains token deep or tile, set to 4 for catalogs. + + +A data-provider/collection specific secondary type, e.g. srctbl or catalog for measurements, tile or pawprint for VISTA images, etc. + + +Logical data set type (image, spectrum, visibility, cube, measurements, etc.) + + +ESO-specific field not present in the standard ObsCore. The original ESO identifier of the main science file. ObsCore uses obs_publisher_did, which returns the full IVO Identifier. This field can be used to join with other ESO (non-VO) tables, like provenance. + + +Maximum spectral value observed, expressed in vacuum wavelength in meters; ref. WAVELMAX keyword in ESO SDP standard. + + +Minimum specrtal value observed, expressed in vacuum wavelength in meters; ref. WAVELMIN keyword in ESO SDP standard. + + +The characteristic spectral resolving power (lambda/delta(lambda)) of the data set. Ref. SPECRES keyword in ESO SDP standard, computed from em_min and em_max when SPECRES not provided := central lambda / delta(lambda) = 0.5*(wavelmin+wavelmax) / (wavelmax-wavelmin) + + +Number of elements on the spectral axis. + + +Name of the ESO telescope utilised to gather the photons. Possible values are: APEX-12m ESO-3.6 ESO-NTT ESO-VISTA ESO-VLT-U1 ESO-VLT-U2 ESO-VLT-U3 ESO-VLT-U4 ESO-VST MPG/ESO-2.2, and more to come, plus any combination of those (comma-separated), or the word MULTI. + + +ESO-specific field not present in the standard ObsCore. The ESO instrument filter name. ObsCore uses em_min, em_max, and em_res_power to characterise the transmission curve of the bandpass; this field can be used to improve readibility. + + +ESO-specific field not present in the standard ObsCore. Galactic coordinate: latitude; Image/tile/cube centre, barycentre of image arrays in MEF format ; spectroscopic target position; centre of the survey field in case of catalogues. + + +ESO-specific field not present in the standard ObsCore. Galactic coordinate: longitude; Image/tile/cube centre, barycentre of image arrays in MEF format ; spectroscopic target position; centre of the survey field in case of catalogues. + + +Name of the instrument(s) utilised to gather the data; if the data set combines multiple instruments, usually the value MULTI is assigned. + + +A flag indicating whether the target is the Sun (1) or not (0). It is currently known to be properly set for ALMA products, and for the ESO HELIOS (feeding HARPS and NIRPS) and POET (feeding ESPRESSO) solar telescopes. + + +Time stamp of last modification of the metadata + + +ESO-specific field not present in the standard ObsCore. A flag, either M or S. M-Multi OB means that the exposures from different telescope visits have been combined together; S-Single OB means that the product is the result of either a single exposure, or of the combination of exposures taken during the same visit, hence with similar atmospheric conditions, airmasses, etc. + + +ESO-specific field not present in the standard ObsCore. Number of observations participating into this data product. + + +Level of the calibration of the observable (e.g. flux). + + +Nature of the observable within the data set, expressed as an Uniform Content Descriptor (IVOA standard). + + +The name of the data collection the data set belongs to. + + +The original file name as assigned by the data producer, in form of a IVOA identifier. + + +The Principal Investigator either of the team that provided the data set to ESO through the Phase 3 process, or of the observing team in case the data have been certified by ESO. + + +In case multiple data sets are available for an observation, e.g. with different calibration levels, the obs_id value will be the same for each data set the observation comprises. The obs_id should remain identical through time for future reference. + + +IVOA dataset identifier for the published data set. It must be unique within the namespace controlled by the publisher. + + +The date when the data set becomes public. + + +It should contain a brief description (displayable in less than one line of text) specifying in scientific terms the content of the dataset. + + +ESO-specific field not present in the standard ObsCore. Technique used during the observation. Normally, OBSTECH should be adopted from the keyword HIERARCH ESO DPR TECH of the original data. + + +ESO-specific field not present in the standard ObsCore. Possible values: EDP or IDP. EDP is for products prepared and certified by the astronomical community, IDP is for products generated and certified by ESO. + + +A list of the polarisation states present in the data set. It is serialised as a concatenated list of the possible values: {I Q U V RR LL RL LR XX YY XY YX POLI POLA} where the list separator is a slash /. Leading and trailing slashes must always be present; example: /YY/. + + +Number of polarisation states available in the data set. + + +The web (html) rendering of the preview, both for ALMA and LPO data. (The datalink offered by the access_url does not support ALMA data) + + +The identifier(s) of the observing programme(s) this data set originates from. + + +ESO-specific field not present in the standard ObsCore. The publication date is the time when the product has been made available to the user community (whether under proprietary period or not). + + +ESO-specific field. It provides the link to the PDF document describing the release content, the originating observations, the calibration and data reduction procedures, the data quality, the data format, and the scientific context of the programme + + +Equatorial coordinate: Declination (FK5/J2000); Image/tile/cube centre, barycentre of image arrays in MEF format ; spectroscopic target position; centre of the survey field in case of catalogues. Ref. DEC keyword in ESO SDP standard. + + +Angular size of the spatial field of view the photons were collected from. + + +Spatial pixel scale + + +Equatorial coordinate: Right Ascension (FK5/J2000); Image/tile/cube centre, barycentre of image arrays in MEF format ; spectroscopic target position; centre of the survey field in case of catalogues. Ref. RA keyword in ESO SDP standard. + + +The spatial footprint of the data set. For an ESO spectrum, the returned footprint is the position (s_ra,s_dec). + + +The characteristic spatial resolution of the data set; ref. ESO SDP standard: PSF_FWHM for images, SKY_RES for 3d cubes, assumed to be the APERTURE for spectral data. + + +Number of elements (e.g. pixels) along the first spatial axis. + + +Number of elements (e.g. pixels) along the second spatial axis. + + +ESO-specific field not present in the standard ObsCore. The average signal-to-noise ratio of a spectrum. Not applicable to other data product types. + + +Strehl ratio: Ratio of the intensity at the peak of the light distribution of an on-axis point source to the intensity assuming an ideal optical system limited only by diffraction over the aperture. It should be a value between 0 and 1 + + +Total integration time per pixel (in seconds); ref. EXPTIME keyword in ESO SDP standard. + + +Stop time in MJD; ref. MJD-END keyword in ESO SDP standard. + + +Start time in MJD; ref MJD-OBS keyword in ESO SDP standard. + + +Temporal resolution (FWHM) in seconds; currently defined as := (t_max-t_min)*86400. + + +Number of elements along the time axis (currently set to 1 for all observations). + + +The target name as assigned by the Principal Investigator; ref. Ref. OBJECT keyword in ESO SDP standard. For spectroscopic public surveys, the value shall be set to the survey source identifier, which shall be unique within the survey + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
8.3388458application/x-votable+xml;content=datalinkhttps://archive.eso.org/datalink/links?ID=ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647doi:10.1051/0004-6361/2026610232imageADP.2026-08-19T13:19:07.6478.570499999999999E-77.7655E-710.1465835571289061ESO-VLT-U3N_I-8.958607199.787228SPHERE02026-08-19T13:32:34.690ZS96absoluteBETELGEUSE-Bivo://eso.org/origfile?SPHERE_ZIMPOL_Betelgeuse_P1_N_I_phase3.fitsMONTARGES, MIGUEL4101305ivo://eso.org/ID?ADP.2026-08-19T13:19:07.6472026-08-19T13:28:29ZPOLARIMETRY,P1EDPhttps://archive.eso.org/dataset/ADP.2026-08-19T13:19:07.647114.28H9.0012026-08-19T13:28:29Zhttps://www.eso.org/rm/api/v1/public/releaseDescriptions/2547.4070630.001448154720.003688.792938POLYGON J2000 88.793454 7.406553 88.792421 7.406553 88.792421 7.407577 88.793454 7.4075770.0161024102424.76260647.2413780560647.206587113005.937216Betelgeuse
+
+
diff --git a/tests/fixtures/telescope-vo/live-evidence.json b/tests/fixtures/telescope-vo/live-evidence.json new file mode 100644 index 0000000000..2a8982e14f --- /dev/null +++ b/tests/fixtures/telescope-vo/live-evidence.json @@ -0,0 +1,152 @@ +{ + "captured": "2026-09-20", + "scope": "Initial acquisition used the public query/get/outputs/export commands. The smaller subset and direct product used saved-session APIs with retained live ObsCore discovery and fresh DataLink/science requests, followed by public outputs/export. No sample-equivalence or calibration-accuracy claim.", + "runs": [ + { + "operation": "soda-circle-0.2-arcsec", + "delivery": "output/telescopes/vo/live-betelgeuse-final/pick-2/result.json", + "deliverySha256": "648a33ff129acbbabd7b367138aee82ac6b48441e9705e8cbfec87a968375ba0", + "product": { + "path": "files/output/telescopes/vo/acquired/d54d7b249a8f95df9df68ed3ba8216f4519614ad5c3fe5ab6b88cebc1b2a441d/science.fits", + "original": "output/telescopes/vo/acquired/d54d7b249a8f95df9df68ed3ba8216f4519614ad5c3fe5ab6b88cebc1b2a441d/science.fits", + "sha256": "626256de642d203412371967584bf0007e817df47ba29afb20bde762d9f22448", + "bytes": 169920 + }, + "satisfaction": { + "status": "unresolved", + "acceptance": "all-requested-constraints", + "constraints": { + "artifact": { + "answer": "yes", + "reason": "The current receipt verifies this exact product." + }, + "target": { + "answer": "yes", + "reason": "Product target: betelgeuse." + }, + "region": { + "answer": "unknown", + "reason": "RADECSYS= 'FK5 ' / Coordinate system \nthe RADECSYS keyword is deprecated, use RADESYSa." + }, + "wavelength": { + "answer": "unknown", + "reason": "No qualified wavelength interval is stated; a central wavelength does not establish band coverage." + }, + "time": { + "answer": "yes", + "reason": "Any observation time was accepted." + }, + "kind": { + "answer": "yes", + "reason": "Product kind: image." + }, + "result": { + "answer": "yes", + "reason": "Qualified result: telescope-product." + }, + "angularResolution": { + "answer": "unknown", + "reason": "No supported measured or calibrated resolution evidence is established; numbers, sampling and nominal optics alone are insufficient." + } + } + }, + "figureSha256": "c0ff414d387cbdceff73e05ec09701b8f6280127c0d34f5a9a031de08d9547f7" + }, + { + "operation": "soda-circle-0.1-arcsec", + "delivery": "output/telescopes/vo/live-second-subset/pick-1/result.json", + "deliverySha256": "9283117149209754108daae7a81d8678711013acbe4cb0dce04d4850eea1ff83", + "product": { + "path": "files/output/telescopes/vo/acquired/52c12e19985f5f35415101756b8a922f43f901e1e3e8d1e392c2924f1a3fec2b/science.fits", + "original": "output/telescopes/vo/acquired/52c12e19985f5f35415101756b8a922f43f901e1e3e8d1e392c2924f1a3fec2b/science.fits", + "sha256": "f0a8a0cc5785c19231104dab4fbbc160e3d823b27ce74ea58139da6d1e9f8150", + "bytes": 95040 + }, + "satisfaction": { + "status": "unresolved", + "acceptance": "all-requested-constraints", + "constraints": { + "artifact": { + "answer": "yes", + "reason": "The current receipt verifies this exact product." + }, + "target": { + "answer": "yes", + "reason": "Product target: betelgeuse." + }, + "region": { + "answer": "partial", + "reason": "Requested boundary positions lie outside the returned image." + }, + "wavelength": { + "answer": "unknown", + "reason": "No qualified wavelength interval is stated; a central wavelength does not establish band coverage." + }, + "time": { + "answer": "yes", + "reason": "Any observation time was accepted." + }, + "kind": { + "answer": "yes", + "reason": "Product kind: image." + }, + "result": { + "answer": "yes", + "reason": "Qualified result: telescope-product." + }, + "angularResolution": { + "answer": "unknown", + "reason": "No supported measured or calibrated resolution evidence is established; numbers, sampling and nominal optics alone are insufficient." + } + } + }, + "figureSha256": "cdf4097e906fb6fa148e752c998c7d12bc09c3609fa693695e813adfa15b3f67" + }, + { + "operation": "direct", + "delivery": "output/telescopes/vo/live-direct/pick-1/result.json", + "deliverySha256": "50ec1591f3f0d3231477c077ac906266f7fc9fba6902c1fe0f3275dfc0d56ea3", + "product": { + "path": "files/output/telescopes/vo/acquired/003058678b192837a6222929eb3881509c2d8483e4bb216ee9ffba20a55bfbee/science.fits", + "original": "output/telescopes/vo/acquired/003058678b192837a6222929eb3881509c2d8483e4bb216ee9ffba20a55bfbee/science.fits", + "sha256": "50acda00ac3cde75cde275504c90849d33e6f975e4b4e0ac2e98dc40c9e92bb3", + "bytes": 8458560 + }, + "satisfaction": { + "status": "unresolved", + "acceptance": "all-requested-constraints", + "constraints": { + "artifact": { + "answer": "yes", + "reason": "The current receipt verifies this exact product." + }, + "target": { + "answer": "yes", + "reason": "Product target: betelgeuse." + }, + "wavelength": { + "answer": "unknown", + "reason": "No qualified wavelength interval is stated; a central wavelength does not establish band coverage." + }, + "time": { + "answer": "yes", + "reason": "Any observation time was accepted." + }, + "kind": { + "answer": "yes", + "reason": "Product kind: image." + }, + "result": { + "answer": "yes", + "reason": "Qualified result: telescope-product." + }, + "angularResolution": { + "answer": "unknown", + "reason": "No supported measured or calibrated resolution evidence is established; numbers, sampling and nominal optics alone are insufficient." + } + } + }, + "figureSha256": "98f4a472e3d3b88aeebab8e1dd14ca2c411a39f4e8385f97c50dbcde0999f177" + } + ] +} diff --git a/tests/fixtures/telescope-vo/psa-epn.xml b/tests/fixtures/telescope-vo/psa-epn.xml new file mode 100644 index 0000000000..20f831c5d6 --- /dev/null +++ b/tests/fixtures/telescope-vo/psa-epn.xml @@ -0,0 +1,212 @@ + + + + + + + + + + +Estimate file size in kbyte (with this spelling) + + +RFC 2045 media type (mime), required to be all-lower case + + +URL of the data file, case sensitive (additional files may be linked through datalink_url). Can point to a script. If present, next 2 parameters must also be present. + + +Max resolution in first coordinate + + +Min resolution in first coordinate + + +Max of first coordinate, depends on the frame + + +Min of first coordinate, depends on the frame + + +Max resolution in second coordinate + + +Min resolution in second coordinate + + +Max of second coordinate, depends on the frame + + +Min of second coordinate, depends on the frame + + +Max resolution in third coordinate + + +Min resolution in third coordinate + + +Max of third coordinate + + +Min of third coordinate + + +Date of first entry of this granule + + +Path of the label file in the ESDC Datalabs environment. + + +Organization of the data product, from enumerated list + + +Max emergence angle + + +Min emergence angle + + +Common to granules of same type + + +Unique ID in data service + + +Max incidence angle (solar zenithal angle) + + +Min incidence angle (solar zenithal angle) + + +Standard name of the observatory or spacecraft + + +Standard name of instrument + + +Type of instrument + + +URL of the label file in the PSA ftp. + + +UCD(s) defining the data + + +Date of last modification + + +Associates granules derived from the same data + + +Max phase angle + + +Min phase angle + + +Dataset-related encoding, or simplified CODMAC calibration level + + +Start of public access period (set to creation_date if no proprietary period) + + +ObsCore-like footprint in 2D (if spatial_frame_type = celestial or body) + + +Title of resource = schema name + + +Flavor of coordinate system, defines the nature of coordinates. From enumerated list. Use "none" if undefined. + + +Max spectral range (as frequency) + + +Min spectral range (as frequency) + + +Max spectral resolution (resolving power) + + +Min spectral resolution (resolving power) + + +Max spectral sampling step + + +Min spectral sampling step + + +Type of target, from enumerated list + + +Standard IAU name of target (must match target_class), case sensitive + + +URL of a thumbnail image with predefined size (png 200 pix, for use in a client only) + + +Max integration time + + +Min integration time + + +Stop time (in JD). UTC measured at time_origin location (default is observer's frame) + + +Start time (in JD). UTC measured at time_origin location (default is observer's frame) + + +Max time sampling step + + +Min time sampling step + + + + +H/4zCZPxmAAABbUAAAAVYXBwbGljYXRpb24veC1wZHMtemlwAAAA02h0dHBzOi8v +cHNhLmVzYS5pbnQvcHNhLXRhcC9kYXRhP3JldHJpZXZhbF9hY2Nlc3M9RElSRUNU +JnJldHJpZXZhbF90eXBlPVBST0RVQ1QmdGFwY2xpZW50PUVQTi1UQVAmaWQ9dXJu +OmVzYTpwc2E6ZW0xNl90Z29fY2FzOmRhdGFfY2FsaWJyYXRlZDpjYXNfY2FsX3Nj +XzIwMjIxMTEydDE2MzQ1MC0yMDIyMTExMnQxNjM0NTQtMjIxNzctMS1wYW4tODUw +NzI1Nzk2LTIyLTB/+AAAAAAAAH/4AAAAAAAAf/gAAAAAAAB/+AAAAAAAAH/4AAAA +AAAAf/gAAAAAAAB/+AAAAAAAAH/4AAAAAAAAf/gAAAAAAAB/+AAAAAAAAH/4AAAA +AAAAf/gAAAAAAAAAAAAVMjAyNS0wNi0zMFQwMDowMDowMC4wAAAA2i9kYXRhL3Bz +YV9wc2EwMV9kYXRhbGFiczAxL0V4b01hcnMyMDE2L2VtMTZfdGdvX2Nhcy9kYXRh +X2NhbGlicmF0ZWQvU2NpZW5jZV9QaGFzZS9PcmJpdF9SYW5nZV8yMjEwMF8yMjE5 +OS9PcmJpdF8yMjE3Ny9TY2llbmNlLzg1MDcyNTc5Ni9QQU4vY2FzX2NhbF9zY18y +MDIyMTExMlQxNjM0NTAtMjAyMjExMTJUMTYzNDU0LTIyMTc3LTEtUEFOLTg1MDcy +NTc5Ni0yMi0wX180XzAueG1sAAAAAmNpf/gAAAAAAAB/+AAAAAAAAAAAACh1cm46 +ZXNhOnBzYTplbTE2X3Rnb19jYXM6ZGF0YV9jYWxpYnJhdGVkAAAAc3Vybjplc2E6 +cHNhOmVtMTZfdGdvX2NhczpkYXRhX2NhbGlicmF0ZWQ6Y2FzX2NhbF9zY18yMDIy +MTExMnQxNjM0NTAtMjAyMjExMTJ0MTYzNDU0LTIyMTc3LTEtcGFuLTg1MDcyNTc5 +Ni0yMi0wOjo0LjB/+AAAAAAAAH/4AAAAAAAAAAAADEV4b01hcnMgMjAxNgAAAAZD +YVNTSVMAAAAAAAAA5Wh0dHBzOi8vYXJjaGl2ZXMuZXNhYy5lc2EuaW50L3BzYS9m +dHAvRXhvTWFyczIwMTYvZW0xNl90Z29fY2FzL2RhdGFfY2FsaWJyYXRlZC9TY2ll +bmNlX1BoYXNlL09yYml0X1JhbmdlXzIyMTAwXzIyMTk5L09yYml0XzIyMTc3L1Nj +aWVuY2UvODUwNzI1Nzk2L1BBTi9jYXNfY2FsX3NjXzIwMjIxMTEyVDE2MzQ1MC0y +MDIyMTExMlQxNjM0NTQtMjIxNzctMS1QQU4tODUwNzI1Nzk2LTIyLTBfXzRfMC54 +bWwAAAAAAAAAFTIwMjUtMDYtMzBUMDA6MDA6MDAuMAAAAG51cm46ZXNhOnBzYTpl +bTE2X3Rnb19jYXM6ZGF0YV9jYWxpYnJhdGVkOmNhc19jYWxfc2NfMjAyMjExMTJ0 +MTYzNDUwLTIwMjIxMTEydDE2MzQ1NC0yMjE3Ny0xLXBhbi04NTA3MjU3OTYtMjIt +MH/4AAAAAAAAf/gAAAAAAAAAAAADAAAAFTIwMjMtMDUtMTJUMDA6MDA6MDAuMAAA +AAAAAAADcHNhAAAABG5vbmV/+AAAAAAAAH/4AAAAAAAAf/gAAAAAAAB/+AAAAAAA +AH/4AAAAAAAAf/gAAAAAAAAAAAAGcGxhbmV0AAAABE1hcnMAAADIaHR0cHM6Ly9w +c2EuZXNhLmludC9wc2EtdGFwL2RhdGE/cmV0cmlldmFsX2FjY2Vzcz1ESVJFQ1Qm +cmV0cmlldmFsX3R5cGU9SU1BR0UmbGlkdmlkPXVybjplc2E6cHNhOmVtMTZfdGdv +X2NhczpkYXRhX2NhbGlicmF0ZWQ6Y2FzX2NhbF9zY18yMDIyMTExMnQxNjM0NTAt +MjAyMjExMTJ0MTYzNDU0LTIyMTc3LTEtcGFuLTg1MDcyNTc5Ni0yMi0wOjo0LjB/ ++AAAAAAAAH/4AAAAAAAAQULEfBhvoM5BQsR8GG4ccX/4AAAAAAAAf/gAAAAAAAA= + + + +
+ + +
+
diff --git a/tools/objects/astronomy-packages/MATPLOTLIB-LICENSE.txt b/tools/objects/astronomy-packages/MATPLOTLIB-LICENSE.txt new file mode 100644 index 0000000000..61d2d0f111 --- /dev/null +++ b/tools/objects/astronomy-packages/MATPLOTLIB-LICENSE.txt @@ -0,0 +1,1253 @@ +License agreement for matplotlib versions 1.3.0 and later +========================================================= + +1. This LICENSE AGREEMENT is between the Matplotlib Development Team +("MDT"), and the Individual or Organization ("Licensee") accessing and +otherwise using matplotlib software in source or binary form and its +associated documentation. + +2. Subject to the terms and conditions of this License Agreement, MDT +hereby grants Licensee a nonexclusive, royalty-free, world-wide license +to reproduce, analyze, test, perform and/or display publicly, prepare +derivative works, distribute, and otherwise use matplotlib +alone or in any derivative version, provided, however, that MDT's +License Agreement and MDT's notice of copyright, i.e., "Copyright (c) +2012- Matplotlib Development Team; All Rights Reserved" are retained in +matplotlib alone or in any derivative version prepared by +Licensee. + +3. In the event Licensee prepares a derivative work that is based on or +incorporates matplotlib or any part thereof, and wants to +make the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to matplotlib . + +4. MDT is making matplotlib available to Licensee on an "AS +IS" basis. MDT MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, MDT MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB +WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. + +5. MDT SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB + FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR +LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING +MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF +THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between MDT and +Licensee. This License Agreement does not grant permission to use MDT +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using matplotlib , +Licensee agrees to be bound by the terms and conditions of this License +Agreement. + +License agreement for matplotlib versions prior to 1.3.0 +======================================================== + +1. This LICENSE AGREEMENT is between John D. Hunter ("JDH"), and the +Individual or Organization ("Licensee") accessing and otherwise using +matplotlib software in source or binary form and its associated +documentation. + +2. Subject to the terms and conditions of this License Agreement, JDH +hereby grants Licensee a nonexclusive, royalty-free, world-wide license +to reproduce, analyze, test, perform and/or display publicly, prepare +derivative works, distribute, and otherwise use matplotlib +alone or in any derivative version, provided, however, that JDH's +License Agreement and JDH's notice of copyright, i.e., "Copyright (c) +2002-2011 John D. Hunter; All Rights Reserved" are retained in +matplotlib alone or in any derivative version prepared by +Licensee. + +3. In the event Licensee prepares a derivative work that is based on or +incorporates matplotlib or any part thereof, and wants to +make the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to matplotlib. + +4. JDH is making matplotlib available to Licensee on an "AS +IS" basis. JDH MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, JDH MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB +WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. + +5. JDH SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB + FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR +LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING +MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF +THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between JDH and +Licensee. This License Agreement does not grant permission to use JDH +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using matplotlib, +Licensee agrees to be bound by the terms and conditions of this License +Agreement. +---- + +This binary distrubution of Matplotlib can also bundle the following software +(depending on the build): + +Name: AMS Fonts +Files: matplotlib/tests/cmr10.pfb +Description: Type-1 version of one of Knuth's Computer Modern fonts +License: OFL-1.1 + The cmr10.pfb file is a Type-1 version of one of Knuth's Computer Modern fonts. + It is included here as test data only, but the following license applies. + + Copyright (c) 1997, 2009, American Mathematical Society (http://www.ams.org). + All Rights Reserved. + + "cmb10" is a Reserved Font Name for this Font Software. + "cmbsy10" is a Reserved Font Name for this Font Software. + "cmbsy5" is a Reserved Font Name for this Font Software. + "cmbsy6" is a Reserved Font Name for this Font Software. + "cmbsy7" is a Reserved Font Name for this Font Software. + "cmbsy8" is a Reserved Font Name for this Font Software. + "cmbsy9" is a Reserved Font Name for this Font Software. + "cmbx10" is a Reserved Font Name for this Font Software. + "cmbx12" is a Reserved Font Name for this Font Software. + "cmbx5" is a Reserved Font Name for this Font Software. + "cmbx6" is a Reserved Font Name for this Font Software. + "cmbx7" is a Reserved Font Name for this Font Software. + "cmbx8" is a Reserved Font Name for this Font Software. + "cmbx9" is a Reserved Font Name for this Font Software. + "cmbxsl10" is a Reserved Font Name for this Font Software. + "cmbxti10" is a Reserved Font Name for this Font Software. + "cmcsc10" is a Reserved Font Name for this Font Software. + "cmcsc8" is a Reserved Font Name for this Font Software. + "cmcsc9" is a Reserved Font Name for this Font Software. + "cmdunh10" is a Reserved Font Name for this Font Software. + "cmex10" is a Reserved Font Name for this Font Software. + "cmex7" is a Reserved Font Name for this Font Software. + "cmex8" is a Reserved Font Name for this Font Software. + "cmex9" is a Reserved Font Name for this Font Software. + "cmff10" is a Reserved Font Name for this Font Software. + "cmfi10" is a Reserved Font Name for this Font Software. + "cmfib8" is a Reserved Font Name for this Font Software. + "cminch" is a Reserved Font Name for this Font Software. + "cmitt10" is a Reserved Font Name for this Font Software. + "cmmi10" is a Reserved Font Name for this Font Software. + "cmmi12" is a Reserved Font Name for this Font Software. + "cmmi5" is a Reserved Font Name for this Font Software. + "cmmi6" is a Reserved Font Name for this Font Software. + "cmmi7" is a Reserved Font Name for this Font Software. + "cmmi8" is a Reserved Font Name for this Font Software. + "cmmi9" is a Reserved Font Name for this Font Software. + "cmmib10" is a Reserved Font Name for this Font Software. + "cmmib5" is a Reserved Font Name for this Font Software. + "cmmib6" is a Reserved Font Name for this Font Software. + "cmmib7" is a Reserved Font Name for this Font Software. + "cmmib8" is a Reserved Font Name for this Font Software. + "cmmib9" is a Reserved Font Name for this Font Software. + "cmr10" is a Reserved Font Name for this Font Software. + "cmr12" is a Reserved Font Name for this Font Software. + "cmr17" is a Reserved Font Name for this Font Software. + "cmr5" is a Reserved Font Name for this Font Software. + "cmr6" is a Reserved Font Name for this Font Software. + "cmr7" is a Reserved Font Name for this Font Software. + "cmr8" is a Reserved Font Name for this Font Software. + "cmr9" is a Reserved Font Name for this Font Software. + "cmsl10" is a Reserved Font Name for this Font Software. + "cmsl12" is a Reserved Font Name for this Font Software. + "cmsl8" is a Reserved Font Name for this Font Software. + "cmsl9" is a Reserved Font Name for this Font Software. + "cmsltt10" is a Reserved Font Name for this Font Software. + "cmss10" is a Reserved Font Name for this Font Software. + "cmss12" is a Reserved Font Name for this Font Software. + "cmss17" is a Reserved Font Name for this Font Software. + "cmss8" is a Reserved Font Name for this Font Software. + "cmss9" is a Reserved Font Name for this Font Software. + "cmssbx10" is a Reserved Font Name for this Font Software. + "cmssdc10" is a Reserved Font Name for this Font Software. + "cmssi10" is a Reserved Font Name for this Font Software. + "cmssi12" is a Reserved Font Name for this Font Software. + "cmssi17" is a Reserved Font Name for this Font Software. + "cmssi8" is a Reserved Font Name for this Font Software. + "cmssi9" is a Reserved Font Name for this Font Software. + "cmssq8" is a Reserved Font Name for this Font Software. + "cmssqi8" is a Reserved Font Name for this Font Software. + "cmsy10" is a Reserved Font Name for this Font Software. + "cmsy5" is a Reserved Font Name for this Font Software. + "cmsy6" is a Reserved Font Name for this Font Software. + "cmsy7" is a Reserved Font Name for this Font Software. + "cmsy8" is a Reserved Font Name for this Font Software. + "cmsy9" is a Reserved Font Name for this Font Software. + "cmtcsc10" is a Reserved Font Name for this Font Software. + "cmtex10" is a Reserved Font Name for this Font Software. + "cmtex8" is a Reserved Font Name for this Font Software. + "cmtex9" is a Reserved Font Name for this Font Software. + "cmti10" is a Reserved Font Name for this Font Software. + "cmti12" is a Reserved Font Name for this Font Software. + "cmti7" is a Reserved Font Name for this Font Software. + "cmti8" is a Reserved Font Name for this Font Software. + "cmti9" is a Reserved Font Name for this Font Software. + "cmtt10" is a Reserved Font Name for this Font Software. + "cmtt12" is a Reserved Font Name for this Font Software. + "cmtt8" is a Reserved Font Name for this Font Software. + "cmtt9" is a Reserved Font Name for this Font Software. + "cmu10" is a Reserved Font Name for this Font Software. + "cmvtt10" is a Reserved Font Name for this Font Software. + "euex10" is a Reserved Font Name for this Font Software. + "euex7" is a Reserved Font Name for this Font Software. + "euex8" is a Reserved Font Name for this Font Software. + "euex9" is a Reserved Font Name for this Font Software. + "eufb10" is a Reserved Font Name for this Font Software. + "eufb5" is a Reserved Font Name for this Font Software. + "eufb7" is a Reserved Font Name for this Font Software. + "eufm10" is a Reserved Font Name for this Font Software. + "eufm5" is a Reserved Font Name for this Font Software. + "eufm7" is a Reserved Font Name for this Font Software. + "eurb10" is a Reserved Font Name for this Font Software. + "eurb5" is a Reserved Font Name for this Font Software. + "eurb7" is a Reserved Font Name for this Font Software. + "eurm10" is a Reserved Font Name for this Font Software. + "eurm5" is a Reserved Font Name for this Font Software. + "eurm7" is a Reserved Font Name for this Font Software. + "eusb10" is a Reserved Font Name for this Font Software. + "eusb5" is a Reserved Font Name for this Font Software. + "eusb7" is a Reserved Font Name for this Font Software. + "eusm10" is a Reserved Font Name for this Font Software. + "eusm5" is a Reserved Font Name for this Font Software. + "eusm7" is a Reserved Font Name for this Font Software. + "lasy10" is a Reserved Font Name for this Font Software. + "lasy5" is a Reserved Font Name for this Font Software. + "lasy6" is a Reserved Font Name for this Font Software. + "lasy7" is a Reserved Font Name for this Font Software. + "lasy8" is a Reserved Font Name for this Font Software. + "lasy9" is a Reserved Font Name for this Font Software. + "lasyb10" is a Reserved Font Name for this Font Software. + "lcircle1" is a Reserved Font Name for this Font Software. + "lcirclew" is a Reserved Font Name for this Font Software. + "lcmss8" is a Reserved Font Name for this Font Software. + "lcmssb8" is a Reserved Font Name for this Font Software. + "lcmssi8" is a Reserved Font Name for this Font Software. + "line10" is a Reserved Font Name for this Font Software. + "linew10" is a Reserved Font Name for this Font Software. + "msam10" is a Reserved Font Name for this Font Software. + "msam5" is a Reserved Font Name for this Font Software. + "msam6" is a Reserved Font Name for this Font Software. + "msam7" is a Reserved Font Name for this Font Software. + "msam8" is a Reserved Font Name for this Font Software. + "msam9" is a Reserved Font Name for this Font Software. + "msbm10" is a Reserved Font Name for this Font Software. + "msbm5" is a Reserved Font Name for this Font Software. + "msbm6" is a Reserved Font Name for this Font Software. + "msbm7" is a Reserved Font Name for this Font Software. + "msbm8" is a Reserved Font Name for this Font Software. + "msbm9" is a Reserved Font Name for this Font Software. + "wncyb10" is a Reserved Font Name for this Font Software. + "wncyi10" is a Reserved Font Name for this Font Software. + "wncyr10" is a Reserved Font Name for this Font Software. + "wncysc10" is a Reserved Font Name for this Font Software. + "wncyss10" is a Reserved Font Name for this Font Software. + + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + + + +Name: BaKoMa Fonts +Files: matplotlib/mpl-data/fonts/ttf/cm*.ttf matplotlib/mpl-data/fonts/afm/cm*.afm +Description: Computer Modern Fonts in PostScript Type 1 and TrueType font formats. +License: BaKoMa Fonts Licence + BaKoMa Fonts Licence + -------------------- + + This licence covers two font packs (known as BaKoMa Fonts Collection, + which is available at `CTAN:fonts/cm/ps-type1/bakoma/'): + + 1) BaKoMa-CM (1.1/12-Nov-94) + Computer Modern Fonts in PostScript Type 1 and TrueType font formats. + + 2) BaKoMa-AMS (1.2/19-Jan-95) + AMS TeX fonts in PostScript Type 1 and TrueType font formats. + + Copyright (C) 1994, 1995, Basil K. Malyshev. All Rights Reserved. + + Permission to copy and distribute these fonts for any purpose is + hereby granted without fee, provided that the above copyright notice, + author statement and this permission notice appear in all copies of + these fonts and related documentation. + + Permission to modify and distribute modified fonts for any purpose is + hereby granted without fee, provided that the copyright notice, + author statement, this permission notice and location of original + fonts (http://www.ctan.org/tex-archive/fonts/cm/ps-type1/bakoma) + appear in all copies of modified fonts and related documentation. + + Permission to use these fonts (embedding into PostScript, PDF, SVG + and printing by using any software) is hereby granted without fee. + It is not required to provide any notices about using these fonts. + + Basil K. Malyshev + INSTITUTE FOR HIGH ENERGY PHYSICS + IHEP, OMVT + Moscow Region + 142281 PROTVINO + RUSSIA + + E-Mail: bakoma@mail.ru + or malyshev@mail.ihep.ru + + + + +Name: ColorBrewer Color Schemes +Files: lib/matplotlib/_cm.py +Description: Color schemes from ColorBrewer +License: Apache-2.0 + Apache-Style Software License for ColorBrewer software and ColorBrewer Color Schemes + + Copyright (c) 2002 Cynthia Brewer, Mark Harrower, and The Pennsylvania State University. + + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed + under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + CONDITIONS OF ANY KIND, either express or implied. See the License for the + specific language governing permissions and limitations under the License. + + +Name: Courier 10 +Files: matplotlib/tests/Courier10PitchBT-Bold.pfb +Description: Courier 10 font, used in tests. +License: Bitstream-Charter + The Courier10PitchBT-Bold.pfb file is a Type-1 version of + Courier 10 Pitch BT Bold by Bitstream, obtained from + . It is included + here as test data only, but the following license applies. + + + (c) Copyright 1989-1992, Bitstream Inc., Cambridge, MA. + + You are hereby granted permission under all Bitstream propriety rights + to use, copy, modify, sublicense, sell, and redistribute the 4 Bitstream + Charter (r) Type 1 outline fonts and the 4 Courier Type 1 outline fonts + for any purpose and without restriction; provided, that this notice is + left intact on all copies of such fonts and that Bitstream's trademark + is acknowledged as shown below on all unmodified copies of the 4 Charter + Type 1 fonts. + + BITSTREAM CHARTER is a registered trademark of Bitstream Inc. + + +Name: FreeType +Files: matplotlib/ft2font.*.so +Description: Font rendering library +License: FTL OR GPL-2.0-or-later + The FreeType 2 font engine is copyrighted work and cannot be used + legally without a software license. In order to make this project + usable to a vast majority of developers, we distribute it under two + mutually exclusive open-source licenses. + + This means that *you* must choose *one* of the two licenses described + below, then obey all its terms and conditions when using FreeType 2 in + any of your projects or products. + + - The FreeType License, found in the file `docs/FTL.TXT`, which is + similar to the original BSD license *with* an advertising clause + that forces you to explicitly cite the FreeType project in your + product's documentation. All details are in the license file. + This license is suited to products which don't use the GNU General + Public License. + + Note that this license is compatible to the GNU General Public + License version 3, but not version 2. + + - The GNU General Public License version 2, found in + `docs/GPLv2.TXT` (any later version can be used also), for + programs which already use the GPL. Note that the FTL is + incompatible with GPLv2 due to its advertisement clause. + + The contributed BDF and PCF drivers come with a license similar to + that of the X Window System. It is compatible to the above two + licenses (see files `src/bdf/README` and `src/pcf/README`). The same + holds for the source code files `src/base/fthash.c` and + `include/freetype/internal/fthash.h`; they were part of the BDF driver + in earlier FreeType versions. + + The gzip module uses the zlib license (see `src/gzip/zlib.h`) which + too is compatible to the above two licenses. + + The files `src/autofit/ft-hb.c` and `src/autofit/ft-hb.h` contain code + taken almost verbatim from the HarfBuzz file `hb-ft.cc`, which uses + the 'Old MIT' license, compatible to the above two licenses. + + The MD5 checksum support (only used for debugging in development + builds) is in the public domain. + + +Name: HarfBuzz +Files: matplotlib/ft2font.*.so +Description: Text shaping library +License: MIT-Modern-Variant + HarfBuzz is licensed under the so-called "Old MIT" license. Details follow. + For parts of HarfBuzz that are licensed under different licenses see individual + files names COPYING in subdirectories where applicable. + + Copyright © 2010-2022 Google, Inc. + Copyright © 2015-2020 Ebrahim Byagowi + Copyright © 2019,2020 Facebook, Inc. + Copyright © 2012,2015 Mozilla Foundation + Copyright © 2011 Codethink Limited + Copyright © 2008,2010 Nokia Corporation and/or its subsidiary(-ies) + Copyright © 2009 Keith Stribley + Copyright © 2011 Martin Hosken and SIL International + Copyright © 2007 Chris Wilson + Copyright © 2005,2006,2020,2021,2022,2023 Behdad Esfahbod + Copyright © 2004,2007,2008,2009,2010,2013,2021,2022,2023 Red Hat, Inc. + Copyright © 1998-2005 David Turner and Werner Lemberg + Copyright © 2016 Igalia S.L. + Copyright © 2022 Matthias Clasen + Copyright © 2018,2021 Khaled Hosny + Copyright © 2018,2019,2020 Adobe, Inc + Copyright © 2013-2015 Alexei Podtelezhnikov + + For full copyright notices consult the individual files in the package. + + + Permission is hereby granted, without written agreement and without + license or royalty fees, to use, copy, modify, and distribute this + software and its documentation for any purpose, provided that the + above copyright notice and the following two paragraphs appear in + all copies of this software. + + IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE TO ANY PARTY FOR + DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES + ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN + IF THE COPYRIGHT HOLDER HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH + DAMAGE. + + THE COPYRIGHT HOLDER SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, + BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS + ON AN "AS IS" BASIS, AND THE COPYRIGHT HOLDER HAS NO OBLIGATION TO + PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + + +Name: JSXTools resize observer +Files: lib/matplotlib/backends/web_backend/js/mpl.js +Description: Minimal polyfill for the ResizeObserver API +License: CC0-1.0 + # CC0 1.0 Universal + + ## Statement of Purpose + + The laws of most jurisdictions throughout the world automatically confer + exclusive Copyright and Related Rights (defined below) upon the creator and + subsequent owner(s) (each and all, an “owner”) of an original work of + authorship and/or a database (each, a “Work”). + + Certain owners wish to permanently relinquish those rights to a Work for the + purpose of contributing to a commons of creative, cultural and scientific works + (“Commons”) that the public can reliably and without fear of later claims of + infringement build upon, modify, incorporate in other works, reuse and + redistribute as freely as possible in any form whatsoever and for any purposes, + including without limitation commercial purposes. These owners may contribute + to the Commons to promote the ideal of a free culture and the further + production of creative, cultural and scientific works, or to gain reputation or + greater distribution for their Work in part through the use and efforts of + others. + + For these and/or other purposes and motivations, and without any expectation of + additional consideration or compensation, the person associating CC0 with a + Work (the “Affirmer”), to the extent that he or she is an owner of Copyright + and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and + publicly distribute the Work under its terms, with knowledge of his or her + Copyright and Related Rights in the Work and the meaning and intended legal + effect of CC0 on those rights. + + 1. Copyright and Related Rights. A Work made available under CC0 may be + protected by copyright and related or neighboring rights (“Copyright and + Related Rights”). Copyright and Related Rights include, but are not limited + to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness + depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations + thereof. + + 2. Waiver. To the greatest extent permitted by, but not in contravention of, + applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and + unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright + and Related Rights and associated claims and causes of action, whether now + known or unknown (including existing as well as future claims and causes of + action), in the Work (i) in all territories worldwide, (ii) for the maximum + duration provided by applicable law or treaty (including future time + extensions), (iii) in any current or future medium and for any number of + copies, and (iv) for any purpose whatsoever, including without limitation + commercial, advertising or promotional purposes (the “Waiver”). Affirmer + makes the Waiver for the benefit of each member of the public at large and + to the detriment of Affirmer’s heirs and successors, fully intending that + such Waiver shall not be subject to revocation, rescission, cancellation, + termination, or any other legal or equitable action to disrupt the quiet + enjoyment of the Work by the public as contemplated by Affirmer’s express + Statement of Purpose. + + 3. Public License Fallback. Should any part of the Waiver for any reason be + judged legally invalid or ineffective under applicable law, then the Waiver + shall be preserved to the maximum extent permitted taking into account + Affirmer’s express Statement of Purpose. In addition, to the extent the + Waiver is so judged Affirmer hereby grants to each affected person a + royalty-free, non transferable, non sublicensable, non exclusive, + irrevocable and unconditional license to exercise Affirmer’s Copyright and + Related Rights in the Work (i) in all territories worldwide, (ii) for the + maximum duration provided by applicable law or treaty (including future time + extensions), (iii) in any current or future medium and for any number of + copies, and (iv) for any purpose whatsoever, including without limitation + commercial, advertising or promotional purposes (the “License”). The License + shall be deemed effective as of the date CC0 was applied by Affirmer to the + Work. Should any part of the License for any reason be judged legally + invalid or ineffective under applicable law, such partial invalidity or + ineffectiveness shall not invalidate the remainder of the License, and in + such case Affirmer hereby affirms that he or she will not (i) exercise any + of his or her remaining Copyright and Related Rights in the Work or (ii) + assert any associated claims and causes of action with respect to the Work, + in either case contrary to Affirmer’s express Statement of Purpose. + + 4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or + otherwise, including without limitation warranties of title, + merchantability, fitness for a particular purpose, non infringement, or + the absence of latent or other defects, accuracy, or the present or + absence of errors, whether or not discoverable, all to the greatest + extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person’s Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + + For more information, please see + http://creativecommons.org/publicdomain/zero/1.0/. + + +Name: Last Resort fonts +Files: matplotlib/mpl-data/fonts/ttf/LastResortHE-Regular.ttf +Description: Last Resort High-Efficiency Font +License: OFL-1.1 + This Font Software is licensed under the SIL Open Font License, + Version 1.1. + + This license is copied below, and is also available with a FAQ at: + http://scripts.sil.org/OFL + + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font + creation efforts of academic and linguistic communities, and to + provide a free and open framework in which fonts may be shared and + improved in partnership with others. + + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply to + any document created using the fonts or their derivatives. + + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + + "Original Version" refers to the collection of Font Software + components as distributed by the Copyright Holder(s). + + "Modified Version" refers to any derivative made by adding to, + deleting, or substituting -- in part or in whole -- any of the + components of the Original Version, by changing formats or by porting + the Font Software to a new environment. + + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, + modify, redistribute, and sell modified and unmodified copies of the + Font Software, subject to the following conditions: + + 1) Neither the Font Software nor any of its individual components, in + Original or Modified Versions, may be sold by itself. + + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created using + the Font Software. + + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + + SPDX-License-Identifier: OFL-1.1 + + +Name: libraqm +Files: matplotlib/ft2font.*.so +Description: Complex text layout library +License: MIT + The MIT License (MIT) + + Copyright © 2015 Information Technology Authority (ITA) + Copyright © 2016-2023 Khaled Hosny + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + +Name: QHull +Files: matplotlib/_qhull.*.so +Description: Convex hull, Delaunay triangulation, Voronoi diagrams, Halfspace intersection +License: Qhull + Qhull, Copyright (c) 1993-2020 + + C.B. Barber + Arlington, MA + + and + + The National Science and Technology Research Center for + Computation and Visualization of Geometric Structures + (The Geometry Center) + University of Minnesota + + email: qhull@qhull.org + + This software includes Qhull from C.B. Barber and The Geometry Center. + Files derived from Qhull 1.0 are copyrighted by the Geometry Center. The + remaining files are copyrighted by C.B. Barber. Qhull is free software + and may be obtained via http from www.qhull.org. It may be freely copied, + modified, and redistributed under the following conditions: + + 1. All copyright notices must remain intact in all files. + + 2. A copy of this text file must be distributed along with any copies + of Qhull that you redistribute; this includes copies that you have + modified, or copies of programs or other software products that + include Qhull. + + 3. If you modify Qhull, you must include a notice giving the + name of the person performing the modification, the date of + modification, and the reason for such modification. + + 4. When distributing modified versions of Qhull, or other software + products that include Qhull, you must provide notice that the original + source code may be obtained as noted above. + + 5. There is no warranty or other guarantee of fitness for Qhull, it is + provided solely "as is". Bug reports or fixes may be sent to + qhull_bug@qhull.org; the authors may or may not act on them as + they desire. + + +Name: Qt4 Editor +Files: matplotlib/backends/qt_editor +Description: Module creating PyQt4 form dialogs/layouts to edit various type of parameters +License: MIT + Module creating PyQt4 form dialogs/layouts to edit various type of parameters + + + formlayout License Agreement (MIT License) + ------------------------------------------ + + Copyright (c) 2009 Pierre Raybaut + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + +Name: SheenBidi +Files: matplotlib/ft2font.*.so +Description: Unicode Bidirectional Algorithm implementation +License: Apache-2.0 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Name: Solarized +Files: matplotlib/mpl-data/stylelib/Solarize_Light2.mplstyle +Description: Solarized color scheme/style +License: MIT + https://github.com/altercation/solarized/blob/master/LICENSE + Copyright (c) 2011 Ethan Schoonover + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + +Name: Stix fonts +Files: matplotlib/mpl-data/fonts/ttf/STIX*.ttf +Description: STIX fonts +License: OFL-1.1 + STIX Font License + + 24 May 2010 + + Copyright (c) 2001-2010 by the STI Pub Companies, consisting of the American + Institute of Physics, the American Chemical Society, the American Mathematical + Society, the American Physical Society, Elsevier, Inc., and The Institute of + Electrical and Electronic Engineers, Inc. (www.stixfonts.org), with Reserved + Font Name STIX Fonts, STIX Fonts (TM) is a trademark of The Institute of + Electrical and Electronics Engineers, Inc. + + Portions copyright (c) 1998-2003 by MicroPress, Inc. (www.micropress-inc.com), + with Reserved Font Name TM Math. To obtain additional mathematical fonts, please + contact MicroPress, Inc., 68-30 Harrow Street, Forest Hills, NY 11375, USA, + Phone: (718) 575-1816. + + Portions copyright (c) 1990 by Elsevier, Inc. + + This Font Software is licensed under the SIL Open Font License, Version 1.1. + This license is copied below, and is also available with a FAQ at: + https://scripts.sil.org/OFL + + ----------------------------------------------------------- + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + ----------------------------------------------------------- + + PREAMBLE + The goals of the Open Font License (OFL) are to stimulate worldwide + development of collaborative font projects, to support the font creation + efforts of academic and linguistic communities, and to provide a free and + open framework in which fonts may be shared and improved in partnership + with others. + + The OFL allows the licensed fonts to be used, studied, modified and + redistributed freely as long as they are not sold by themselves. The + fonts, including any derivative works, can be bundled, embedded, + redistributed and/or sold with any software provided that any reserved + names are not used by derivative works. The fonts and derivatives, + however, cannot be released under any other type of license. The + requirement for fonts to remain under this license does not apply + to any document created using the fonts or their derivatives. + + DEFINITIONS + "Font Software" refers to the set of files released by the Copyright + Holder(s) under this license and clearly marked as such. This may + include source files, build scripts and documentation. + + "Reserved Font Name" refers to any names specified as such after the + copyright statement(s). + + "Original Version" refers to the collection of Font Software components as + distributed by the Copyright Holder(s). + + "Modified Version" refers to any derivative made by adding to, deleting, + or substituting -- in part or in whole -- any of the components of the + Original Version, by changing formats or by porting the Font Software to a + new environment. + + "Author" refers to any designer, engineer, programmer, technical + writer or other person who contributed to the Font Software. + + PERMISSION & CONDITIONS + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Font Software, to use, study, copy, merge, embed, modify, + redistribute, and sell modified and unmodified copies of the Font + Software, subject to the following conditions: + + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + + 2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + + 3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + + 5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + + TERMINATION + This license becomes null and void if any of the above conditions are + not met. + + DISCLAIMER + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM + OTHER DEALINGS IN THE FONT SOFTWARE. + +Name: Yorick Colormaps +Files: lib/matplotlib/_cm.py +Description: Gist/Yorick colormaps +License: + BSD-style license for gist/yorick colormaps. + + Copyright: + + Copyright (c) 1996. The Regents of the University of California. + All rights reserved. + + Permission to use, copy, modify, and distribute this software for any + purpose without fee is hereby granted, provided that this entire + notice is included in all copies of any software which is or includes + a copy or modification of this software and in all copies of the + supporting documentation for such software. + + This work was produced at the University of California, Lawrence + Livermore National Laboratory under contract no. W-7405-ENG-48 between + the U.S. Department of Energy and The Regents of the University of + California for the operation of UC LLNL. + + + DISCLAIMER + + This software was prepared as an account of work sponsored by an + agency of the United States Government. Neither the United States + Government nor the University of California nor any of their + employees, makes any warranty, express or implied, or assumes any + liability or responsibility for the accuracy, completeness, or + usefulness of any information, apparatus, product, or process + disclosed, or represents that its use would not infringe + privately-owned rights. Reference herein to any specific commercial + products, process, or service by trade name, trademark, manufacturer, + or otherwise, does not necessarily constitute or imply its + endorsement, recommendation, or favoring by the United States + Government or the University of California. The views and opinions of + authors expressed herein do not necessarily state or reflect those of + the United States Government or the University of California, and + shall not be used for advertising or product endorsement purposes. + + + AUTHOR + + David H. Munro wrote Yorick and Gist. Berkeley Yacc (byacc) generated + the Yorick parser. The routines in Math are from LAPACK and FFTPACK; + MathC contains C translations by David H. Munro. The algorithms for + Yorick's random number generator and several special functions in + Yorick/include were taken from Numerical Recipes by Press, et. al., + although the Yorick implementations are unrelated to those in + Numerical Recipes. A small amount of code in Gist was adapted from + the X11R4 release, copyright M.I.T. -- the complete copyright notice + may be found in the (unused) file Gist/host.c. diff --git a/tools/objects/astronomy-packages/NOTICE.md b/tools/objects/astronomy-packages/NOTICE.md index 9f31d44758..3513249e9d 100644 --- a/tools/objects/astronomy-packages/NOTICE.md +++ b/tools/objects/astronomy-packages/NOTICE.md @@ -31,4 +31,38 @@ to the Virtual Observatory”, *Astronomy and Computing* 25 (2018), doi:10.1016/ Astroquery and PyVO depend on [Astropy 8.0.1](https://pypi.org/project/astropy/). It is installed from the hash-locked environment and is not copied or modified. Astropy is distributed under the BSD 3-Clause license reproduced in -[ASTROPY-LICENSE.rst](ASTROPY-LICENSE.rst). +[ASTROPY-LICENSE.rst](ASTROPY-LICENSE.rst). Its NDData arithmetic owns selected-output +error propagation. [Astropy visualization](https://docs.astropy.org/en/stable/visualization/index.html) +owns WCSAxes sky coordinates, ImageNormalize scaling and quantity-aware axes. Astropy FITS +and QTable serialize the scientific outputs, including units and masks. + +# Matplotlib + +cssEarth uses [Matplotlib 3.11.2](https://matplotlib.org/) for static telescope output figures. +It is an installed, hash-pinned dependency in the astronomy environment; no implementation +source is copied or modified. Its complete license is retained in +[MATPLOTLIB-LICENSE.txt](MATPLOTLIB-LICENSE.txt). See [savefig](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.savefig.html) +for the PNG/SVG output boundary. Numeric values, scientific interpretation and receipts remain +owned by cssEarth. Plotted archive data retain their original provenance and terms. + +# Optional output reference tools + +The output oracle imports [specutils 2.4.0](https://specutils.readthedocs.io/) and +[Photutils 3.0.0](https://photutils.readthedocs.io/), both under BSD-3-Clause. They are +installed in an optional test environment, not copied, modified, or bundled into the +CLI. Their upstream distributions retain the copyright and license notices. The +pinned optional versions are in `oracle-requirements.txt`; the reports record the +versions used. These comparisons do not imply endorsement by either project. + +# PlanetMapper + +[PlanetMapper 1.14.0](https://github.com/ortk95/planetmapper) owns the `telescope project` +route's navigation and nearest-neighbour surface resampling. It chains SpiceyPy/CSPICE +for geometry and pyproj/PROJ for its map projections. Installed dependencies, not +copied implementations; MIT notice: [PLANETMAPPER-LICENSE.txt](PLANETMAPPER-LICENSE.txt). +Citation: [King & Fletcher (2023), JOSS 8(90), 5728](https://doi.org/10.21105/joss.05728). +The pinned environment also includes SciPy, Photutils, pyproj, SpiceyPy and tqdm; +their distributions retain their license notices. Photutils is now a production +transitive dependency as well as an optional oracle dependency. This route never +starts PlanetMapper's GUI. cssEarth owns input qualification, selection, masks, +scientific assumptions and evidence; SPICE kernels retain NAIF/provider terms. diff --git a/tools/objects/astronomy-packages/PLANETMAPPER-LICENSE.txt b/tools/objects/astronomy-packages/PLANETMAPPER-LICENSE.txt new file mode 100644 index 0000000000..081bbad74b --- /dev/null +++ b/tools/objects/astronomy-packages/PLANETMAPPER-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Oliver King + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/objects/astronomy-packages/client.mts b/tools/objects/astronomy-packages/client.mts index 28836bf183..a96f18539d 100644 --- a/tools/objects/astronomy-packages/client.mts +++ b/tools/objects/astronomy-packages/client.mts @@ -3,10 +3,22 @@ import { spawn } from 'node:child_process'; import { requireArray, requireRecord, requireString } from '../../source-values.mts'; import { astroqueryToolchain } from './toolchain.mts'; +import { parseMetadata, parsePin, type Json, type Pin, type MetadataResponse } from '../telescopes/vo/contracts.mts'; export class ArchiveTransportError extends Error {} +export type VoFailureCode = 'authentication' | 'no-content' | 'byte-limit' | 'protocol' | 'transport' | 'interrupted' | 'identity' | 'local-io'; +export class VoAccessError extends Error { + readonly code: VoFailureCode; + readonly httpStatus: number | null; + constructor(code: VoFailureCode, message: string, httpStatus: number | null) { super(message); this.name = 'VoAccessError'; this.code = code; this.httpStatus = httpStatus; } +} export type HorizonsEpochs = readonly number[] | { readonly start: string; readonly stop: string; readonly step: string }; export type AstroqueryRequest = + | { readonly operation: 'vo-download'; readonly url: string; readonly destination: string; readonly byteLimit: number; readonly format?: 'fits' | 'zip' | 'tar'; readonly parameters: Readonly>; + readonly descriptor?: { readonly file: Pin; readonly row: number; readonly serviceId: string } } + | { readonly operation: 'vo-tap'; readonly service: string; readonly query: string; readonly maxrec: number; readonly directory: string; readonly byteLimit: number; readonly timeFormat?: 'mjd' | 'jd'; readonly timeScale?: 'utc' | 'tai' | 'tt' | 'tdb'; readonly timeModel?: 'epn-tap-2.0' } + | { readonly operation: 'vo-links'; readonly url: string; readonly parameters?: Readonly>; readonly directory: string; readonly byteLimit: number } + | { readonly operation: 'vo-parse'; readonly file: string; readonly url: string; readonly byteLimit: number; readonly timeFormat?: 'mjd' | 'jd'; readonly timeScale?: 'utc' | 'tai' | 'tt' | 'tdb'; readonly timeModel?: 'epn-tap-2.0' } | { readonly operation: 'mast-service'; readonly service: string; readonly parameters: Readonly>; readonly pagesize?: number; readonly page?: number } | { readonly operation: 'mast-download'; readonly uri: string; readonly destination: string } | { readonly operation: 'tap-query'; readonly service: string; readonly query: string; readonly maxrec?: number } @@ -16,6 +28,8 @@ export type AstroqueryRequest = | { readonly operation: 'horizons-vectors'; readonly id: string; readonly location: string; readonly epochs: HorizonsEpochs; readonly refplane?: 'ecliptic' | 'earth' | 'body' | 'frame'; readonly aberrations?: 'geometric' | 'astrometric' | 'apparent'; readonly raw?: boolean }; export interface AstroqueryAnswer { + readonly transfer?: { readonly file: Pin; readonly effectiveUrl: string; readonly contentType: string | null; readonly etag: string | null; readonly lastModified: string | null }; + readonly vo?: MetadataResponse; readonly schema: 'cssearth-astroquery-answer@2'; readonly astroquery: string; readonly pyvo?: string; @@ -64,7 +78,229 @@ def rows(table): operation = request['operation'] answer = {'schema': 'cssearth-astroquery-answer@2', 'astroquery': astroquery.__version__, 'operation': operation} -if operation == 'mast-service': +if operation == 'vo-download': + import os, hashlib, tempfile, io, requests + from astropy.io.votable import parse + from astropy.io import fits + from pyvo.dal.adhoc import DatalinkResults, SodaQuery + from pyvo.dal.query import DALQuery + class TransferSession(requests.Session): + def request(self, method, url, **kwargs): + kwargs.setdefault('timeout', (15, 45)) + return super().request(method, url, **kwargs) + session = TransferSession() + class TransferFailure(Exception): + def __init__(self, code, message): + self.code = code + super().__init__(message) + response = None + try: + descriptor = request.get('descriptor') + if descriptor: + pin = descriptor['file'] + with open(pin['path'], 'rb') as f: raw = f.read() + if len(raw) != pin['bytes'] or hashlib.sha256(raw).hexdigest() != pin['sha256']: raise TransferFailure('identity', 'Descriptor pin changed') + links = DatalinkResults(parse(io.BytesIO(raw)), url=request['url']) + row = links[descriptor['row']] + if row.service_def != descriptor['serviceId']: raise TransferFailure('identity', 'Service row changed') + resource = links.get_adhocservice_by_id(row.service_def) + standard = next((p.value for p in resource.params if p.name == 'standardID'), None) + if standard != 'ivo://ivoa.net/std/SODA#sync-1.0': raise ValueError('Not synchronous SODA') + bound = SodaQuery.from_resource(row, resource, session=session) + for key, v in request['parameters'].items(): + if key not in ('BAND', 'CIRCLE') and (key not in bound or str(bound[key]) != str(v)): + raise ValueError('Fixed or referenced dataset parameter changed: ' + key) + query = SodaQuery.from_resource(row, resource, session=session, **request['parameters']) + if query.queryurl != request['url']: raise TransferFailure('identity', 'Resolved SODA URL changed') + else: + if request['parameters']: raise ValueError('Direct access cannot accept subset parameters') + query = DALQuery(request['url'], session=session) + limit = request['byteLimit'] + if type(limit) is not int or limit < 1: raise ValueError('Invalid science byte limit') + response = query.submit() + os.makedirs(os.path.dirname(request['destination']), exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=os.path.dirname(request['destination']), suffix='.partial') + try: + with os.fdopen(fd, 'wb') as f: + if response.status_code == 204: raise TransferFailure('no-content', 'No science content (HTTP 204)') + if response.status_code in (401,403): raise TransferFailure('authentication', 'Archive authorization failed (HTTP ' + str(response.status_code) + ')') + response.raise_for_status() + size, mark, digest = 0, 10000000, hashlib.sha256() + for chunk in response.iter_content(chunk_size=1024 * 1024): + size += len(chunk) + if size > limit: raise TransferFailure('byte-limit', 'Science transfer byte limit exceeded') + f.write(chunk); digest.update(chunk) + if size >= mark: + print(f'VO product: {size / 1e6:.1f} MB', file=sys.stderr, flush=True) + mark = (size // 10000000 + 1) * 10000000 + if not size: raise TransferFailure('no-content', 'Empty science response') + length = response.headers.get('Content-Length') + if length and not response.headers.get('Content-Encoding') and int(length) != size: raise TransferFailure('interrupted', 'Incomplete science response') + if request.get('format', 'fits') == 'fits': + try: + with fits.open(temporary, memmap=True) as hdus: + hdus.verify('exception') + if not any(h.header.get('NAXIS', 0) >= 2 and h.header.get('XTENSION', '').strip() not in ('TABLE','BINTABLE') for h in hdus): + raise ValueError('Response contains no supported FITS image/cube') + except Exception as error: raise TransferFailure('protocol', 'Invalid science FITS response: ' + str(error)) from error + # Never overwrite a previously acquired artifact at this destination. + os.link(temporary, request['destination']) + answer['transfer'] = {'file': {'path': request['destination'], 'bytes': size, 'sha256': digest.hexdigest()}, + 'effectiveUrl': response.url, 'contentType': response.headers.get('Content-Type'), 'etag': response.headers.get('ETag'), 'lastModified': response.headers.get('Last-Modified')} + finally: + response.close() + if os.path.exists(temporary): os.unlink(temporary) + except Exception as error: + status = response.status_code if response is not None else None + code = (error.code if isinstance(error, TransferFailure) else + 'interrupted' if isinstance(error, (requests.exceptions.ChunkedEncodingError, requests.exceptions.ContentDecodingError)) else + 'transport' if isinstance(error, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)) else + 'protocol' if isinstance(error, requests.exceptions.HTTPError) else + 'local-io' if isinstance(error, OSError) else 'protocol') + answer['failure'] = {'code': code, 'httpStatus': status, 'message': str(error)} +elif operation in ('vo-tap', 'vo-links', 'vo-parse'): + import io, os, hashlib, warnings, tempfile + from datetime import datetime, timezone + from astropy.io.votable import parse + from astropy.time import Time + from astropy.io.votable.tree import Param + from pyvo.dal.adhoc import DatalinkQuery, DatalinkResults + from pyvo.dal.tap import TAPResults + import requests + + class BoundedSession(requests.Session): + def request(self, method, url, **kwargs): + kwargs.setdefault('timeout', (15, 45)) + return super().request(method, url, **kwargs) + + limit = request['byteLimit'] + if type(limit) is not int or limit < 1: raise ValueError('Invalid metadata byte limit') + fetched = datetime.now(timezone.utc).isoformat() + if operation == 'vo-parse': + with open(request['file'], 'rb') as f: payload = f.read(limit + 1) + raw_path, effective_url, http_status = request['file'], request['url'], 200 + else: + session = BoundedSession() + query = (pyvo.dal.TAPService(request['service'], session=session).create_query(request['query'], maxrec=request['maxrec']) + if operation == 'vo-tap' else DatalinkQuery(request['url'], session=session, **request.get('parameters', {}))) + response = query.submit(post=operation == 'vo-tap') + try: + response.raw.decode_content = True + payload = response.raw.read(limit + 1) + effective_url, http_status = response.url, response.status_code + finally: response.close() + if len(payload) > limit: raise ValueError('VO metadata byte limit exceeded') + os.makedirs(request['directory'], exist_ok=True) + raw_path = os.path.join(request['directory'], hashlib.sha256(payload).hexdigest() + '.xml') + fd, staging = tempfile.mkstemp(dir=request['directory'], suffix='.partial') + try: + with os.fdopen(fd, 'wb') as f: f.write(payload) + os.replace(staging, raw_path) + finally: + if os.path.exists(staging): os.unlink(staging) + if len(payload) > limit: raise ValueError('VO metadata byte limit exceeded') + def lossless(v): + if isinstance(v, np.generic): return lossless(v.item()) + if isinstance(v, np.ndarray) and v.ndim == 0: + return None if np.ma.is_masked(v) else lossless(v.item()) + if isinstance(v, (list, tuple, np.ndarray)): return [lossless(x) for x in v] + if v is None or v is np.ma.masked or np.ma.is_masked(v): return None + if isinstance(v, int) and not isinstance(v, bool) and abs(v) > 9007199254740991: return {'integer': str(v)} + return value(v) + def attributes(obj, keys): + return {k: None if getattr(obj, k, None) is None else str(getattr(obj, k)) for k in keys} + def field(f): + return {'name': f.name or f.ID, 'id': f.ID, **attributes(f, ('datatype','arraysize','unit','ucd','utype','xtype','ref'))} + def parameter(p): + return {**field(p), 'value': lossless(p.value), 'constraints': {'minimum': lossless(p.values.min), 'maximum': lossless(p.values.max), + 'options': lossless(p.values.options), 'null': lossless(p.values.null)}} + metadata = {'schema': 'cssearth-vo-metadata@1', 'pyvo': pyvo.__version__, + 'raw': {'path': raw_path, 'bytes': len(payload), 'sha256': hashlib.sha256(payload).hexdigest()}, + 'effectiveUrl': effective_url, 'fetchedAt': fetched, 'httpStatus': http_status, + 'queryStatus': 'ERROR', 'fields': [], 'rows': [], 'resources': [], 'coordinateSystems': [], 'timeSystems': [], 'times': [], 'bindings': [], 'issues': []} + try: + with warnings.catch_warnings(record=True) as notices: + warnings.simplefilter('always') + votable = parse(io.BytesIO(payload), verify='warn') + all_resources = [] + def visit(resource): + all_resources.append(resource) + for child in resource.resources: visit(child) + for resource in votable.resources: visit(resource) + metadata['coordinateSystems'].extend(attributes(c, ('ID','system','equinox','epoch')) for c in votable.coordinate_systems) + metadata['timeSystems'].extend(attributes(t, ('ID','timeorigin','timescale','refposition')) for t in votable.time_systems) + for resource in all_resources: + metadata['resources'].append({'id': resource.ID, 'type': resource.type, 'utype': resource.utype, + 'parameters': [parameter(p) for p in resource.params], + 'groups': [{'name': g.name, 'parameters': [parameter(p) for p in g.entries if isinstance(p, Param)]} for g in resource.groups]}) + metadata['coordinateSystems'].extend(attributes(c, ('ID','system','equinox','epoch')) for c in resource.coordinate_systems) + metadata['timeSystems'].extend(attributes(t, ('ID','timeorigin','timescale','refposition')) for t in resource.time_systems) + statuses = [str(i.value).upper() for resource in all_resources for i in resource.infos if i.name == 'QUERY_STATUS'] + metadata['issues'].extend(str(i.content) for resource in all_resources for i in resource.infos if i.name == 'QUERY_STATUS' and i.value == 'ERROR') + result = DatalinkResults(votable, url=effective_url) if operation == 'vo-links' else TAPResults(votable, url=effective_url) + metadata['queryStatus'] = statuses[-1] if statuses else 'OK' + if metadata['queryStatus'] not in ('OK','OVERFLOW','ERROR'): metadata['queryStatus'] = 'ERROR' + table = result.resultstable + metadata['fields'] = [field(f) for f in table.fields] + metadata['rows'] = [{f.name or f.ID: lossless(row[f.ID or f.name]) for f in table.fields} for row in table.array] + if 'service_def' in [f.name for f in table.fields]: + links = DatalinkResults(votable, url=effective_url) + for i, link in enumerate(links): + if not link.service_def: continue + binding = {'row': i, 'serviceId': link.service_def, 'url': None, 'parameters': {}, 'error': None} + try: + descriptor = links.get_adhocservice_by_id(link.service_def) + bound = DatalinkQuery.from_resource(link, descriptor) + binding.update(url=bound.queryurl, parameters={str(k): lossless(v) for k, v in bound.items()}) + except Exception as e: binding['error'] = type(e).__name__ + ': ' + str(e) + metadata['bindings'].append(binding) + for row in metadata['rows']: + times = {} + for name in ('t_min','t_max','time_min','time_max'): + if name not in row: continue + times[name] = None + f = next(f for f in table.fields if (f.name or f.ID) == name) + referenced = [t for t in metadata['timeSystems'] if f.ref and t['ID'] == f.ref] + if f.ref and len(referenced) != 1: + metadata['issues'].append('Time ' + name + ': unresolved or ambiguous TIMESYS reference ' + f.ref) + continue + system = referenced[0] if referenced else None + try: + epn_time = request.get('timeModel') == 'epn-tap-2.0' and name in ('time_min', 'time_max') + row_scale = row.get('time_scale') if epn_time else None + row_refposition = row.get('time_refposition') if epn_time else None + def text(value, label, blank_is_missing=False): + if value is None or (blank_is_missing and isinstance(value, str) and not value.strip()): return None + if not isinstance(value, str) or not value.strip(): raise ValueError(label + ' is malformed') + return value.strip() + row_scale = text(row_scale, 'EPN time_scale', True) + row_refposition = text(row_refposition, 'EPN time_refposition', True) + system_scale = text(system.get('timescale'), 'TIMESYS timescale') if system is not None else None + system_refposition = text(system.get('refposition'), 'TIMESYS refposition') if system is not None else None + if system is not None and not system_scale: raise ValueError('TIMESYS has no time scale') + if system_scale and row_scale and system_scale.casefold() != row_scale.casefold(): + raise ValueError('TIMESYS time scale conflicts with EPN time_scale') + if system_refposition and row_refposition and system_refposition.casefold() != row_refposition.casefold(): + raise ValueError('TIMESYS reference position conflicts with EPN time_refposition') + scale = system_scale or row_scale or request.get('timeScale') or ('utc' if epn_time else None) + if row[name] is not None and scale and request.get('timeFormat'): + if str(f.unit) not in ('d', 'day'): raise ValueError('Time unit is not days') + origin = (system or {}).get('timeorigin') + expected_origin = 2400000.5 if request['timeFormat'] == 'mjd' else 0 + expected_token = 'MJD-origin' if request['timeFormat'] == 'mjd' else 'JD-origin' + if origin is not None and not (str(origin) == expected_token or float(origin) == expected_origin): raise ValueError('Declared time origin disagrees with table model') + times[name] = Time(row[name], format=request['timeFormat'], scale=scale.lower()).utc.isot + 'Z' + except Exception as e: metadata['issues'].append('Time ' + name + ': ' + str(e)) + metadata['times'].append(times) + metadata['issues'].extend(str(w.message) for w in notices) + except Exception as e: + metadata.update(queryStatus='ERROR', fields=[], rows=[], times=[], bindings=[]) + metadata['issues'].append(type(e).__name__ + ': ' + str(e)) + if http_status < 200 or http_status >= 300: + metadata['queryStatus'] = 'ERROR' + metadata['issues'].append('HTTP ' + str(http_status)) + answer['vo'] = metadata +elif operation == 'mast-service': from astroquery.mast import Mast from requests.exceptions import ConnectionError, Timeout, HTTPError try: @@ -154,6 +390,7 @@ export const runAstroqueryProcess: AstroqueryRunner = (python, env, request) => child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; if (request.operation === 'mast-download') forwardProgress(chunk); + if (request.operation === 'vo-download') for (const line of chunk.split('\n')) if (line.startsWith('VO product:')) process.stderr.write(`${line}\n`); }); child.on('error', fail); child.on('close', code => { @@ -170,7 +407,18 @@ export function parseAstroqueryAnswer(value: unknown, request: AstroqueryRequest throw new TypeError(`Astroquery answered with the wrong contract, version or operation.`); if (request.operation === 'mast-service' && raw.transportError !== undefined) throw new ArchiveTransportError(requireString(raw.transportError, 'MAST transport failure')); if (request.operation === 'tap-query' && raw.pyvo !== pyvoVersion) throw new TypeError('PyVO answered with the wrong version.'); - const expectsRows = request.operation !== 'mast-download' && !((request.operation === 'horizons-ephemerides' || request.operation === 'horizons-vectors') && request.raw); + const isVo = request.operation === 'vo-tap' || request.operation === 'vo-links' || request.operation === 'vo-parse'; + const vo = isVo ? parseMetadata(raw.vo) : undefined; + if (request.operation === 'vo-download' && raw.failure !== undefined) { + const failure = requireRecord(raw.failure), code = requireString(failure.code), status = failure.httpStatus; + if (!['authentication','no-content','byte-limit','protocol','transport','interrupted','identity','local-io'].includes(code) || + status !== null && (typeof status !== 'number' || !Number.isInteger(status) || status < 100 || status > 599)) throw new TypeError('Invalid VO failure state.'); + throw new VoAccessError(code as VoFailureCode, requireString(failure.message), status as number | null); + } + const t = request.operation === 'vo-download' ? requireRecord(raw.transfer, 'VO transfer') : undefined; + const nullable = (v: unknown) => v === null ? null : requireString(v); + const transfer = t ? { file: parsePin(t.file), effectiveUrl: requireString(t.effectiveUrl), contentType: nullable(t.contentType), etag: nullable(t.etag), lastModified: nullable(t.lastModified) } : undefined; + const expectsRows = !isVo && request.operation !== 'vo-download' && request.operation !== 'mast-download' && !((request.operation === 'horizons-ephemerides' || request.operation === 'horizons-vectors') && request.raw); if (expectsRows && raw.rows === undefined) throw new TypeError(`Astroquery ${request.operation} returned no rows field.`); const rows = raw.rows === undefined ? undefined : requireArray(raw.rows, 'Astroquery rows').map((row, index) => requireRecord(row, `Astroquery row ${index}`)); const files = raw.files === undefined ? undefined : requireArray(raw.files, 'Astroquery files').map((file, index) => requireString(file, `Astroquery file ${index}`)); @@ -180,6 +428,8 @@ export function parseAstroqueryAnswer(value: unknown, request: AstroqueryRequest const tapStatus = tap ? requireString(tap.queryStatus, 'TAP query status') : undefined; if (tap && tap.complete !== (tapStatus!.toUpperCase() === 'OK')) throw new TypeError(`TAP status ${tapStatus} contradicts its completeness boolean.`); return { schema: 'cssearth-astroquery-answer@2', astroquery: version, operation: request.operation, + ...(vo ? { vo } : {}), + ...(transfer ? { transfer } : {}), ...(request.operation === 'tap-query' ? { pyvo: pyvoVersion, tap: { queryStatus: tapStatus!, complete: tap!.complete as boolean } } : {}), ...(rows ? { rows } : {}), ...(files ? { files } : {}), ...(answerText === undefined ? {} : { text: answerText }) }; } diff --git a/tools/objects/astronomy-packages/cube-outputs.mts b/tools/objects/astronomy-packages/cube-outputs.mts new file mode 100644 index 0000000000..0d63ad7cce --- /dev/null +++ b/tools/objects/astronomy-packages/cube-outputs.mts @@ -0,0 +1,103 @@ +/** Explicit cube selections; Astropy NDData owns weighted arithmetic and uncertainty propagation. */ +export const CUBE_OUTPUT_PYTHON = String.raw` +from astropy.nddata import NDDataArray, StdDevUncertainty +from pathlib import Path +def science_array(name, shape, fill=0, dtype='float64'): + directory=request.get('arrayDirectory') + if directory: + path=Path(directory);path.mkdir(parents=True,exist_ok=True) + array=np.lib.format.open_memmap(path/(name+'.npy'),mode='w+',dtype=dtype,shape=shape) + array[:]=fill + return array + if np.prod(shape)>1000000: raise ValueError('Large extraction requires file-backed arrays') + return np.full(shape,fill,dtype=dtype) +class CubeOutput: + def __init__(self, request, shape, centers, edges, dataunit, error_valid): + self.kind=request['kind']; self.wave=centers; self.width=shape[-1]; self.height=shape[-2] + self.policy=request.get('uncertainty','omit') + if self.policy not in ('omit','independent'): raise ValueError('Choose uncertainty omit or independent') + if dataunit is None: raise ValueError('Aggregation requires qualified science units') + if centers is None: raise ValueError('Aggregation requires a qualified wavelength axis') + if self.policy=='independent' and not error_valid: raise ValueError('Independent propagation requires validated sample uncertainties') + self.selection_unit=dataunit; self.unit=str(dataunit) + self.definition=''; self.selection=request; self.weight_unit=None + if self.kind=='aperture-spectrum': + # A fixed footprint avoids changing the measured region at masked wavelengths. + def region(box): + if not isinstance(box,list) or len(box)!=4 or any(type(v)!=int for v in box): raise ValueError('Region requires integer X0,Y0,X1,Y1') + x0,y0,x1,y1=box + if not 0<=x00 + if self.kind=='band-image': + weights/=weights.sum(); self.definition='Wavelength-bin-weighted mean image' + elif self.kind=='feature-map': + continuum=request.get('continuum') + if not isinstance(continuum,list) or len(continuum)!=4: raise ValueError('Feature map needs two bracketing continuum bands') + a,b,c,d=continuum; left=band([a,b]); right=band([c,d]); low,high=request['band'] + if not b<=low0)|(right>0) + left/=left.sum(); right/=right.sum() + xleft=np.dot(left,centers); xright=np.dot(right,centers) + if not xright>xleft: raise ValueError('Continuum bands need distinct sampled wavelengths') + fraction=(centers-xleft)/(xright-xleft) + weights=weights-left*np.dot(weights,1-fraction)-right*np.dot(weights,fraction) + self.unit=str(dataunit*u.um); self.weight_unit=u.um + self.definition='Continuum-subtracted wavelength integral; positive emission, negative absorption' + else: raise ValueError('Unknown cube aggregation') + self.weights=weights; self.required=required + self.output_shape=(self.height,self.width) + self.values=science_array('values',self.output_shape) + self.sigma=science_array('sigma',self.output_shape) + self.mask=science_array('mask',self.output_shape,False,'bool') + + def add(self, channel, y, start, values, sigma, good): + stop=start+len(values) + if self.kind=='aperture-spectrum': + weights=np.zeros(len(values));needed=np.zeros(len(values),dtype=bool) + for (x0,y0,x1,y1),sign in self.regions: + if y0<=ylo: weights[lo:hi]=sign/((x1-x0)*(y1-y0));needed[lo:hi]=True + if not needed.any(): return + weights=weights[needed];values=values[needed];sigma=sigma[needed];good=good[needed];slot=channel + elif self.required[channel]: + weights=self.weights[channel]; slot=(y,slice(start,stop)) + else: return + samples=NDDataArray(np.where(good,values,0),mask=~good,unit=self.selection_unit, + uncertainty=StdDevUncertainty(np.where(good,sigma,0)) if self.policy=='independent' else None) + weighted=samples.multiply(NDDataArray(weights,unit=self.weight_unit)) + if self.kind=='aperture-spectrum': weighted=weighted.sum(operation_ignores_mask=False) + previous=NDDataArray(np.array(self.values[slot]),mask=np.array(self.mask[slot]),unit=weighted.unit, + uncertainty=StdDevUncertainty(np.array(self.sigma[slot])) if self.policy=='independent' else None) + combined=previous.add(weighted) + self.values[slot]=combined.data;self.mask[slot]=combined.mask + if self.policy=='independent':self.sigma[slot]=combined.uncertainty.array + + def result(self): + values,sigma=self.values,self.sigma + for i in range(self.output_shape[0]): + values[i]=np.where(self.mask[i],np.nan,values[i]) + sigma[i]=np.where(self.mask[i],np.nan,sigma[i]) if self.policy=='independent' else np.nan + return values,sigma,{'definition':self.definition,'unit':self.unit,'selection':self.selection, + 'arithmetic':'Astropy NDDataArray multiply/sum/add with StdDevUncertainty', + 'uncertaintyPolicy':self.policy,'maskPolicy':'All selected samples required; missing samples are not renormalized.', + 'uncertaintyLabel':'Conditional ±1σ (independent samples)' if self.policy=='independent' else 'Uncertainty not propagated (covariance unknown)'} +`; diff --git a/tools/objects/astronomy-packages/oracle-requirements.txt b/tools/objects/astronomy-packages/oracle-requirements.txt new file mode 100644 index 0000000000..e2efc6f670 --- /dev/null +++ b/tools/objects/astronomy-packages/oracle-requirements.txt @@ -0,0 +1,15 @@ +# Optional reference tools; install in a separate venv with requirements.lock as constraints. +asdf==5.4.0 +asdf-astropy==0.11.0 +asdf_coordinates_schemas==0.5.1 +asdf_standard==1.5.0 +asdf_transform_schemas==0.6.0 +asdf_wcs_schemas==0.5.0 +attrs==26.1.0 +gwcs==1.0.3 +jmespath==1.1.0 +ndcube==2.4.2 +photutils==3.0.0 +scipy==1.18.1 +semantic-version==2.10.0 +specutils==2.4.0 diff --git a/tools/objects/astronomy-packages/ownership.json b/tools/objects/astronomy-packages/ownership.json index c838beb51e..b1f83b4c04 100644 --- a/tools/objects/astronomy-packages/ownership.json +++ b/tools/objects/astronomy-packages/ownership.json @@ -7,49 +7,110 @@ "package": "pyvo", "version": "1.9.1", "operation": "tap-query", - "consumers": ["ALMA", "ESO", "Chandra", "KOA", "CADC", "SIMBAD", "OiDB", "VizieR"] + "consumers": [ + "ALMA", + "ESO", + "Chandra", + "KOA", + "CADC", + "SIMBAD", + "OiDB", + "VizieR" + ] }, { "mechanic": "MAST service queries and downloads", "package": "astroquery", "version": "0.4.11", "operation": "mast-service, mast-download", - "consumers": ["Hubble", "JWST"] + "consumers": [ + "Hubble", + "JWST" + ] }, { "mechanic": "ALMA product discovery", "package": "astroquery", "version": "0.4.11", "operation": "alma-data-info", - "consumers": ["ALMA"] + "consumers": [ + "ALMA" + ] }, { "mechanic": "VizieR cone search", "package": "astroquery", "version": "0.4.11", "operation": "vizier-region", - "consumers": ["stellar diameter discovery"] + "consumers": [ + "stellar diameter discovery" + ] }, { "mechanic": "JPL Horizons observer ephemerides and vectors", "package": "astroquery", "version": "0.4.11", "operation": "horizons-ephemerides, horizons-vectors", - "consumers": ["Chandra", "Hubble", "Juno"] + "consumers": [ + "Chandra", + "Hubble", + "Juno" + ] }, { "mechanic": "PDS4 Registry product discovery and pagination", "package": "pds.peppi", "version": "0.5.0", "operation": "resolve-target, discover-target, discover-product", - "consumers": ["PDS observation qualification"] + "consumers": [ + "PDS observation qualification" + ] }, { "mechanic": "Supported PDS3 and PDS4 product decoding", "package": "pdr", "version": "1.4.4", "operation": "decode-product", - "consumers": ["PDS observation qualification"] + "consumers": [ + "PDS observation qualification" + ] + }, + { + "mechanic": "Product qualification units and FITS spectral WCS", + "package": "astropy", + "version": "8.0.1", + "operation": "science.mts: fits, units", + "consumers": [ + "source product qualification", + "reducer product qualification" + ] + }, + { + "mechanic": "Scientific image coordinates, display normalization, quantity axes and portable FITS/ECSV outputs", + "package": "astropy", + "version": "8.0.1", + "operation": "plots.mts: WCSAxes, ImageNormalize, quantity_support, FITS, QTable", + "consumers": [ + "telescope native and derived image/spectrum exports" + ] + }, + { + "mechanic": "Static scientific output figures", + "package": "matplotlib", + "version": "3.11.2", + "operation": "plots.mts", + "consumers": [ + "telescope native and derived image/spectrum exports" + ] + }, + { + "mechanic": "Planetary image navigation and surface resampling", + "package": "planetmapper", + "version": "1.14.0", + "operation": "projection.mts", + "consumers": [ + "telescope project" + ] } ], "retained": [ @@ -66,12 +127,12 @@ { "mechanic": "SPICE kernel evaluation", "implementation": "tools/spice", - "reason": "The production TypeScript evaluator is preparation-time infrastructure shared by 22 consumers and is already checked against CSPICE. A partial SpiceyPy path would create two production answers for one operation." + "reason": "Existing preparation consumers retain their CSPICE-checked evaluator. PlanetMapper exclusively owns SPICE geometry inside the new telescope project route; results are not mixed between evaluators." }, { "mechanic": "streaming FITS and in-process WCS reads", "implementation": "tools/fits.mts and tools/objects/interferometry/fits-table.mts", - "reason": "Astropy remains the independent oracle. Moving 127 consumers across a process boundary would remove subregion streaming and does not close an archive or science capability gap." + "reason": "Existing in-process consumers retain subregion streaming. Astropy owns scientific metadata qualification through science.mts; those consumers do not implement that qualification contract." }, { "mechanic": "Spitzer archive discovery and byte retrieval", diff --git a/tools/objects/astronomy-packages/pds-client.mts b/tools/objects/astronomy-packages/pds-client.mts index e92ac82fcc..2439eeed9f 100644 --- a/tools/objects/astronomy-packages/pds-client.mts +++ b/tools/objects/astronomy-packages/pds-client.mts @@ -7,7 +7,7 @@ export type PdsPackageRequest = | { readonly operation: 'resolve-target'; readonly names: readonly string[] } | { readonly operation: 'discover-target'; readonly targetLid: string } | { readonly operation: 'discover-product'; readonly targetLid: string; readonly lidvid: string } - | { readonly operation: 'decode-product'; readonly labelPath: string }; + | { readonly operation: 'decode-product'; readonly labelPath: string; readonly arrayDirectory?: string }; export interface PdsPackageAnswer { readonly schema: 'cssearth-pds-package-answer@1'; readonly peppi: string; readonly pdr: string; @@ -27,6 +27,15 @@ import pds.peppi as pep request = json.load(sys.stdin) operation = request['operation'] +def export_array(array): + if not request.get('arrayDirectory'):return {} + directory=Path(request['arrayDirectory']);directory.mkdir(parents=True,exist_ok=True) + name='native-'+str(len(structures))+'.npy' + array=np.ma.asarray(array) + values=np.asarray(array) if not np.ma.getmaskarray(array).any() else np.ma.filled(array.astype(float),np.nan) + np.save(directory/name,values) + return {'arrayFile':name} + answer = {'schema':'cssearth-pds-package-answer@1','peppi':version('pds.peppi'),'pdr':version('pdr'),'operation':operation} def value(item): @@ -102,34 +111,36 @@ elif operation == 'decode-product': valid = np.asarray(array.compressed()) valid = valid[np.isfinite(valid)] if not valid.size: raise ValueError(f'{key} has no finite samples') - structures.append({'name':key,'nativeMetadata':native_metadata(key),'shape':list(array.shape),'dtype':str(array.dtype),'elements':int(array.size),'finite':int(valid.size),'minimum':float(valid.min()),'maximum':float(valid.max())}) + structures.append({**export_array(array),'name':key,'nativeMetadata':native_metadata(key),'shape':list(array.shape),'dtype':str(array.dtype),'elements':int(array.size),'finite':int(valid.size),'minimum':float(valid.min()),'maximum':float(valid.max())}) if not structures: raise ValueError('PDS3 product has no supported numeric structure') answer['decoded'] = {'standard':'PDS3','metadata':{'scaling':'pdr get_scaled with special-value masking for arrays; tables as decoded by pdr','excludedNonScienceObjects':[key for key in data.keys() if 'HEADER' in key or key == 'HISTORY']},'structures':structures} json.dump(answer, sys.stdout, allow_nan=False, separators=(',',':')) sys.exit(0) root = ET.parse(label).getroot() def local(tag): return tag.rsplit('}',1)[-1] - special_constants = [(local(child.tag),(child.text or '').strip()) for node in root.iter() if local(node.tag) == 'Special_Constants' for child in node if child.text] + all_special_constants = [(local(child.tag),(child.text or '').strip()) for node in root.iter() if local(node.tag) == 'Special_Constants' for child in node if child.text] structures = [] for key in data.keys(): if key == 'label' or key.endswith('_HEADER') or key.startswith('HEADER_'): continue - array = np.asanyarray(data[key]) + native = np.asanyarray(data[key]) + numeric = np.issubdtype(native.dtype, np.number) + array = np.ma.asarray(data.get_scaled(key)) if numeric else native mask = np.ma.getmaskarray(array) - values = np.asarray(np.ma.filled(array, np.nan)) - numeric = np.issubdtype(values.dtype, np.number) + values = np.asarray(array) finite = np.isfinite(values) if numeric else None + matching = [node for node in root.iter() if local(node.tag).startswith('Array_') and + any(local(child.tag) == 'local_identifier' and (child.text or '').strip() == key for child in node)] + special_constants = [] if len(matching)!=1 else [(local(child.tag),(child.text or '').strip()) for node in matching[0].iter() if local(node.tag)=='Special_Constants' for child in node if child.text] special = np.zeros(array.shape, dtype=bool) if numeric: for _, text in special_constants: try: - constant = np.array([int(text,16)], dtype=np.uint32).view(np.float32)[0] if text.lower().startswith('0x') and values.dtype.itemsize == 4 else float(text) - special |= values == constant + constant = np.array([int(text,16)], dtype=np.uint32).view(np.float32)[0] if text.lower().startswith('0x') and native.dtype.itemsize == 4 else float(text) + special |= np.asarray(native) == constant except (ValueError, OverflowError): pass - valid = finite & ~special if numeric else None - matching = [node for node in root.iter() if local(node.tag).startswith('Array_') and - any(local(child.tag) == 'local_identifier' and (child.text or '').strip() == key for child in node)] + valid = finite & ~special & ~mask if numeric else None units = [] if len(matching) != 1 else [(child.text or '').strip() for element in matching[0] if local(element.tag) == 'Element_Array' for child in element if local(child.tag) == 'unit'] - structures.append({'name':key,'nativeMetadata':{'unit':units[0] if len(units) == 1 else None},'shape':list(array.shape),'dtype':str(array.dtype),'elements':int(array.size),'masked':int(mask.sum()),'special':int(special.sum()), + structures.append({**(export_array(np.ma.array(values,mask=~valid)) if numeric else {}),'name':key,'nativeMetadata':{'unit':units[0] if len(units) == 1 else None},'shape':list(array.shape),'dtype':str(array.dtype),'elements':int(array.size),'masked':int(mask.sum()),'special':int(special.sum()), **({'finite':int(valid.sum()),'minimum':float(values[valid].min()),'maximum':float(values[valid].max())} if numeric and valid.any() else {})}) def first(name): node = next((node for node in root.iter() if local(node.tag) == name), None) @@ -184,7 +195,7 @@ elif operation == 'decode-product': 'centerFilterWavelength':first('center_filter_wavelength'),'bandwidth':first('bandwidth'),'spectralBins':spectral_bins(),'opticalFilters':optical_filters(), 'mapProjection':first('map_projection_name'),'longitudeDirection':first('longitude_direction'), 'pixelResolutionX':field_with_unit('pixel_resolution_x'),'pixelResolutionY':field_with_unit('pixel_resolution_y'), - 'specialConstants':[{'kind':kind,'value':text} for kind,text in special_constants],'references':refs},'structures':structures} + 'specialConstants':[{'kind':kind,'value':text} for kind,text in all_special_constants],'references':refs},'structures':structures} else: raise ValueError(f'Unsupported PDS package operation: {operation}') json.dump(answer, sys.stdout, allow_nan=False, separators=(',',':')) diff --git a/tools/objects/astronomy-packages/pds-client.test.mts b/tools/objects/astronomy-packages/pds-client.test.mts index 76c8c22741..917295c994 100644 --- a/tools/objects/astronomy-packages/pds-client.test.mts +++ b/tools/objects/astronomy-packages/pds-client.test.mts @@ -27,3 +27,15 @@ test('the PDS boundary validates every decoded structure', () => { assert.throws(() => parsePdsPackageAnswer({ schema: 'cssearth-pds-package-answer@1', operation: 'decode-product', peppi: '0.5.0', pdr: '1.4.4', decoded: { standard: 'PDS4', metadata: {}, structures: [{ name: 'image', shape: [2, 2], dtype: '>f8' }] } }, request), /elements/u); }); + +test('PDS4 missing constants are scoped to their own array',async()=>{ + const {mkdtemp,writeFile,rm}=await import('node:fs/promises');const {tmpdir}=await import('node:os');const {resolve}=await import('node:path'); + const {pdsPackages}=await import('./pds-client.mts');const dir=await mkdtemp(resolve(tmpdir(),'pds-array-mask-')); + try{ + const array=(name:string,offset:number,missing:boolean)=>`${name}${offset}2Last Index FastestUnsignedByteLine11Sample22${missing?'0':''}`; + const xml=`data.bin${array('SCI',0,false)}${array('OTHER',2,true)}`; + await writeFile(resolve(dir,'label.xml'),xml);await writeFile(resolve(dir,'data.bin'),Buffer.from([0,1,0,1])); + const answer=await pdsPackages({operation:'decode-product',labelPath:resolve(dir,'label.xml')}); + assert.equal(answer.decoded?.structures.find(s=>s.name==='SCI')?.finite,2);assert.equal(answer.decoded?.structures.find(s=>s.name==='OTHER')?.finite,1); + }finally{await rm(dir,{recursive:true,force:true});} +}); diff --git a/tools/objects/astronomy-packages/plots.mts b/tools/objects/astronomy-packages/plots.mts new file mode 100644 index 0000000000..0ec668e594 --- /dev/null +++ b/tools/objects/astronomy-packages/plots.mts @@ -0,0 +1,138 @@ +/** Astropy owns scientific serialization and axes; Matplotlib owns static rendering. */ +import { spawn } from 'node:child_process'; +import { astroqueryToolchain } from './toolchain.mts'; +import { requireRecord, requireString } from '../../source-values.mts'; +export const PLOT_PYTHON = String.raw` +import csv,json,sys,warnings +from pathlib import Path +import numpy as np +import astropy +from astropy import units as u +from astropy.io import fits +from astropy.table import QTable,MaskedColumn +from astropy.wcs import WCS +from astropy.wcs.utils import wcs_to_celestial_frame +from astropy.visualization import ImageNormalize,LinearStretch,quantity_support +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +r=json.load(sys.stdin); data=r['data']; out=Path(r['directory']); kind=data['kind'] +if 'arrays' in data: + values=np.load(out/'arrays'/data['arrays']['values'],mmap_mode='r');sigma=np.load(out/'arrays'/data['arrays']['sigma'],mmap_mode='r') +else:values=np.asarray(data['values'],dtype=float);sigma=np.asarray(data['sigma'],dtype=float) +if not np.isfinite(values).any(): raise ValueError('The requested output contains no usable samples') +if sigma.shape!=values.shape: raise ValueError('Uncertainty shape mismatch') +unit=u.Unit(data['unit']) if data['unit'] else None +unit_label=('dimensionless' if unit==u.dimensionless_unscaled else unit.to_string('latex_inline')) if unit is not None else 'unit not stated' +metadata={'kind':kind,'definition':data.get('definition','Native sampled values'), + 'selection':r['selection'],'uncertainty':data.get('uncertaintyPolicy','recorded'), + 'mask':'Non-finite values are missing; no interpolation', + 'source_file':Path(r['source']).name,'source_hdu':r['selection']['hdu'], + 'provenance':'output.product.json'} +presentation={'coordinates':{'kind':'pixel','reason':'No celestial WCS supplied'},'warnings':[]} +plt.rcParams.update({'font.family':'DejaVu Sans','font.size':10,'figure.facecolor':'#181b1f','axes.facecolor':'#181b1f','text.color':'#d5d7dc','axes.labelcolor':'#d5d7dc','xtick.color':'#b8bbc4','ytick.color':'#b8bbc4','axes.edgecolor':'#626770','svg.hashsalt':'cssearth-telescope-output'}) +files=['figure.png','figure.svg','values.csv'] +with (out/'values.csv').open('w',newline='') as file: + writer=csv.writer(file) + number=lambda n:float(n) if np.isfinite(n) else '' + if kind in ('image','band-image','feature-map'): + if values.ndim!=2:raise ValueError('Expected a two-dimensional image') + spatial=None + with warnings.catch_warnings(record=True) as notices: + warnings.simplefilter('always') + with fits.open(r['source'],memmap=True) as source: + hdu=source[r['selection']['hdu']] + if tuple(hdu.shape[-2:])!=values.shape:raise ValueError('Output grid differs from source WCS grid') + wcs=WCS(hdu.header,source) + if wcs.has_celestial: + used=np.flatnonzero(wcs.axis_correlation_matrix[[wcs.wcs.lng,wcs.wcs.lat]].any(axis=0)) + if np.array_equal(used,[0,1]): + spatial=wcs.celestial + presentation['coordinates']={'kind':'celestial','frame':str(wcs_to_celestial_frame(spatial)), + 'physicalTypes':spatial.world_axis_physical_types,'grid':'native; no reprojection'} + else:presentation['coordinates']['reason']='Celestial coordinates depend on axes beyond the exported image; no 2D WCS asserted' + product=spatial.to_fits(relax=True) if spatial is not None else fits.HDUList([fits.PrimaryHDU()]) + presentation['warnings']=[str(n.message) for n in notices] + product[0].data=values + header=product[0].header + if unit is not None:header['BUNIT']=unit.to_string('fits') or '1' + header['HIERARCH CSSEARTH KIND']=kind + header['HIERARCH CSSEARTH UNCERTAINTY']=metadata['uncertainty'] + header['HISTORY']=metadata['definition'] + header['HISTORY']='Derived product; selection and source evidence are in output.product.json' + mask=fits.ImageHDU((~np.isfinite(values)).astype('uint8'),name='MASK') + mask.header['HISTORY']='1 = missing output value; 0 = usable. Not original DQ bits.' + product.append(mask) + if np.isfinite(sigma).any(): + err=fits.ImageHDU(sigma,name='ERR') + if unit is not None:err.header['BUNIT']=unit.to_string('fits') or '1' + err.header['HISTORY']='Standard deviation; NaN means unavailable. See uncertainty policy in primary header.' + product.append(err) + product.writeto(out/'image.fits',checksum=True);product.close();files.append('image.fits') + fig,ax=plt.subplots(figsize=(8,5),layout='constrained',subplot_kw={'projection':spatial} if spatial is not None else {}) + lo,hi=float(np.nanmin(values)),float(np.nanmax(values)) + if kind=='feature-map':hi=max(abs(lo),abs(hi));lo=-hi + norm=ImageNormalize(vmin=lo,vmax=hi,stretch=LinearStretch(),clip=False) + cmap='RdBu_r' if kind=='feature-map' else 'viridis' + presentation['normalization']={'owner':'astropy.visualization.ImageNormalize','stretch':'linear','minimum':lo,'maximum':hi,'colormap':cmap,'missing':'transparent'} + stride=max(1,int(np.ceil(max(values.shape)/1600))) + preview=values[::stride,::stride] + presentation['preview']={'method':'nearest sample','stride':stride,'shape':list(preview.shape),'numericProducts':'full native grid; no downsampling'} + # Preserve source pixel centers, including the last partial stride, for WCSAxes. + shown=ax.imshow(np.ma.masked_invalid(preview),origin='lower',interpolation='nearest',cmap=cmap,norm=norm, + extent=(-stride/2,(preview.shape[1]-.5)*stride,-stride/2,(preview.shape[0]-.5)*stride)) + ax.set_xlim(-.5,values.shape[1]-.5);ax.set_ylim(-.5,values.shape[0]-.5) + fig.colorbar(shown,ax=ax,label=unit_label) + presentation['normalization'].update(minimum=float(norm.vmin),maximum=float(norm.vmax)) + if spatial is not None: + ax.coords.grid(color='#b8bbc4',alpha=.25,linestyle=':') + for index,physical in enumerate(spatial.world_axis_physical_types): + label={'pos.eq.ra':'Right ascension','pos.eq.dec':'Declination'}.get(physical) + if label:ax.coords[index].set_axislabel(label) + else:ax.set_xlabel('Image x (zero-based pixel)');ax.set_ylabel('Image y (zero-based pixel)') + title=r['target']+' — '+kind + if data['plane'] is not None:title+=f"; plane {data['plane']} ({data['wavelengthsMicrometres'][data['plane']]:.5g} µm)" + if kind!='image':title+=f"; {data['selection']['band'][0]:g}–{data['selection']['band'][1]:g} µm\n"+data['definition'].split(';')[0] + ax.set_title(title,fontsize=10);writer.writerow(['x_pixel','y_pixel','value','standard_deviation']) + for y in range(values.shape[0]): + for x in range(values.shape[1]):writer.writerow([x,y,number(values[y,x]),number(sigma[y,x])]) + elif kind in ('spectrum','aperture-spectrum'): + wave=np.asarray(data['wavelengthsMicrometres'],dtype=float) + if values.ndim!=1 or wave.shape!=values.shape or not np.isfinite(wave).all():raise ValueError('Spectrum coordinate mismatch') + table=QTable(meta=metadata) + table['wavelength']=wave*u.um + table['value']=MaskedColumn(values,mask=~np.isfinite(values),unit=unit) + table['standard_deviation']=MaskedColumn(sigma,mask=~np.isfinite(sigma),unit=unit) + table.write(out/'spectrum.ecsv',format='ascii.ecsv');files.append('spectrum.ecsv') + presentation['coordinates']={'kind':'spectral','owner':'astropy.visualization.quantity_support','wavelengthUnit':'um','valueUnit':data['unit']} + with quantity_support(): + fig,ax=plt.subplots(figsize=(8,5),layout='constrained') + plotted_values=values*unit if unit is not None else values + ax.plot(wave*u.um,plotted_values,color='#d5d7dc',lw=1) + if np.isfinite(sigma).any(): + low=(values-sigma)*unit if unit is not None else values-sigma + high=(values+sigma)*unit if unit is not None else values+sigma + ax.fill_between(wave*u.um,low,high,color='#d5d7dc',alpha=.2,label=data.get('uncertaintyLabel','Recorded ±1σ'));ax.legend(frameon=False,labelcolor='#d5d7dc') + title=f"{r['target']} — pixel ({data['x']}, {data['y']})" if kind=='spectrum' else f"{r['target']} — aperture {data['selection']['aperture']}\n{data['definition']}" + ax.set_xlabel('Wavelength ('+u.um.to_string('latex_inline')+')');ax.set_ylabel(unit_label);ax.set_title(title,fontsize=10) + if kind=='aperture-spectrum' and data['uncertaintyPolicy']=='omit':ax.text(.01,.02,data['uncertaintyLabel'],transform=ax.transAxes,fontsize=8) + writer.writerow(['wavelength_um','value','standard_deviation']) + for w,v,e in zip(wave,values,sigma):writer.writerow([w,number(v),number(e)]) + else:raise ValueError('Unknown plot kind') +presentation['png']={'background':'transparent','bounds':'tight','gutterInches':0.12} +fig.savefig(out/'figure.png',dpi=160,transparent=True,bbox_inches='tight',pad_inches=.12,metadata={'Software':'Astropy / Matplotlib; css.earth telescope selections'}) +fig.savefig(out/'figure.svg',bbox_inches='tight',pad_inches=.12,metadata={'Date':None,'Creator':'Astropy / Matplotlib; css.earth telescope selections'}) +plt.close(fig) +json.dump({'astropy':astropy.__version__,'matplotlib':matplotlib.__version__,'files':files,'presentation':presentation,'usable':int(np.isfinite(values).sum())},sys.stdout) +`; +export async function plotProduct(directory:string,target:string,data:Record,source:string,selection:Readonly>):Promise> { + const tc=await astroqueryToolchain(); + return new Promise((accept,reject)=>{ + const child=spawn(tc.python,['-c',PLOT_PYTHON],{env:{...process.env,...tc.env,MPLBACKEND:'Agg'},stdio:['pipe','pipe','pipe']}); + let out='',err='';child.stdout.setEncoding('utf8').on('data',text=>{out+=text;});child.stderr.setEncoding('utf8').on('data',text=>{err+=text;}); + child.on('error',reject);child.on('close',code=>{ + if(code!==0)return reject(new Error(`Plot failed: ${err.slice(-2000)}`)); + try{const result=requireRecord(JSON.parse(out));if(requireString(result.matplotlib)!=='3.11.2'||requireString(result.astropy)!=='8.0.1')throw new Error('Unexpected plotting package version');accept(result);}catch(error){reject(error);} + });child.stdin.end(JSON.stringify({directory,target,data,source,selection})); + }); +} diff --git a/tools/objects/astronomy-packages/projection.mts b/tools/objects/astronomy-packages/projection.mts new file mode 100644 index 0000000000..2bcb1449aa --- /dev/null +++ b/tools/objects/astronomy-packages/projection.mts @@ -0,0 +1,102 @@ +/** PlanetMapper owns navigation and resampling; no interactive viewer is started. */ +import { spawn } from 'node:child_process'; +import { astroqueryToolchain } from './toolchain.mts'; +import { requireRecord } from '../../source-values.mts'; + +export const PROJECTION_PYTHON = String.raw` +import json,sys,warnings +from pathlib import Path +import numpy as np +import astropy,planetmapper,spiceypy,pyproj,matplotlib +from astropy.io import fits +from astropy.time import Time +from astropy import units as u +from astropy.visualization import ImageNormalize,LinearStretch +from PIL import Image +matplotlib.use('Agg') +import matplotlib.pyplot as plt +r=json.load(sys.stdin); out=Path(r['directory']); g=r['geometry'] +spiceypy.kclear() +for kernel in g['kernels']:spiceypy.furnsh(kernel['file']) +with fits.open(r['image']) as f: + values=np.asarray(f[0].data,dtype=float); header=f[0].header.copy() + if values.ndim!=2 or values.size>1000000:raise ValueError('Projection needs a bounded two-dimensional measurement') + if 'ERR' not in f:raise ValueError('A body map requires supplied uncertainty; aggregate independent-sample uncertainty must be explicitly requested upstream') + sigma=np.asarray(f['ERR'].data,dtype=float) + if sigma.shape!=values.shape:raise ValueError('Uncertainty grid differs from the measurement') + if 'MASK' in f:values[np.asarray(f['MASK'].data)!=0]=np.nan + unit=u.Unit(header['BUNIT']) + if unit!=u.dimensionless_unscaled and not any(base==u.sr and power<0 for base,power in zip(unit.bases,unit.powers)): + raise ValueError('Surface projection accepts intensive values (dimensionless or per steradian), not flux per pixel') +with fits.open(r['source'],memmap=True) as f: + primary=f[0].header + if str(primary.get('TARGNAME','')).strip().casefold()!=r['target'].casefold():raise ValueError('Source target does not match the delivery; an explicit target association is required') + if str(primary.get('TELESCOP','')).strip().casefold()!=g['observer'].casefold():raise ValueError('Observer differs from the source telescope') + start=Time(primary['DATE-BEG'],format='isot',scale='utc'); end=Time(primary['DATE-END'],format='isot',scale='utc') + if end=0)&np.isfinite(emission)&(emission<=g['maximumEmissionDegrees']) +mapped[~valid]=np.nan;error[~valid]=np.nan +if not valid.any():raise ValueError('No supported surface samples within the emission limit') +hdr=fits.Header({'BUNIT':header['BUNIT'],'CTYPE1':'LON','CTYPE2':'LAT','CUNIT1':'deg','CUNIT2':'deg','CRPIX1':1.,'CRPIX2':1.,'CRVAL1':180/w,'CRVAL2':90-90/h,'CDELT1':360/w,'CDELT2':-180/h}) +hdr['HISTORY']='Planetocentric latitude; east-positive longitude; nearest source pixel; repeated map cells are correlated' +fits.HDUList([fits.PrimaryHDU(),fits.ImageHDU(mapped,header=hdr,name='VALUE'),fits.ImageHDU(error,header=hdr,name='SIGMA'),fits.ImageHDU(emission,name='EMISSION')]).writeto(out/'map.fits',checksum=True) +lo,hi=float(np.nanmin(mapped)),float(np.nanmax(mapped)); norm=ImageNormalize(vmin=lo,vmax=hi,stretch=LinearStretch()) +cmap=matplotlib.colormaps['viridis'].copy();cmap.set_bad('#333941') +rgba=cmap(norm(np.ma.masked_invalid(mapped)),bytes=True);Image.fromarray(rgba).save(out/'texture.png') +# Prepared polar plates use the same sample lookup and colour scale as the map. +n=128; yy,xx=np.mgrid[:n,:n]; xx=(xx+.5)/n*2-1;yy=(yy+.5)/n*2-1 +rho=np.hypot(xx,yy); angle=np.mod(np.degrees(np.arctan2(yy,xx)),360); polar=[] +for sign in [1,-1]: + latitude=sign*np.degrees(np.arccos(np.minimum(rho*np.sin(np.pi/36),1))) + rows=np.clip(((90-latitude)/180*h).astype(int),0,h-1);cols=np.mod((angle/360*w).astype(int),w) + pixels=rgba[rows,cols].copy();pixels[rho>1,3]=0;polar.append(pixels) +Image.fromarray(np.concatenate(polar,axis=1)).save(out/'poles.png') +plt.rcParams.update({'figure.facecolor':'#181b1f','axes.facecolor':'#333941','text.color':'#d5d7dc','axes.labelcolor':'#d5d7dc','xtick.color':'#b8bbc4','ytick.color':'#b8bbc4'}) +fig,ax=plt.subplots(figsize=(10,5),layout='constrained') +im=ax.imshow(np.ma.masked_invalid(mapped),extent=(0,360,-90,90),origin='upper',cmap=cmap,norm=norm,interpolation='nearest') +ax.set(xlabel='East longitude (degrees)',ylabel='Planetocentric latitude (degrees)',title=r['target']+' — '+r['quantity']+'\n'+g['registration']['method']+' registration; nearest sample; grey = unobserved') +fig.colorbar(im,ax=ax,label=header['BUNIT']);fig.savefig(out/'figure.png',dpi=160,transparent=True,bbox_inches='tight',pad_inches=.12);plt.close(fig) +observer_lon,observer_lat=obs.graphic2centric_lonlat(obs.subpoint_lon,obs.subpoint_lat) +west=float(-observer_lon)%360 +result={'units':header['BUNIT'],'bodyCode':int(obs.target_body_id),'radiusKm':float(obs.r_eq),'radiiKm':list(map(float,obs.radii)), + 'instrument':instrument,'observation':observation,'midTimeJd':float(middle.jd),'startTimeJd':float(start.jd),'endTimeJd':float(end.jd),'startIso':start.isot+'Z','endIso':end.isot+'Z','exposureSeconds':float((end-start).sec), + 'rangeKm':float(obs.target_distance),'subObserver':{'latitudeDegrees':float(observer_lat),'westLongitudeDegrees':west}, + 'disc':{'x':x0,'y':y0,'radius':r0,'rotation':rotation},'samplingArcsec':float(obs.target_diameter_arcsec/(2*r0)),'registration':g['registration'],'warnings':notices, + 'interpolation':'nearest','uncertainty':'Same source standard deviation; repeated output cells are correlated. Navigation, beam and temporal smearing errors are not included.', + 'shape':'SPICE reference ellipsoid; not terrain','coverageCells':int(valid.sum()),'normalization':{'minimum':lo,'maximum':hi,'colormap':'viridis','missing':'#333941'}, + 'software':{'PlanetMapper':planetmapper.__version__,'SpiceyPy':spiceypy.__version__,'pyproj':pyproj.__version__,'PROJ':pyproj.proj_version_str,'Astropy':astropy.__version__,'Matplotlib':matplotlib.__version__}} +(out/'navigation.json').write_text(json.dumps(result,indent=2)+'\n');json.dump(result,sys.stdout) +`; + +export async function projectWithPlanetMapper(request:Record):Promise> { + const tc=await astroqueryToolchain(); + return new Promise((accept,reject)=>{ + const child=spawn(tc.python,['-c',PROJECTION_PYTHON],{env:{...process.env,...tc.env},stdio:['pipe','pipe','pipe']}); + let stdout='',stderr='';child.stdout.setEncoding('utf8');child.stderr.setEncoding('utf8'); + child.stdout.on('data',chunk=>{stdout+=chunk;});child.stderr.on('data',chunk=>{stderr+=chunk;}); + child.on('error',reject);child.on('close',code=>{if(code!==0)reject(new Error(stderr.slice(-5000)||`PlanetMapper exited ${code}`));else{try{accept(requireRecord(JSON.parse(stdout)));}catch(error){reject(error);}}}); + child.stdin.on('error',reject);child.stdin.end(JSON.stringify(request)); + }); +} diff --git a/tools/objects/astronomy-packages/requirements.lock b/tools/objects/astronomy-packages/requirements.lock index 5239d71ae4..08fa5be99c 100644 --- a/tools/objects/astronomy-packages/requirements.lock +++ b/tools/objects/astronomy-packages/requirements.lock @@ -432,3 +432,614 @@ webencodings==0.6.1 \ --hash=sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910 \ --hash=sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b # via html5lib + +# Output plotting dependency; exact release hashes from PyPI. +matplotlib==3.11.2 \ + --hash=sha256:01dc8eaaab5a9fce9ff615eca82345728f289e4715b186ee10c6d85272fc26bb \ + --hash=sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575 \ + --hash=sha256:07d9b9fa60cd4c393692f50d0bb03123242ddf61c99bb0e95e75feb354e7c1a8 \ + --hash=sha256:116cdb0eb0eb5644fc98eb2975d4b4dd4ad35e5c8e6b851c22e6976f371f7ab5 \ + --hash=sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539 \ + --hash=sha256:1a3040b209f3968b4e84161df7b174f07a9fad33b0f2d7e48ea3bbd3075e2863 \ + --hash=sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710 \ + --hash=sha256:254d4ddb2fa8df3b4c689c0c306063aee10521df82cfb438185e499c75fe37c1 \ + --hash=sha256:2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f \ + --hash=sha256:30ec15d7eefee71de16b7c689b42ba49715a4644650b76a6c7c70d79daf24e91 \ + --hash=sha256:399fef672f7046ef7d6a57572b2a6f9845f3f5afcff04e7b2df7a363a9f42190 \ + --hash=sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2 \ + --hash=sha256:3da3bc0cbf7245e7db72cc6d29d12c5abef72cb73059c73b945f14e3545f3eb2 \ + --hash=sha256:3e8576f7c47e02fd4f21f44171302d2d1d58d4471d46da3d71fe8899d19539d9 \ + --hash=sha256:57b9ea60a835937c2012861923cbb91f47db8565775d326d1c42fc926aa10351 \ + --hash=sha256:5e1e923a3fc3326b99ec0a6ff1ab1338ac6c6cc62ad9d8a9c944197c7f8c6221 \ + --hash=sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b \ + --hash=sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c \ + --hash=sha256:79a258f58253dfa025af80a9e9bb228d75fced007f0e93ae7423fefbde81a74d \ + --hash=sha256:7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0 \ + --hash=sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292 \ + --hash=sha256:7ef7a53b66780e5d942923724f08577fbc5be1f7322da0f0f3f9dcaa45dd803d \ + --hash=sha256:854df8d7dfe9fdffcbaa6f39e44a6c24b409cb4d7561fbc09213b157d833f6a6 \ + --hash=sha256:894a9cbbecbe30ae6787d464df2e8fc7a8d475cfc68f87c3029f7c11152899b3 \ + --hash=sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71 \ + --hash=sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54 \ + --hash=sha256:a24d5fd36e4f0e742c3851dcd20810e56a95633a342e4bf6cb591c678e8fe61f \ + --hash=sha256:a6939df7567114b6bac7f4c5e06c84a67f197c1b2f2e4b234d4eecb3bfec9482 \ + --hash=sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943 \ + --hash=sha256:af2661f6ac6bbd1d081996f54fdd9385715c625ace8cec0869055c0cfbf38981 \ + --hash=sha256:bbf1062991d826ed27e2144f3afa4461afbb8ff56e8f703043e191a8163b1ee9 \ + --hash=sha256:bc067c462a86f0e57bf52fc6e058d90171a5420007dac00c46e90153050c69a7 \ + --hash=sha256:bf3fe71fbfb8ec0e310e0bc8537c3405a01f38f25f9394ed2135e6202fed542b \ + --hash=sha256:c0b83f044ce10a98027b105b3931548719a6e8c7ef986b4362651e0b5367c8dc \ + --hash=sha256:c27e577ece613ea12a0790e00b4eb80d901c59a3e16cad474f31d8b1529690b9 \ + --hash=sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43 \ + --hash=sha256:c9721f81275499da1feeb36a2cf8192ea086283b3bd16b7dc4c9d7aedb7396d6 \ + --hash=sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc \ + --hash=sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4 \ + --hash=sha256:cf41ecd1b0c0b6f7177ed965a54c2afbe888715c7cf6054dc12d53bc1494002c \ + --hash=sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869 \ + --hash=sha256:d480038c83691532ed52ff3147db51fa902fc78cb2d8349993a1cdb684435bff \ + --hash=sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27 \ + --hash=sha256:e43b188f0a5b75447bcc197728258166aa64365770ed1caa36595a5e1ca4bbba \ + --hash=sha256:e60cf3047a51edecdc4535a9196bbd6a732936b8e9f8184aabf4d16165884aaf \ + --hash=sha256:eac4b07d4e3743b172451e122ea964f72f152879d4f9adcf3f3d33e518f12ead \ + --hash=sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9 \ + --hash=sha256:ecea603dd2fbf8242fd31a305a8b12a4ece2de28096870c65fdd0d1e35b8d9a6 \ + --hash=sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261 \ + --hash=sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2 \ + --hash=sha256:f25446b2981717dca9786bac841cb3fd7efb568e3e3c755dd980481c5cb9228d \ + --hash=sha256:f2ac30cf5eb5dff1b584627ae0b0e1186551a4f69ae3c75073911da497a29170 \ + --hash=sha256:fea03cf56568cc1cba08b470be6a0559e71c3a5b688d54b7179bb35ba23d0821 + +# Output plotting dependency; exact release hashes from PyPI. +contourpy==1.4.0 \ + --hash=sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c \ + --hash=sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d \ + --hash=sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c \ + --hash=sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de \ + --hash=sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d \ + --hash=sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b \ + --hash=sha256:196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814 \ + --hash=sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1 \ + --hash=sha256:20da2f86bfaed60dbba729fe738c8241b1dd22b6cf7e8cbbf30df290bd04ba28 \ + --hash=sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c \ + --hash=sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55 \ + --hash=sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f \ + --hash=sha256:3e0a2392533e5e2abdf277c951047516acc8d2f3c42b2dd9dc34c907c9ebabf2 \ + --hash=sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db \ + --hash=sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d \ + --hash=sha256:423bd5f4b3f11d54a8c597234e513382e3454bb106b8ec7ae32ba4ed63f8d99b \ + --hash=sha256:432e89f835cf01f8d2123a130a3126b17ee6e0348e4d62c0973872b30aa873bf \ + --hash=sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0 \ + --hash=sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9 \ + --hash=sha256:4bffcb2e8cc5c0e837631acab59dd04265fdac376550e7c3e310523877c68cb7 \ + --hash=sha256:510f7d93d94cf6ebf4e2cd0640bea16ab8affac7547230175b6745b596ce0599 \ + --hash=sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2 \ + --hash=sha256:5545ff38229c15d11d822fc6f7415b932da9f4975f53e305b7b96ebfb01f889c \ + --hash=sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c \ + --hash=sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1 \ + --hash=sha256:618137ec5778fb76d494c9ec854b104a377fddba0d128bef7136cce2c3bf0df8 \ + --hash=sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76 \ + --hash=sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45 \ + --hash=sha256:6507a016976a75a15aee47809a34605de6244ccc056e6164bf9fb14b27eecad4 \ + --hash=sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67 \ + --hash=sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c \ + --hash=sha256:6f963ea9f0d68d2b6a02d861e3c0aae09ae25ba21f243fcf281e9fed468b078d \ + --hash=sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf \ + --hash=sha256:758e7496cec3195fc28a28945eab3ceef348c47fb95c7afc2eca2f3901209c8c \ + --hash=sha256:78ed5c5f962b3e156109f0531b174a65ba80d13cc681a70227b12524c162e184 \ + --hash=sha256:79b06c60d5e569ce12c5da8f0c51217cdc484386e2349fa717c30fafb56d2871 \ + --hash=sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561 \ + --hash=sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842 \ + --hash=sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b \ + --hash=sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591 \ + --hash=sha256:90feb8da006803a95ba573fddc9536357d0c3faddc3a4c9e22816ca49af52878 \ + --hash=sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d \ + --hash=sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b \ + --hash=sha256:9c0e07c691f3b3321913ed9b8161c50ea5f77fadd006f52f5e755359b0dcbfc5 \ + --hash=sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b \ + --hash=sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f \ + --hash=sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1 \ + --hash=sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07 \ + --hash=sha256:a5e2bb871b90cd7a52bdee63bf80ef7acb46398d27efc66d7679240016efc5aa \ + --hash=sha256:abd0f7e51ecc52bf8c95713bd80b1c17c516a71fa391c54475308a4377903c2b \ + --hash=sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114 \ + --hash=sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad \ + --hash=sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c \ + --hash=sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23 \ + --hash=sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8 \ + --hash=sha256:c8be3392b84cf43373a488874dc1ebf46f60784a70977f92586e18019d71c7ed \ + --hash=sha256:c927ec747633e68c9920bcaad48b0bcefe648812b822f18c34e2d0e2270a3d2a \ + --hash=sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca \ + --hash=sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1 \ + --hash=sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909 \ + --hash=sha256:de503609fdb71f597634ca64fb4bd2f13d27c97f140e912b1e8ed93544840df0 \ + --hash=sha256:e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea \ + --hash=sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6 \ + --hash=sha256:ea09dc704029930cbd097f75cb0cc1db9852adbcf1a151fac7e9559f7bcbfd19 \ + --hash=sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49 \ + --hash=sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6 \ + --hash=sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70 \ + --hash=sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3 \ + --hash=sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c \ + --hash=sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707 + +# Output plotting dependency; exact release hashes from PyPI. +cycler==0.12.1 \ + --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \ + --hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c + +# Output plotting dependency; exact release hashes from PyPI. +fonttools==4.65.0 \ + --hash=sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680 \ + --hash=sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778 \ + --hash=sha256:06273c71e692caf5989c0437ca50875a5e49e216ddf653228fe9bb35bdc82c0f \ + --hash=sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358 \ + --hash=sha256:0aa8901db22875c831d6a91796549590d7e747da37438f38b69d771b668be445 \ + --hash=sha256:0d2a9892fdb3b7e2d0f4174e3b907d226ff83698249762eeefce08ec5b2de1dd \ + --hash=sha256:22cb846d35d278235ef3b7e947c6040b2057d72e8305a314f21d5342eca49040 \ + --hash=sha256:2e4a380ca40d3a5372e31b340f0da0d53b4583aadbb8e41f6a516afa69c509a4 \ + --hash=sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f \ + --hash=sha256:36fca8efc46b5adfca327c666e739fc05b7a7a6ef17840230f81b22f53230f61 \ + --hash=sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d \ + --hash=sha256:41f684ee6212e411196ab054f8308faf6605f154950e6f4686fb8f2103d624b0 \ + --hash=sha256:4a5db8442453da4b6f43ad325879381b726bf2238a2253efd9584be21a2cefc2 \ + --hash=sha256:52ea9d2a8385075770db74d5e5718fa80b2222bb4fc62856a377dd2865ca8848 \ + --hash=sha256:580eb68ff7bd6954a7a76afddd864bfc66eaaf5f5c20dd6ead9186d0055a4ffe \ + --hash=sha256:5a977e3645dbffaee924209828aa702a215f7ff68bc08010740c10c723787e62 \ + --hash=sha256:5c1700a60e4ff23a0425d5a64abf43d092e6b55071354825781faf255904dcb4 \ + --hash=sha256:6031e77b3fb8c765055ba2b8bd8dcb17030f3bf2484c448b472fdedf4460ba80 \ + --hash=sha256:6275863dad195ee34b6e0ca3fc61c74096bc37e5d6fb8e049f4d68d65865a2b7 \ + --hash=sha256:62c5e42c79449def957adf8a9a65a43018efa7e2a6bc6baa3afe955e0d5fb2ab \ + --hash=sha256:64c9b26816415b5e3d899e9077109d327b22140fe3c4066644d8cdbad5bb1569 \ + --hash=sha256:661bd91c4be13721408b2d4b67a9b3fa7736713adc9a6c9780c9c60fc7959f90 \ + --hash=sha256:673e2b3ac4ac8e4f3607d390ecc5a606e5db5c4e88fb4cb2999593efb65afea2 \ + --hash=sha256:6813cc1e2e883bd6c15b3e04f72c78dc65fdc4ca861063adf5f341fbaec2ca62 \ + --hash=sha256:6d815734e7fede0ad1f233f23f0f191cbe8fc64762ff041e589bc0f78e0b2397 \ + --hash=sha256:6dd6243f60e2d6160c2966e1e14020dc261ffd741b69a2e4ca8bfd051592e4b7 \ + --hash=sha256:71e4c67b6196a2f447f46476fd2302604721617f5e0a21b0988bdd87b6bb9687 \ + --hash=sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f \ + --hash=sha256:7830e9fa3bebc44dbc27ff44d8201def30ea5c48a773696d58e69e6bcd9cd5d4 \ + --hash=sha256:7a03cff943b204a90bf3d1c04c97b9509a8aa0ee99e2e544084ca43ad995975b \ + --hash=sha256:7a18b2ffd44249fe84289253197aa65ad4f2de554c0d381f18b1f5939bc6bc60 \ + --hash=sha256:7aa0518b45ff5286ad56f063938db3add3816e899aab58d782b3f9a252523caa \ + --hash=sha256:830f91327ca83bfc1278e7060068a498938f84d05dc4869675486f84f55d4fe1 \ + --hash=sha256:834962fd7cf21c58e81ac50a59e6ed2306f9df5e3dd481dad1cd7d2c4c60b773 \ + --hash=sha256:8aa1291e4c767abf1b0b79ca2d6895f7c0b661d9d95d03b5791c883a9d1e1f08 \ + --hash=sha256:8ae1846b0f192fd485d26a455af19b8f5cf05aff08f9836f533913d8fcea133c \ + --hash=sha256:8b7bb52817a24731d2e4f4df0e71fdde05e6c806c8f8f1517b015d142fdacfa5 \ + --hash=sha256:8e44a34d91b3c793879767eb115867ced74d2eb94974e64e72fe9e2eea71cf1a \ + --hash=sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6 \ + --hash=sha256:93a73af2075036d36d7fbf856779c56a1b3b86ffcdae6abede7596604c42c156 \ + --hash=sha256:9db2cb95847c18eef74a4ef0fe257a893ae3f4b0395f4866e2f426ab07f3d804 \ + --hash=sha256:9f201796c8e24e657be77c16fa664e798a46122144217f90838982937a964f0a \ + --hash=sha256:a3991732c87b3f054a2a8cf86dd0d602833fa8cb37c911503173771646e1013d \ + --hash=sha256:a6c8d184e523580a7c55d21cde37176a3c91cb539cf06c2aa36ffc634fd75296 \ + --hash=sha256:aa50dd7b9baf75e2bbd43401fc0d237f7a94a8ad2e0c57ea97160fc631af5eb0 \ + --hash=sha256:aecc899fdbf9ecbf728f8977977e2e1043ee4d70c257124c8fa4cbcf796fcd83 \ + --hash=sha256:aff640a4fcb021fa83f9879d5bfa115b6931522dae991a24faa75888bd6aeff6 \ + --hash=sha256:b11d8a4a0c3ca74bbd4c105b7ef82501945c939e6096d9934ec7d288cdf5aaa9 \ + --hash=sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c \ + --hash=sha256:bbd9faf777a9deb6790df4f2b0be611857c45fe86605e840d7154a028d828af7 \ + --hash=sha256:be9b9a95ed0af03375e99020e921c4bc6b41fad10e053dea7acad370521a3c46 \ + --hash=sha256:c130be2232e3caf8d2b476854ea78421ec1642917ff5ab695284bac31bbb072b \ + --hash=sha256:c779d838815b91889c95ed64c9be5950ad5a683279f91aeb23384cb757ddc6a3 \ + --hash=sha256:ca2b02d74e9ad7e21a1d11e4701425800a4b0c63cf90486e60258262feccbcbf \ + --hash=sha256:d0d25027ade65ec46b13c0436e51bcb7c5171a4ea255a5e7a8d0d1d3ab4cffd7 \ + --hash=sha256:d8ffd2f62b402180b0edae8f86a071f583970e2177143117db5cf4c52da60079 \ + --hash=sha256:d9484b7ee1b49b6b8a0231c849f3983723dec29e3a7366d9b1b02f4036f71944 \ + --hash=sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e \ + --hash=sha256:dc87a9f846bec83c3795804f62b4632716d46e3522869a3dd9cd44a5d245b006 \ + --hash=sha256:e2c21772fcf70325189707b19f346812690bb1b0bd7e207e6ac205244806b303 \ + --hash=sha256:e3944e0bdba42effb71959e43d91b599326b02b59c78310d5675e8a75525e7d8 \ + --hash=sha256:e5ceccaf2e57d83b753a2b5db5d94aa0a8071886d4afebd2d520c9683e6bef0e \ + --hash=sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5 \ + --hash=sha256:fb53892b570f7f1f0055e75fc4de32673e32f749c4c8a606b63d5c436650e634 \ + --hash=sha256:fcf39949f56911348514b466714efa9118bec3d2be249e1c487263f7cda6edab \ + --hash=sha256:ffc918702661f1d74d2fbb2f5551036b64f6d2d743139e105289b694bcd16f54 + +# Output plotting dependency; exact release hashes from PyPI. +kiwisolver==1.5.1 \ + --hash=sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0 \ + --hash=sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a \ + --hash=sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6 \ + --hash=sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19 \ + --hash=sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0 \ + --hash=sha256:0a4faea5c6db201c6a21391d2ac926ea97acf7dacdbc3c417189e1adb1a00837 \ + --hash=sha256:0ba9527afc80ae3d7814ed98b6572d02bf85eaf48065678342c5f0c6dab7a8c7 \ + --hash=sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7 \ + --hash=sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c \ + --hash=sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c \ + --hash=sha256:16895f553ee6620a827d2da56b871f835fb70b9216cca5d188e885caf6e3bd23 \ + --hash=sha256:17851e5dad4484be0cbccbde3b15331deae036de9aebd45eed964487802b172f \ + --hash=sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1 \ + --hash=sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad \ + --hash=sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5 \ + --hash=sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208 \ + --hash=sha256:1983f0974a750a6f6556f368ba11105d1d8369c735b944747c9f12ae5aea7aae \ + --hash=sha256:1a7587dc335f2c0f5bd577fd0540bd16c66006bdb60f759a1059f025e6c4f071 \ + --hash=sha256:1acc7e5b7ef05e9da8bb70cd6c7c4513090213d2e1ad9720f599f0bf6c52aec5 \ + --hash=sha256:1d852545c4d0e35a72728d072cbaa59e2fa7dd84bdf01e068d670dd0ceb58eb6 \ + --hash=sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2 \ + --hash=sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483 \ + --hash=sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851 \ + --hash=sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316 \ + --hash=sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7 \ + --hash=sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951 \ + --hash=sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858 \ + --hash=sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0 \ + --hash=sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819 \ + --hash=sha256:2e10ae1bba1899188b33557c10d73affcc12033edd18adddb57d209039976a4c \ + --hash=sha256:3221f78211074f561c44ca42eac0619828171bec15a2c4cf6f7747d07df76e8e \ + --hash=sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38 \ + --hash=sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c \ + --hash=sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed \ + --hash=sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083 \ + --hash=sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46 \ + --hash=sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e \ + --hash=sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c \ + --hash=sha256:416ba7ff9f233b7036689bb5a3783537e838ad483f63558d2a800f75afe738b1 \ + --hash=sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a \ + --hash=sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85 \ + --hash=sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca \ + --hash=sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef \ + --hash=sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974 \ + --hash=sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e \ + --hash=sha256:5025e36fb4fb275cef0a4e30dbb11cb4ae61d1c83deb90189cb5d7e4cafd6b55 \ + --hash=sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85 \ + --hash=sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a \ + --hash=sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13 \ + --hash=sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38 \ + --hash=sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673 \ + --hash=sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567 \ + --hash=sha256:5daa1f19e097050b9c4d9a78fcc9263cb96c9dfae08037ddc1b7c4ad1889f2a2 \ + --hash=sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62 \ + --hash=sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046 \ + --hash=sha256:685929988b208a911f1285e2f8ed54210b0d681a3dc0f03e00d599d291986e7e \ + --hash=sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048 \ + --hash=sha256:6b92f60017dda7d877fdc546438b5e28f31c523264f49cf5a48c1d0ce1a0dfbc \ + --hash=sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2 \ + --hash=sha256:719a35fa1156db3640555f95ebb94f60a444e64d1c69626b0edef5df78eba225 \ + --hash=sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d \ + --hash=sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b \ + --hash=sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe \ + --hash=sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3 \ + --hash=sha256:7824b5e8bdbf0bccb4ccd37bbb115849a1dc45437fb4de8351385ed07c437ee0 \ + --hash=sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812 \ + --hash=sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c \ + --hash=sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9 \ + --hash=sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad \ + --hash=sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75 \ + --hash=sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761 \ + --hash=sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad \ + --hash=sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724 \ + --hash=sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f \ + --hash=sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa \ + --hash=sha256:8af9b142ad719ae3a911ebf616bc4b78b32bbab84d6a40d3ad2f129670509957 \ + --hash=sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127 \ + --hash=sha256:8de6f2a4ce7e7bd27d23dd94abf0ccafe0e0e5cc9c764b0577191f2c25f08f26 \ + --hash=sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8 \ + --hash=sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89 \ + --hash=sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378 \ + --hash=sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540 \ + --hash=sha256:95a02752aa032eef4aed01cda6d9b687c669bd0396bf4519eef8bba22a286720 \ + --hash=sha256:96c30002424670b5e1e46495c2b8cbffef39cf77c1d79e76462029d50339785b \ + --hash=sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330 \ + --hash=sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2 \ + --hash=sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810 \ + --hash=sha256:a5716a33bfabb2c6ce27b6cf03253467b3804f83e215f4d202685cf93c6c9874 \ + --hash=sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f \ + --hash=sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee \ + --hash=sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d \ + --hash=sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab \ + --hash=sha256:aa7d00b1700966d2917e54d278aba86897890ca9276dd8b76cf6446b6c181b92 \ + --hash=sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300 \ + --hash=sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c \ + --hash=sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a \ + --hash=sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444 \ + --hash=sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86 \ + --hash=sha256:b3d78f7bb2b9d9a30345be1474b9aaa8685430b54afb51ba3639b5c6c11e9ed6 \ + --hash=sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f \ + --hash=sha256:b69602970994a2ed8bbfa78c2f0394a7435226c6040489702d9f0a0ad0c07052 \ + --hash=sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450 \ + --hash=sha256:bad20d4c69c851c982a1e3606f4c293edfd5a87885786c50082412240c4b1ffd \ + --hash=sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1 \ + --hash=sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a \ + --hash=sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148 \ + --hash=sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1 \ + --hash=sha256:c3a4e41e3096bf1f0f1b76e2ffd6d828d6547f574f702d59bdbef7acfa59db9c \ + --hash=sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408 \ + --hash=sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8 \ + --hash=sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e \ + --hash=sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b \ + --hash=sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140 \ + --hash=sha256:cdaeeb6c350106df6bf9d873395973e5f066a9713200b72cd64f55d0a3eafab6 \ + --hash=sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294 \ + --hash=sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f \ + --hash=sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b \ + --hash=sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002 \ + --hash=sha256:d50de98e8d807dc31822fff96f50293163a62418eb65487a21b42713d72ed0b7 \ + --hash=sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88 \ + --hash=sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6 \ + --hash=sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612 \ + --hash=sha256:dc1a26b8e53395a01c2c611e58602fa47461f136fba7cd5542e6db6d64be1839 \ + --hash=sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524 \ + --hash=sha256:e05c2f7925f1d88778e53cb44f14e0223204a3bdd09a41664750363acfb1f2ef \ + --hash=sha256:e12dfea7f5fc2a34a9080efbf79c4c44eb380ec5b9c6fea09407e08f0d1e941d \ + --hash=sha256:e4e4523d6f336708d732516e6cfca7796cf3d96c9474eb5aecf6165f2f1fefc3 \ + --hash=sha256:e4e49f7e1a4e7191bdf9dc67a974db714501b1fc52c24324103d06a86abd5c08 \ + --hash=sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6 \ + --hash=sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076 \ + --hash=sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80 \ + --hash=sha256:f0a887b6565bbfe80efde2b7f6e8890d7d9bbdb11bdb17028a3690c32fe0621f \ + --hash=sha256:f0f4a42db92d6ec7677ab9d12830a2a8ec145a9c6d15db2b593466bc875c78d7 \ + --hash=sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a \ + --hash=sha256:f1d56ec54d257d05e0b50f5780d967540cd07beeaf9e5f645b26d50cce79f4d8 \ + --hash=sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255 \ + --hash=sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63 \ + --hash=sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a \ + --hash=sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b \ + --hash=sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd \ + --hash=sha256:febcce10f2bcdbb80b4ea919238a6a4ac13dbc4c7cadbe8d5d75c3682f8b5404 + +# Output plotting dependency; exact release hashes from PyPI. +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + +# Output plotting dependency; exact release hashes from PyPI. +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + +# Output plotting dependency; exact release hashes from PyPI. +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + +planetmapper==1.14.0 \ + --hash=sha256:669c879558c92b3fd4809473f0be40a7c426b3387e05fb7569dca78f6b036cb0 \ + --hash=sha256:ad23e1a3f86864eb072a9e571b6c7004a7de46cbccf7b46889d276e8c4fa5a3b + +photutils==3.0.0 \ + --hash=sha256:112ab07628f0b1d8bb6e70c926279a867e6bb57eea82f8141410b155a00e73d1 \ + --hash=sha256:1faa9da37a2fb46c2ef527bf2bba00df268553635758032497163ef2c5e83c4a \ + --hash=sha256:5e1ca2be9433e1e1f9f6d477e1e6c8029755caceefb4e349048955e3cb825529 \ + --hash=sha256:60767e0bc8ef963561a73252c878b9348450cab0a1a1a85729f621410cc4a395 \ + --hash=sha256:6e0ccb7361144f8f220c8d852c01678661bc7ed5a1e1378b39c8bf78a479efc4 + +scipy==1.18.1 \ + --hash=sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc \ + --hash=sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5 \ + --hash=sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123 \ + --hash=sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7 \ + --hash=sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd \ + --hash=sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239 \ + --hash=sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0 \ + --hash=sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb \ + --hash=sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35 \ + --hash=sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d \ + --hash=sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89 \ + --hash=sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5 \ + --hash=sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe \ + --hash=sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3 \ + --hash=sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89 \ + --hash=sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1 \ + --hash=sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305 \ + --hash=sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307 \ + --hash=sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28 \ + --hash=sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230 \ + --hash=sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2 \ + --hash=sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174 \ + --hash=sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba \ + --hash=sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66 \ + --hash=sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12 \ + --hash=sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d \ + --hash=sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0 \ + --hash=sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7 \ + --hash=sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82 \ + --hash=sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487 \ + --hash=sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168 \ + --hash=sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0 \ + --hash=sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f \ + --hash=sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729 \ + --hash=sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9 \ + --hash=sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3 \ + --hash=sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad \ + --hash=sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443 \ + --hash=sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d \ + --hash=sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314 \ + --hash=sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899 \ + --hash=sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23 \ + --hash=sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09 \ + --hash=sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf \ + --hash=sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa \ + --hash=sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87 \ + --hash=sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1 \ + --hash=sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315 \ + --hash=sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12 \ + --hash=sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4 \ + --hash=sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f \ + --hash=sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07 \ + --hash=sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298 \ + --hash=sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93 \ + --hash=sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265 \ + --hash=sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6 \ + --hash=sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331 \ + --hash=sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a \ + --hash=sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7 \ + --hash=sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218 \ + --hash=sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9 + +pyproj==3.8.0 \ + --hash=sha256:01a1601da9c6ad247a12d304f96f9e0b4ddd00b307636341c136442a70c5e218 \ + --hash=sha256:0556ce011e1530aea2084a12e39dc9c6ccd25a16c1db5457f39bf83161e7c301 \ + --hash=sha256:0b3ba65286a1ea401ca51fd35f2bd375f26fd29d517d5ee980159f9f739b2109 \ + --hash=sha256:0efefc85d3f262d4e5b43d0ffc4ea30e89881ed21feb281b1d1b1294423411ac \ + --hash=sha256:0fb11c0d6a7caa396275012a9bda461ddf2d209cf6edfe53c477fb9c71778672 \ + --hash=sha256:0ff22ad49d1f59e18a57384926aabcb0ee8bbe9213e5abe375fd18b1b6ace194 \ + --hash=sha256:1074ab4aac836cb0e211fcf0e36dda7d51126c7ce15062ed9007164c6ae93183 \ + --hash=sha256:1271c631c28c1d646c1e0b890bd691d1c4b736f9745a9d8a00f750fefd77de9c \ + --hash=sha256:19db3f429013d20d31cfc56b2db44246fe5c33514b320eaef09f71f1762836bb \ + --hash=sha256:2d4c49e27d404a95d244196fdadb6b0b0ba3c3cbb43a8d2a357fc579a36b7835 \ + --hash=sha256:2da8c0be5660e4f261bf1e63c1d51a8c503947af1c0a330e15e5cddec7ef1e5c \ + --hash=sha256:45d3abdf17a26396f86d353b957323d53e5fc9d9558bed311ae3b1bf6665448d \ + --hash=sha256:54750d95c7aa1cbe78ec7be522ceef9b82b8f6f185d26ca413995a953bf8f1e8 \ + --hash=sha256:653b49e2d5aa87c22c1c32520700ed8f394583a0174a6e69ceb280bdbee1b4e6 \ + --hash=sha256:6b34cec6bdd66b721980c003cc2a0fce0cab7949444ad037fc2a7ecb6f3b996c \ + --hash=sha256:7914e83760284e4d8d3b6b2a6845c21270a07f214bfd89344d45f9ab63e1b4b5 \ + --hash=sha256:79e222f6f8486d0af3ebc5544edecebf396492e38f46500f668c6c389a20e4df \ + --hash=sha256:859caa91bd9a1c614bac7de110f3a62f715e6c5a4fe9140c3fd6a0c5034e390d \ + --hash=sha256:8606ccbc110ce2e8cbe4c9ba6c3f24cd73f603795ac0fce1ceb8fc27a9bb786e \ + --hash=sha256:8cff207c2a92235f79bb2caab29790e1743776e02331143bc5de4224bd695911 \ + --hash=sha256:8e01abceec40fd8326637cc207a4da089a3c3f61e64001cbd86951c746c54085 \ + --hash=sha256:9d7f3526031ba810922b15eeab446667f02af4e78de49141e7f0d4a3c7e10ce1 \ + --hash=sha256:a02db72ac71f36d4da337e43e98f59ec216613d1bbc2aa1543a9488f3a2a17cc \ + --hash=sha256:a510721719b9e3f5964ad8235e3530bdd82a38093266ad037ee08f71fb9995e9 \ + --hash=sha256:a792112106471f97c3f74b51639068388c9ad0605cc1ca100f11fd0bf47a004e \ + --hash=sha256:ad96cf05cfea67e54c16b2119b29ea60ba3b3562643ed3e8a0ce0ecc55efb50e \ + --hash=sha256:b761da280804bb02574c3d950d5e56c47e2aec782d8a3e6714c9c10645cfd020 \ + --hash=sha256:ba53cff2c6e768f1b84844ff621291c5ab89ac8875bd8036ab6065aaf58cdbd9 \ + --hash=sha256:ba88a9b5bfb39a141361e6ccc2a06136383ea2757fbfe3e6df2dd1133ad55304 \ + --hash=sha256:bbf8a786ebfa9a904802dfde0e95e1325df8efbb573a19686c1937499a8f04e8 \ + --hash=sha256:c211c35bd8bbf6693fd2787bf8cb15bbac6fbdbad4f6495e9baf53e84923b1a8 \ + --hash=sha256:c6da4fedc9b86970cca828b871b3fcbf8f9ad2da353b0669445fd8c03c89a9f7 \ + --hash=sha256:c90bf55c42d3d5475958196bf7331b9aa87e1505af49570f126739ad7e808c1e \ + --hash=sha256:c9a4e37ce375a87a407e8771475680b757903be59cd41b29e60f90bd56fa6b65 \ + --hash=sha256:cb38a247201b26be0a2513262e0014847fba6a28400a921d26f6d23db924e345 \ + --hash=sha256:cd047cfb04e451b95ff8b91f824e641a54944fbf64dfef7946e4e8bad6f3f752 \ + --hash=sha256:d31e9ddbd0ffcb65fcd902ab726b26741c9a8e8b90b60844596fd5b67030b39c \ + --hash=sha256:d3a37e54316ebb90f5740aed4728f43cb563109dd4ef610d0a1bc7238666d2a2 \ + --hash=sha256:d5a408b215ef98c9ae19e58ec512360b8ad9b25f8792138f983a58d159ac7157 \ + --hash=sha256:d752eaaae639719abdb4d357008b4311c0931977f4ea0f019e79e4176ab243a7 \ + --hash=sha256:d7b542e249eb593c1af737b7124648868383b69744ab6a1a0a2ffd0113c997f4 \ + --hash=sha256:d7bd22f1d4f058db72b5f09d0fcc9a2346178ccf965139ca483edaa5c3a7f2d3 \ + --hash=sha256:d8e00ccd9195d9dbb968d490ae9e51c3a59378665506c05e36bfdb6493a38a69 \ + --hash=sha256:dba62da116d92a724723b6e206d792993486458369f65db2381f43564d0d2984 \ + --hash=sha256:dd5bc46f443466cf18418290ef6b69b604b706b24adf84f2ea1d32e58a2f7209 \ + --hash=sha256:dde9f238bb08f961c040ce7c6202ad5b841b508ece76eacfd8e18bc202778de7 \ + --hash=sha256:e2129d03506414ff22fa4bc2541100ce3bfd9b6d1d9af805e77631aae04b866b \ + --hash=sha256:e6e21972a28e65fdec4b794b6298206516853e03fc9ac40c14271c5dce6b0d32 \ + --hash=sha256:ea85d85e71d03d9b26d3bab78384226ebcfa71ac61bc25fcca5f50a144003575 \ + --hash=sha256:efa59725bba68bf97fa808b61302df32934acdceb6a5c92a8dd0e71dc266a876 \ + --hash=sha256:efe9f067215397d719df759083dda09b7012de99439003b12dff5109b339771d \ + --hash=sha256:f0540dea10339be8bb9f90c95685607b262547c53fa7645eef010965b80c4a90 \ + --hash=sha256:f6e7aa6b2da0c7ae6b6185cb9769bf2f941c5f9cd0c246e98e04fc6751621eac \ + --hash=sha256:faa68c0996bdd3fd997d86c676e758b72a96209ab14b7c5e8b8dcf3b23f85881 \ + --hash=sha256:fe26eb69e78f8b8d30ee67a6a6cfc172dd6085bc2c164a7143568138cb3a5a39 + +spiceypy==8.2.0 \ + --hash=sha256:00c2714d3dd67180794ad0b66735d6cac470e19627d6e99f9628a50b52133d90 \ + --hash=sha256:06ca746324c4adc8bdff2a79a918c9479b20d7e4dcd154af74ed83d1afe24714 \ + --hash=sha256:0f7ae4fe9e0e3e9bcf6036a161cf0f0197eecf268a7fc574af5e9d15bab1d3a7 \ + --hash=sha256:1548848424fc165ae46cb7c0ec029b786fe50d0da7f52eb1e71d3fa3b6bad5db \ + --hash=sha256:1647e510fb8484f4cf08e6a6b2c57ba806dc7e47a6d9301bdedbe56ff0745135 \ + --hash=sha256:277fde0ca5d4a7e7043b227242c835a61d83c3ee5895c324dc3dbf452a762da9 \ + --hash=sha256:44ddef79b8bb75ee992bf8d0984190b0765aca89a8c7e5a500dad9faa7e51564 \ + --hash=sha256:521ea59af51137138227602a4d0496e2317da73b9913be8b7da8d3d0777e9333 \ + --hash=sha256:55f05d33d02a1d36cc72ed3e875737e5bbd702c967d9ca5c5f487ad58f8a7e84 \ + --hash=sha256:601eba9762cc29cddbe6ec215a06bb42ae8e0f143be3f93a47f327ef989be9f1 \ + --hash=sha256:6957975d7160b2ea397cd96723b6f05f72f2bc4e68cfd602fe357436b7eaef7d \ + --hash=sha256:6f820a48207f5c7794ab484d6daa5a12b2f76ea78ee1364a7465c94d616f9e11 \ + --hash=sha256:744487aa627f6a49d5bc906719f54382e85028472fe7451fe5a7e3746443e8e8 \ + --hash=sha256:7b111a181c3d2d94c55986ccb8abe1470b792133ad2327c4ee532f47c517257f \ + --hash=sha256:875b32cacc9768426c29a75bde8385343c7e04e111f4d15651d12405bd6390fa \ + --hash=sha256:95ae46c2408e14edd39f2cda8bcbdc05c3d80dc4b292e6483c8f3351fc708b29 \ + --hash=sha256:9697ec159d1cd585d9da169739573bc09d8c499ea3d61b69772e4224fe83072e \ + --hash=sha256:9d67acd28c47bff75c785f6fea2b81a899b1c31eadc50472887097d50ef89945 \ + --hash=sha256:b0a00e825ea7b1bd000e3f0b3b8bdacbaa4bb1b3756490856f9ca6dd3488ab4f \ + --hash=sha256:b508acc2a39d1a4b80386b9b258d4eb06a093b74f1efb24734789fafbd2d192d \ + --hash=sha256:b7abbcc53320d74a1d1cf3e86f1c4bcd4bcc2e733a25c3b32fffbac677606ee1 \ + --hash=sha256:c8e808703c5d0889d4fc84a017e91fbb01bd61878ce063a3fddc104584e5e474 \ + --hash=sha256:c91643e43d0b1d0de5616ffd9a2a82a7e2ae7ea50c1bf5f740e89ed1e67d4996 \ + --hash=sha256:cdc6d8a0c4ded38d258d05d16d00c5132fc90c0e1bbb1df373f654ea704cac83 \ + --hash=sha256:dc3c8aa6fb6270a53053af7606b4351b3b0855c76882fd163d27d44c2cea41bf \ + --hash=sha256:e6c9be1b6f23d521551721b47196bc2609cca25abf6b809606c6eb956e0f1090 \ + --hash=sha256:f356e910bc5a080a898e234c077cd7b500e29bd81215f8063d27698d48a6b55d + +tqdm==4.70.1 \ + --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 \ + --hash=sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4 diff --git a/tools/objects/astronomy-packages/science.mts b/tools/objects/astronomy-packages/science.mts new file mode 100644 index 0000000000..37162855f8 --- /dev/null +++ b/tools/objects/astronomy-packages/science.mts @@ -0,0 +1,260 @@ +/** Astropy owns FITS units/WCS; css.earth owns association, conservative masks and claims. */ +import { spawn } from 'node:child_process'; +import { astroqueryToolchain } from './toolchain.mts'; +import { requireRecord, requireString } from '../../source-values.mts'; +import { CUBE_OUTPUT_PYTHON } from './cube-outputs.mts'; +export const SCIENCE_PYTHON = String.raw` +import json, sys, warnings +import numpy as np +import astropy +from astropy import units as u +from astropy.io import fits +from astropy.wcs import WCS +from astropy.coordinates import SkyCoord +from astropy.utils.exceptions import AstropyWarning +warnings.simplefilter('error') +request = json.load(sys.stdin) +${CUBE_OUTPUT_PYTHON} +def unit(text): + if text is None: return None + aliases = {'I/F':'1', 'DIMENSIONLESS':'1', 'DN':'adu', 'counts':'count', 'electrons':'electron'} + try: return u.Unit(aliases.get(text, text), parse_strict='raise') + except (ValueError, Warning): return None + +def merged(ranges): + out=[] + for a,b in sorted(ranges): + if out and a <= out[-1][1] + 1e-12*max(abs(a),abs(b)): out[-1][1]=max(out[-1][1],b) + else: out.append([a,b]) + return out + +def read_science(path): + # Section reads retain bounded memory, including scaled integer images. + with warnings.catch_warnings(record=True) as format_warnings: + warnings.simplefilter('always', AstropyWarning) + opened = fits.open(path, memmap=False, lazy_load_hdus=False) + with opened as hdus: + primary=hdus[0].header + images=[(i,h) for i,h in enumerate(hdus) if isinstance(h,(fits.PrimaryHDU,fits.ImageHDU,fits.CompImageHDU)) and h.header.get('NAXIS',0)>0] + science=[(i,h) for i,h in images if h.name.upper()=='SCI'] + auxnames={'ERR','ERROR','VAR','VARIANCE','IVAR','DQ','MASK','WMAP','WHT','CON','CONTEXT','VAR_POISSON','VAR_RNOISE','VAR_FLAT'} + if not science: science=[(i,h) for i,h in images if h.name.upper() not in auxnames] + if not science: raise ValueError('No science image in FITS product') + seen=set(); structures=[]; refs=[] + for key,val in primary.items(): + if key.startswith('R_') and isinstance(val,str) and val.startswith('crds://'): + refs.append({'field':key,'value':val}) + for index,hdu in science: + h=hdu.header; shape=tuple(hdu.shape); version=h.get('EXTVER',1) + identity=(hdu.name,version) + if identity in seen: raise ValueError('Duplicate science EXTNAME/EXTVER') + seen.add(identity) + limits=[str(w.message) for w in format_warnings]; name=f'HDU {index} ({hdu.name}, EXTVER={version})' + row={'structure':name,'fitsHdu':index,'shape':list(shape),'calibration':[], 'limitations':limits} + rawunit=h.get('BUNIT'); dataunit=unit(rawunit) + if dataunit is not None: row['units']={'value':rawunit,'source':name+':BUNIT','canonical':str(dataunit)} + else: limits.append('Missing or unsupported science unit: '+str(rawunit)) + def companion(names): + candidates=[x for _,x in images if x.name.upper() in names and x.header.get('EXTVER',1)==version] + if len(candidates)>1: raise ValueError('Ambiguous companion '+str(names)+' for '+name) + if not candidates: return None + if sum(x.header.get('EXTVER',1)==version for _,x in science)>1: raise ValueError('Ambiguous science association for companion '+name) + result=candidates[0] + if tuple(result.shape)!=shape: raise ValueError('Companion shape mismatch: '+result.name+' for '+name) + return result + error=companion({'ERR','ERROR','VAR','VARIANCE','IVAR'}) + dq=companion({'DQ','MASK'}) + error_kind=None; error_unit_valid=False + if error is not None: + error_kind='variance' if error.name.upper() in ('VAR','VARIANCE') else 'inverse-variance' if error.name.upper()=='IVAR' else 'standard-deviation' + eu=unit(error.header.get('BUNIT')); wanted=None if dataunit is None else dataunit**(2 if error_kind=='variance' else -2 if error_kind=='inverse-variance' else 1) + if eu is not None and wanted is not None: + if not eu.is_equivalent(wanted): raise ValueError('Uncertainty units disagree with science: '+name) + error_unit_valid=True + else: limits.append('Uncertainty units cannot be established for '+name) + else: limits.append('No associated uncertainty array.') + # WCSLIB owns both nonlinear and table-backed transforms, with the HDUList supplying -TAB arrays. + spec=None; specaxis=None; centers=None; edges=None + ctypes=[str(h.get('CTYPE'+str(i+1),'')) for i in range(len(shape))] + if any(t.startswith(('WAVE','FREQ','AWAV','VRAD','VOPT','VELO')) for t in ctypes): + try: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', fits.verify.VerifyWarning) + w=WCS(h, fobj=hdus, fix=False) + sw=w.wcs.spec + if sw<0: raise ValueError('No spectral WCS axis') + linked=np.flatnonzero(w.axis_correlation_matrix[sw]) + if len(linked)!=1: raise NotImplementedError('Spatially coupled spectral WCS needs a coordinate field') + pixelaxis=int(linked[0]); specaxis=len(shape)-1-pixelaxis; n=shape[specaxis] + if n>1000000: raise ValueError('Spectral coordinate array exceeds budget') + def wavelength(pixels): + coords=np.tile(np.asarray(w.wcs.crpix)-1,(len(pixels),1)); coords[:,pixelaxis]=pixels + values=w.all_pix2world(coords,0)[:,sw]; su=u.Unit(w.world_axis_units[sw]); ct=str(w.wcs.ctype[sw]) + if ct.startswith('AWAV'): raise NotImplementedError('Air-wavelength conversion requires a stated convention') + if su.is_equivalent(u.m/u.s): + rest=h.get('RESTFRQ',h.get('RESTFREQ')) + restq=rest*u.Hz if rest else h.get('RESTWAV',0)*u.m + if restq.value<=0: raise NotImplementedError('Velocity WCS requires a recorded rest wavelength/frequency') + eq=u.doppler_radio(restq) if ct.startswith('VRAD') else u.doppler_optical(restq) if ct.startswith('VOPT') else u.doppler_relativistic(restq) + return (values*su).to(u.Hz,equivalencies=eq).to_value(u.um,equivalencies=u.spectral()) + return (values*su).to_value(u.um,equivalencies=u.spectral()) + centers=wavelength(np.arange(n,dtype=float)) + if not np.all(np.isfinite(centers)&(centers>0)) or (n>1 and not (np.all(np.diff(centers)>0) or np.all(np.diff(centers)<0))): raise ValueError('Invalid/nonmonotonic wavelength coordinates') + # A lookup table defines coordinates at its knots, not an extrapolated optical passband. + if '-TAB' not in str(w.wcs.ctype[sw]): + edges=wavelength(np.arange(n+1,dtype=float)-.5) + if not np.all(np.isfinite(edges)&(edges>0)): raise ValueError('Invalid spectral bin edges') + else: limits.append('Tabulated centers qualified; continuous bin edges are not inferred between knots.') + row['spectral']={'centersMicrometres':centers.tolist(),'axis':specaxis,'source':name+':Astropy WCSLIB'} + if edges is not None: row['spectral']['binEdgesMicrometres']=edges.tolist() + except (NotImplementedError, u.UnitConversionError) as exc: limits.append(str(exc)); specaxis=None + extract=request.get('operation')=='extract' and request.get('hdu')==index + output_values=None; output_sigma=None; aggregate=None; measurement={} + if extract: + if len(shape)<2 or any(n!=1 for n in shape[:max(0,len(shape)-3)]): raise ValueError('Output needs two spatial axes and at most one spectral axis') + if (len(shape)>2 and shape[-3]>1 or specaxis is not None) and specaxis!=len(shape)-3: raise ValueError('Output needs a qualified leading spectral axis') + kind=request['kind']; w=shape[-1]; height=shape[-2] + if kind=='image': + plane=request.get('plane') + if specaxis is not None: + if not isinstance(plane,int) or plane<0 or plane>=shape[specaxis]: raise ValueError('Select an explicit zero-based spectral plane') + elif plane is not None: raise ValueError('A two-dimensional image has no spectral plane selector') + output_values=science_array('values',(height,w),np.nan); output_sigma=science_array('sigma',(height,w),np.nan) + elif kind=='spectrum': + x=request.get('x'); y=request.get('y') + if specaxis!=len(shape)-3 or centers is None: raise ValueError('Output needs a qualified wavelength axis') + if not isinstance(x,int) or not isinstance(y,int) or not 0<=xshape[1]-.5)|(by<-.5)|(by>shape[0]-.5)) + region_check={'answer':'partial' if outside else 'unknown','reason':'Requested boundary positions lie outside the returned image.' if outside else 'Boundary samples are in the image; complete continuous-region coverage is not established by samples alone.'} + except Exception as e: + region_wcs=None + region_check['reason']=str(e) + usable=np.zeros(shape[specaxis],dtype=bool) if specaxis is not None else None + # Bound temporary masks to about one million samples; no whole-cube boolean allocations. + flatlast=shape[-1]; leading=int(np.prod(shape[:-1])) if len(shape)>1 else 1 + for lead in range(leading): + prefix=np.unravel_index(lead,shape[:-1]) if len(shape)>1 else () + for start in range(0,flatlast,1000000): + sl=prefix+(slice(start,min(start+1000000,flatlast)),) + a=np.asarray(hdu.section[sl]); good=np.isfinite(a); finite+=int(good.sum()); total+=a.size + if dq is not None: + q=np.asarray(dq.section[sl]) + if not np.issubdtype(q.dtype,np.integer): raise ValueError('Quality flags must be integers') + flagged+=int(np.count_nonzero(q)); good &= q==0 + if error is not None: + e=np.asarray(error.section[sl]); bad=np.isfinite(e)&(e<0)&good + if bad.any(): raise ValueError('Negative unmasked uncertainty') + valid=np.isfinite(e)&(e>0 if error_kind=='inverse-variance' else e>=0) + baderror+=int(np.count_nonzero(good&~valid)); good &= valid + if not error_unit_valid: good[:]=False + if extract: + sigma=np.full(a.shape,np.nan) + if error_unit_valid: + scaled=e*eu.to(wanted) + with np.errstate(divide='ignore',invalid='ignore'): + sigma=np.sqrt(scaled) if error_kind=='variance' else 1/np.sqrt(scaled) if error_kind=='inverse-variance' else scaled + if aggregate is not None: + aggregate.add(prefix[specaxis],prefix[-1],start,a,sigma,good) + elif kind=='image' and (specaxis is None or prefix[specaxis]==plane): + output_values[prefix[-1],start:start+a.size]=np.where(good,a,np.nan) + output_sigma[prefix[-1],start:start+a.size]=np.where(good,sigma,np.nan) + elif kind=='spectrum' and prefix[-1]==y and start<=x0 if error_kind=='inverse-variance' else e>=0) + region_usable+=int((within&valid).sum()); region_bad+=int((within&~valid).sum()) + if region_bad: + region_check={'answer':'partial','reason':'Some pixel centers in the requested circle have invalid science, quality flags or invalid supplied uncertainty.'} + row['regionCoverage']={**region_check,'region':request['region'],'usablePixelCenters':region_usable,'invalidPixelCenters':region_bad} + row['uncertainty']={'status':'validated' if error_unit_valid else 'unknown','kind':error_kind,'structure':error.name if error is not None else None} + if usable is not None: + row['spectral']['usableBands']=usable.tolist() + if edges is not None: row['wavelengthIntervalsMicrometres']=merged([[float(min(edges[i],edges[i+1])),float(max(edges[i],edges[i+1]))] for i in range(len(usable)) if usable[i]]) + beam=None; beams=[x for x in hdus if x.name=='BEAMS'] + if beams: + if len(beams)!=1 or len(science)!=1 or specaxis is None: limits.append('Beam table cannot be assigned uniquely.') + else: + table=beams[0]; cols=set(table.columns.names) + if not {'BMAJ','BMIN','CHAN','POL'}.issubset(cols): raise ValueError('Incomplete per-plane beam table') + # Only a single Stokes component is admitted here; do not discard polarization-dependent beams. + if set(np.asarray(table.data['POL']).tolist())!={0}: limits.append('Multiple polarization beams require explicit selection.') + else: + majors=(table.data['BMAJ']*u.Unit(table.columns['BMAJ'].unit)).to_value(u.arcsec) + minors=(table.data['BMIN']*u.Unit(table.columns['BMIN'].unit)).to_value(u.arcsec) + channels=np.asarray(table.data['CHAN']); n=shape[specaxis] + if len(channels)!=n or set(channels.tolist())!=set(range(n)) or not np.all(np.isfinite(majors)&np.isfinite(minors)&(majors>=minors)&(minors>0)): raise ValueError('Beam table does not cover every channel with valid axes') + if goodcount: beam=float(max(majors[i] for i,c in enumerate(channels) if usable[c])) + row['calibration'].append({'field':'BEAMS','value':'Complete channel-indexed restoring beams; maximum usable major-axis FWHM.'}) + elif 'BMAJ' in h or 'BMIN' in h: + major=h.get('BMAJ'); minor=h.get('BMIN') + if major is None or minor is None or not np.isfinite(major) or not np.isfinite(minor) or not major>=minor>0: raise ValueError('Invalid restoring beam axes') + if h.get('CASAMBM',False): limits.append('Per-plane beam flag is set but the BEAMS table is absent.') + elif goodcount: beam=float(major)*3600 + row['calibration'] += [{'field':name+':'+k,'value':str(h[k])} for k in ('BMAJ','BMIN','BPA') if k in h] + if beam is not None and dataunit is not None and dataunit.is_equivalent(u.Jy/u.beam): row['angularResolutionArcsec']=beam + else: limits.append('No applicable measured PSF or restoring beam; pixel sampling is not achieved resolution.') + if extract: + if aggregate is not None: output_values,output_sigma,measurement=aggregate.result() + def nullable(a): return np.where(np.isfinite(a),a,None).tolist() + if request.get('arrayDirectory'): + Path(request['arrayDirectory']).mkdir(parents=True,exist_ok=True) + for key,array in [('values',output_values),('sigma',output_sigma)]: + if not isinstance(array,np.memmap):np.save(Path(request['arrayDirectory'])/(key+'.npy'),array) + else:array.flush() + payload={'arrays':{'values':'values.npy','sigma':'sigma.npy'}} + else:payload={'values':nullable(output_values),'sigma':nullable(output_sigma)} + row['extraction']={'kind':kind,**payload,'unit':rawunit, + 'wavelengthsMicrometres':centers.tolist() if centers is not None else None, + 'plane':request.get('plane'),'x':request.get('x'),'y':request.get('y'),**measurement} + structures.append(row) + return {'structures':structures,'references':refs,'primary':{k:v for k,v in primary.items() if k not in ('COMMENT','HISTORY','') and isinstance(v,(str,int,float,bool))},'astropy':astropy.__version__} + +if request['operation'] in ('fits','extract'): answer=read_science(request['path']) +elif request['operation']=='units': + answer={'units':[None if unit(text) is None else str(unit(text)) for text in request['units']], 'astropy':astropy.__version__} +else: raise ValueError('Unknown science operation') +json.dump(answer,sys.stdout,allow_nan=False,separators=(',',':')) +`; +export async function sciencePackage(request: { operation: 'fits'; path: string; region?: import('../telescopes/vo/contracts.mts').IcrsCircle } | { operation:'extract';path:string;hdu:number;arrayDirectory?:string;kind:'image'|'spectrum'|'band-image'|'aperture-spectrum'|'feature-map';plane?:number;x?:number;y?:number;band?:readonly number[];aperture?:readonly number[];background?:'none'|readonly number[];continuum?:readonly number[];uncertainty?:'omit'|'independent' } | { operation: 'units'; units: readonly string[] }): Promise> { + const tc = await astroqueryToolchain(); + return new Promise((done, fail) => { + const child = spawn(tc.python, ['-c', SCIENCE_PYTHON], { env: { ...process.env, ...tc.env }, stdio: ['pipe', 'pipe', 'pipe'] }); + let out = '', err = ''; + child.stdout.setEncoding('utf8').on('data', s => { out += s; }); child.stderr.setEncoding('utf8').on('data', s => { err += s; }); + child.on('error', fail); child.on('close', code => { if (code !== 0) return fail(new Error(`Astropy metadata failed: ${err.slice(-2500)}`)); + try { const result = requireRecord(JSON.parse(out)); if (requireString(result.astropy) !== '8.0.1') throw new Error('Unexpected Astropy version'); done(result); } catch (error) { fail(error); } }); + child.stdin.end(JSON.stringify(request)); + }); +} diff --git a/tools/objects/astronomy-packages/toolchain.json b/tools/objects/astronomy-packages/toolchain.json index bf48a03892..ea53d25066 100644 --- a/tools/objects/astronomy-packages/toolchain.json +++ b/tools/objects/astronomy-packages/toolchain.json @@ -1,6 +1,6 @@ { "schema": "cssearth-astronomy-packages-toolchain@1", - "purpose": "The pinned Python archive client used by cssEarth. Astroquery owns archive-specific services and PyVO owns generic Virtual Observatory TAP; cssEarth still validates identities and bytes and owns every scientific reduction and evidence claim.", + "purpose": "Pinned archive clients, scientific metadata, measurement arithmetic, plotting and planetary mapping dependencies. cssEarth validates identities, scientific choices and evidence.", "license": "Astroquery, Astropy and PyVO are BSD-3-Clause. Their notices are retained here; archive data keep their providers' separate terms.", "micromamba": { "channel": "conda-forge", diff --git a/tools/objects/pds-labels.mts b/tools/objects/pds-labels.mts index ff25c1ea44..eb77d6e32d 100644 --- a/tools/objects/pds-labels.mts +++ b/tools/objects/pds-labels.mts @@ -40,3 +40,15 @@ export function pds4ProductIdentity(xml: string) { const area = pds4Block(xml, 'Identification_Area').replace(/]*)?>[\s\S]*?<\/Modification_History>/gu, ''); return { logical_identifier: pds4Field(area, 'logical_identifier'), version_id: pds4Field(area, 'version_id') }; } + +/** PDS UTC calendar or ordinal dates; reject day-of-year rollover. */ +export function pds3TimeIso(value: string): string { + const ordinal=/^(\d{4})-(\d{3})T(.*)$/u.exec(value); + if(ordinal){const year=Number(ordinal[1]),day=Number(ordinal[2]),date=new Date(Date.UTC(year,0,day)); + if(day<1||date.getUTCFullYear()!==year)throw new Error('Invalid PDS day of year'); + value=`${date.toISOString().slice(0,10)}T${ordinal[3]}`; + } + const date=new Date(/[zZ]|[+-]\d\d:\d\d$/u.test(value)?value:`${value}Z`); + if(!Number.isFinite(date.valueOf()))throw new Error('Invalid PDS UTC time'); + return date.toISOString(); +} diff --git a/tools/objects/pds/source-observations.mts b/tools/objects/pds/source-observations.mts index 9fd5b5e714..0eedba1624 100644 --- a/tools/objects/pds/source-observations.mts +++ b/tools/objects/pds/source-observations.mts @@ -6,14 +6,9 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { hasErrorCode, requireArray, requireFiniteNumber, requireRecord, requireString } from '../../source-values.mts'; -import { pds3Keyword, pds3Values } from '../pds-labels.mts'; +import { pds3Keyword, pds3Values, pds3TimeIso } from '../pds-labels.mts'; const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); -const iso = (value: string, field: string) => { - const date = new Date(/[zZ]$/u.test(value) ? value : `${value}Z`); - if (Number.isNaN(date.valueOf())) throw new TypeError(`PDS3 ${field} is not a time.`); - return date.toISOString(); -}; const measurement = (value: string, field: string) => { const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?)\s*<([^<>]+)>$/u.exec(value.trim()); if (!match) throw new TypeError(`PDS3 ${field} is not a number with a unit.`); @@ -65,7 +60,7 @@ export async function sourcePds3Observations(root: string, targetId: string): Pr const localScience = await readFile(resolve(sourceRoot, science.path)).catch((error: unknown) => { if (hasErrorCode(error, 'ENOENT')) return undefined; throw error; }); if (localScience !== undefined && (localScience.byteLength !== scienceBytes || sha256(localScience) !== scienceSha)) throw new Error(`${science.path} does not match its source-manifest pin.`); const field = (key: string) => { const value = pds3Keyword(label, key, []); if (value === undefined) throw new TypeError(`${labelPath} lacks ${key}.`); return value; }; - const productId = field('PRODUCT_ID'), productType = field('PRODUCT_TYPE'), datasetId = field('DATA_SET_ID'), host = field('INSTRUMENT_HOST_NAME'), instrumentId = field('INSTRUMENT_ID'), instrument = field('INSTRUMENT_NAME'); + const productId = field('PRODUCT_ID'), productType = pds3Keyword(label, 'PRODUCT_TYPE', []), datasetId = field('DATA_SET_ID'), host = field('INSTRUMENT_HOST_NAME'), instrumentId = field('INSTRUMENT_ID'), instrument = field('INSTRUMENT_NAME'); const targetName = field('TARGET_NAME'); const horizontal = pds3Keyword(label, 'HORIZONTAL_PIXEL_SCALE', []), vertical = pds3Keyword(label, 'VERTICAL_PIXEL_SCALE', []); const surfaceResolutionKm = horizontal === undefined || vertical === undefined ? undefined @@ -74,8 +69,8 @@ export async function sourcePds3Observations(root: string, targetId: string): Pr const center = pds3Keyword(label, 'CENTER_FILTER_WAVELENGTH', []); const filter = pds3Keyword(label, 'FILTER_NAME', []); observations.push({ id: productId.toLowerCase(), archiveProductId: `${datasetId}:${productId}`, datasetId, - program: `${targetId}-pds3-${slug(datasetId)}`, targetName, telescope: title(host), mode: `${instrumentId}/${productType} image`, - observatory: title(host), instrument, startIso: iso(field('START_TIME'), 'START_TIME'), endIso: iso(field('STOP_TIME'), 'STOP_TIME'), + program: `${targetId}-pds3-${slug(datasetId)}`, targetName, telescope: title(host), mode: `${instrumentId}/${productType ? `${productType} ` : ''}image`, + observatory: title(host), instrument, startIso: pds3TimeIso(field('START_TIME')), endIso: pds3TimeIso(field('STOP_TIME')), ...(filter === undefined ? {} : { filter }), ...(center === undefined ? {} : { centralWavelengthMicrometres: convert(center, 'CENTER_FILTER_WAVELENGTH', WAVELENGTH_TO_MICROMETRES) }), ...(surfaceResolutionKm === undefined ? {} : { surfaceResolutionKm }), kind: 'image', use: 'Source-pinned PDS3 image. Its detached label establishes identity, time, filter, units and surface sampling; filter width and achieved optical resolution remain unstated.', diff --git a/tools/objects/pds/source-observations.test.mts b/tools/objects/pds/source-observations.test.mts index bd9ef16168..bda191a879 100644 --- a/tools/objects/pds/source-observations.test.mts +++ b/tools/objects/pds/source-observations.test.mts @@ -12,3 +12,12 @@ test('source-pinned Wild 2 PDS3 images enter the PDS adapter without inventing a assert.equal(observations.every(observation => observation.filter === 'OPNAV' && observation.centralWavelengthMicrometres === 0.6988), true); assert.deepEqual(observations.map(observation => observation.surfaceResolutionKm), [0.021253, 0.014167, 0.01533, 0.01939, 0.024964]); }); + +test('ordinal UTC is validated without requiring a product processing-level field',async()=>{ + const {pds3TimeIso}=await import('../pds-labels.mts'); + assert.equal(pds3TimeIso('2004-163T16:09:09.703'),'2004-06-11T16:09:09.703Z'); + assert.throws(()=>pds3TimeIso('2003-366T00:00:00'),/day of year/); + for(const target of ['phoebe','tethys']){ + const observations=await sourcePds3Observations(ROOT,target);assert.ok(observations.length);assert.ok(observations.every(o=>!o.mode.includes('undefined')&&o.startIso.endsWith('Z'))); + } +}); diff --git a/tools/objects/product-record.mts b/tools/objects/product-record.mts index 9f62968966..f62306fab3 100644 --- a/tools/objects/product-record.mts +++ b/tools/objects/product-record.mts @@ -26,8 +26,11 @@ export const PRODUCT_RECORD_SCHEMA = 'cssearth-telescope-product@1'; * `archive-origin` is the one kind that is not a comparison. It says that the bytes a stage recorded are the observatory's own * final product, retrieved from the archive and pinned by size and sha256. It establishes origin and integrity, nothing more: * it is not `archive-agreement`, because nothing here was re-run and nothing was compared, and a caller asking whether a route - * reproduces an observatory's calibration must never be answered with it. */ -export const EVIDENCE_KINDS = ['archive-agreement', 'archive-origin', 'internal-consistency', 'geometric-registration', 'published-value'] as const; + * reproduces an observatory's calibration must never be answered with it. + * `archive-retrieval-origin` establishes retrieval without claiming a final calibration level. + * `archive-subset-origin` establishes the exact parent and server operation that returned the bytes; + * it does not establish equivalence to the full parent or unchanged sampling. */ +export const EVIDENCE_KINDS = ['archive-agreement', 'archive-origin', 'archive-retrieval-origin', 'archive-subset-origin', 'internal-consistency', 'geometric-registration', 'published-value'] as const; export type EvidenceKind = typeof EVIDENCE_KINDS[number]; export interface ProductInput { readonly role: string; readonly identity: string; readonly bytes: number; readonly sha256: string } diff --git a/tools/objects/telescopes/calibration-dependencies.mts b/tools/objects/telescopes/calibration-dependencies.mts new file mode 100644 index 0000000000..353c04cf3b --- /dev/null +++ b/tools/objects/telescopes/calibration-dependencies.mts @@ -0,0 +1,79 @@ +/** Exact recorded dependencies. Retrieval does not establish calibration accuracy. */ +import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'; +import { dirname, resolve, relative, basename } from 'node:path'; +import { createHash } from 'node:crypto'; +import { pinFile } from '../product-record.mts'; +import { requireArray, requireRecord, requireString, requireFiniteNumber } from '../../source-values.mts'; +import { readFitsFileHdus } from '../../fits.mts'; +export interface CalibrationDependency { + readonly field: string; readonly reference: string; readonly status: 'pinned' | 'unresolved'; readonly reason: string; + readonly file?: string; readonly sha256?: string; readonly bytes?: number; readonly origin?: string; + readonly applicability: 'recorded' | 'matched' | 'contradicted' | 'unknown'; +} +export function parseCalibrationDependencies(raw: unknown): CalibrationDependency[] { + return requireArray(raw).map(v => { + const r=requireRecord(v), status=requireString(r.status), applicability=requireString(r.applicability); + if(!['pinned','unresolved'].includes(status)||!['recorded','matched','contradicted','unknown'].includes(applicability))throw new Error('Invalid calibration dependency status'); + const row={field:requireString(r.field),reference:requireString(r.reference),status:status as CalibrationDependency['status'],reason:requireString(r.reason),applicability:applicability as CalibrationDependency['applicability']}; + if(status==='unresolved')return row; + const sha256=requireString(r.sha256),bytes=requireFiniteNumber(r.bytes),file=requireString(r.file); + if(!/^[a-f0-9]{64}$/u.test(sha256)||!Number.isSafeInteger(bytes)||bytes<1||file.startsWith('/')||file.split('/').includes('..'))throw new Error('Invalid calibration pin'); + return {...row,sha256,bytes,file,origin:requireString(r.origin)}; + }); +} +export async function verifyCalibrationDependencies(root:string, rows:readonly CalibrationDependency[]):Promise { + for(const row of rows)if(row.status==='pinned'){ + const actual=await pinFile(resolve(root,row.file!)).catch(()=>null); + if(!actual||actual.sha256!==row.sha256||actual.bytes!==row.bytes)return false; + } + return true; +} +export function calibrationOrigin(reference:string):string|undefined { + if(/^\$[a-z][a-z0-9_]*\/[a-zA-Z0-9_./-]+$/u.test(reference)&&!reference.split('/').includes('..')) + return `https://asc-isisdata.s3.us-west-2.amazonaws.com/usgs_data/${reference.slice(1)}`; + if(/^crds:\/\/(jwst|hst|roman)_[a-zA-Z0-9_.-]+$/u.test(reference)) { + const name=reference.slice(7),mission=name.split('_')[0];return `https://${mission}-crds.stsci.edu/unchecked_get/references/${mission}/${name}`; + } + return undefined; +} +/** Cache pins are immutable after first retrieval. Changed cached bytes fail, never silently repin. */ +export async function calibrationDependencies(root:string, references:readonly {field:string;value:string}[], productHeader:Readonly>, options:{maxFileBytes?:number;maxTotalBytes?:number;fetcher?:typeof fetch}={}):Promise { + const rows:CalibrationDependency[]=[], maxFile=options.maxFileBytes??16_000_000, maxTotal=options.maxTotalBytes??64_000_000; + let downloaded=0; + for(const ref of references){ + const origin=calibrationOrigin(ref.value); + if(!origin){rows.push({field:ref.field,reference:ref.value,status:'unresolved',applicability:'unknown',reason:'No exact archive resolver for this reference.'});continue;} + const key=createHash('sha256').update(origin).digest('hex'), dir=resolve(root,'output/telescopes/calibration',key), file=resolve(dir,basename(new URL(origin).pathname)), manifest=resolve(dir,'pin.json'); + try{ + const saved=await readFile(manifest,'utf8').then(t=>requireRecord(JSON.parse(t)),()=>undefined); + if(saved){const current=await pinFile(file);if(current.sha256!==saved.sha256||current.bytes!==saved.bytes||saved.origin!==origin)throw new Error('Calibration cache integrity mismatch');} + else { + const fetcher=options.fetcher??fetch; + const response=await fetcher(origin,{signal:AbortSignal.timeout(30_000)}); + if(!response.ok||!response.body)throw new Error(`Archive returned HTTP ${response.status}`); + const declared=Number(response.headers.get('content-length')); + if((declared>maxFile)||(downloaded+declared>maxTotal)){await response.body.cancel();throw new Error('Dependency exceeds this run\'s explicit calibration download budget');} + const chunks:Uint8Array[]= [];let n=0,mark=10_000_000; + for await(const chunk of response.body){n+=chunk.length;if(n>maxFile||downloaded+n>maxTotal)throw new Error('Dependency exceeds calibration download budget');chunks.push(chunk);if(n>=mark){process.stderr.write(`${basename(file)}: ${(n/1e6).toFixed(1)} MB\n`);mark+=10_000_000;}} + if(!n)throw new Error('Empty calibration file'); + await mkdir(dir,{recursive:true});const tmp=`${file}.${process.pid}.partial`;await writeFile(tmp,Buffer.concat(chunks));await rename(tmp,file); + const pin=await pinFile(file);await writeFile(manifest,JSON.stringify({...pin,origin}));downloaded+=n; + } + const pin=await pinFile(file);let applicability:CalibrationDependency['applicability']='recorded',reason='Exact file named by the product; archive bytes pinned. Calibration accuracy is not independently verified.'; + if(/\.fits$/iu.test(file)){ + const h=(await readFitsFileHdus(file))[0].header;let checked=0,conflict=false; + for(const key of ['INSTRUME','DETECTOR','FILTER','GRATING','PUPIL']){ + const expected=productHeader[key],actual=h[key];if(expected===undefined||actual===undefined||['ANY','N/A','GENERIC'].includes(String(actual)))continue; + checked++;if(!String(actual).split('|').map(s=>s.trim()).includes(String(expected)))conflict=true; + } + if(conflict){applicability='contradicted';reason='Calibration selectors contradict the product headers; this dependency cannot support scientific facts.';} + else if(checked){applicability='matched';reason=`Exact recorded file and ${checked} instrument/configuration selectors agree; calibration accuracy is not independently verified.`;} + } + rows.push({field:ref.field,reference:ref.value,status:'pinned',applicability,reason,file:relative(root,file),origin,...pin}); + }catch(error){ + if(String(error).includes('integrity mismatch'))throw error; + rows.push({field:ref.field,reference:ref.value,status:'unresolved',applicability:'unknown',origin,reason:String(error)}); + } + } + return rows; +} diff --git a/tools/objects/telescopes/cli.mts b/tools/objects/telescopes/cli.mts index 5c44d5c2f7..e82c7d9f74 100644 --- a/tools/objects/telescopes/cli.mts +++ b/tools/objects/telescopes/cli.mts @@ -6,20 +6,51 @@ import { formatAnswer } from './query.mts'; import { assessRequest } from './request-satisfaction.mts'; import { getSession, saveSession, type Session } from './session.mts'; +import { exportSpatialObject } from './spatial-handoff.mts'; +import { listOutputs, exportOutput, validateOutputRequest, type OutputRequest } from './outputs.mts'; +import { projectOutput } from './projection.mts'; +import { exportSphere } from './sphere.mts'; import { HELP } from '../../../packages/telescope/src/help.mts'; export { HELP }; -const queryValues = new Set(['--target', '--wavelength', '--kind', '--from', '--to', '--min-arcsec', '--min-km', '--min-elements', '--range-km', '--radius-km', '--continuum', '--accept-assumptions', '--result']); -export type CliOptions = { readonly command: 'help' } | { readonly command: 'query'; readonly directory: string; readonly requestArgs: string[]; readonly json: boolean; readonly verbose: boolean } | { readonly command: 'get'; readonly directory: string; readonly pick: number; readonly json: boolean; readonly verbose: boolean }; +const queryValues = new Set(['--target', '--wavelength', '--kind', '--from', '--to', '--min-arcsec', '--min-km', '--min-elements', '--range-km', '--radius-km', '--continuum', '--accept-assumptions', '--result', '--icrs-circle', '--spectral-frame', '--max-science-bytes', '--max-metadata-bytes', '--max-link-depth', '--max-link-requests', '--max-expanded-bytes', '--max-package-members']); +export type CliOptions = {readonly command:'spatial';readonly kind:'points'|'volume';readonly result:string;readonly directory:string;readonly json:boolean;readonly verbose:boolean} | {readonly command:'project';readonly result:string;readonly geometry:string;readonly directory:string;readonly json:boolean;readonly verbose:boolean} | {readonly command:'sphere';readonly result:string;readonly directory:string;readonly json:boolean;readonly verbose:boolean} | {readonly command:'outputs';readonly result:string;readonly structure?:string;readonly json:boolean;readonly verbose:boolean} | {readonly command:'export';readonly result:string;readonly directory:string;readonly selection:OutputRequest;readonly json:boolean;readonly verbose:boolean} | { readonly command: 'help' } | { readonly command: 'query'; readonly directory: string; readonly requestArgs: string[]; readonly json: boolean; readonly verbose: boolean } | { readonly command: 'get'; readonly offline?: boolean; readonly directory: string; readonly pick: number; readonly json: boolean; readonly verbose: boolean }; export function parseCli(args: readonly string[]): CliOptions { const command = args[0]; if (!args.length || args.includes('--help') || args.includes('-h')) return { command: 'help' as const }; - if (command !== 'query' && command !== 'get') throw new TypeError('Expected query or get. Use telescope --help.'); + if(command==='outputs'||command==='export'||command==='project'){ + const positional:string[]=[],values=new Map(),flags=new Set(); + for(let i=1;i!['--output','--out'].includes(k))||!values.get('--out'))throw new TypeError('Spatial handoff takes only --output points|volume and --out DIRECTORY');return {command:'spatial',kind,...common,directory:resolve(values.get('--out')!)};}if(kind==='sphere'){if([...values.keys()].some(k=>!['--output','--out'].includes(k))||!values.get('--out'))throw new TypeError('Sphere takes only --output sphere and --out DIRECTORY');return {command:'sphere',...common,directory:resolve(values.get('--out')!)};}if(kind!=='image'&&kind!=='spectrum'&&kind!=='band-image'&&kind!=='aperture-spectrum'&&kind!=='feature-map')throw new TypeError('--output takes image, spectrum, band-image, aperture-spectrum, feature-map, sphere, points or volume'); + const integer=(value:string|undefined)=>{if(value===undefined||!/^\d+$/u.test(value)||!Number.isSafeInteger(Number(value)))throw new TypeError('Selectors must be nonnegative whole numbers');return Number(value);}; + const hdu=integer(values.get('--hdu')),plane=values.has('--plane')?integer(values.get('--plane')):undefined; + const parts=values.get('--pixel')?.split(',');if(parts&&parts.length!==2)throw new TypeError('--pixel takes X,Y'); + const pixel=parts?[integer(parts[0]),integer(parts[1])] as const:undefined; + const directory=values.get('--out');if(!directory)throw new TypeError('export requires --out DIRECTORY'); + const numbers=(key:string)=>{const parts=values.get(key)!.split(',');if(parts.some(p=>!p.trim()||!Number.isFinite(Number(p))))throw new TypeError(`${key} requires comma-separated numbers`);return parts.map(Number);}; + const uncertainty=values.get('--uncertainty');if(uncertainty!==undefined&&uncertainty!=='omit'&&uncertainty!=='independent')throw new TypeError('--uncertainty takes omit or independent'); + const selection:OutputRequest={kind,hdu,...(values.has('--structure')?{structure:values.get('--structure')!}:{}),...(plane===undefined?{}:{plane}),...(pixel?{pixel}:{}), + ...(values.has('--band')?{band:numbers('--band')}:{}),...(values.has('--aperture')?{aperture:numbers('--aperture')}:{}), + ...(values.has('--background')?{background:values.get('--background')==='none'?'none':numbers('--background')}:{}), + ...(values.has('--continuum')?{continuum:numbers('--continuum')}:{}),...(uncertainty?{uncertainty}:{})}; + validateOutputRequest(selection); + return {command,...common,directory:resolve(directory),selection}; + } + if (command !== 'query' && command !== 'get') throw new TypeError('Expected query, get, outputs, export or project. Use telescope --help.'); const values = new Map(), switches = new Set(), positional: string[] = [], requestArgs: string[] = []; for (let i = 1; i < args.length; i++) { const arg = args[i]; if (!arg.startsWith('-')) { positional.push(arg); continue; } - if (['--json', '--verbose', ...(command === 'query' ? ['--any-time'] : [])].includes(arg)) { + if (['--json', '--verbose', ...(command === 'query' ? ['--any-time'] : ['--offline'])].includes(arg)) { if (switches.has(arg)) throw new TypeError(`Repeated option ${arg}.`); switches.add(arg); if (arg === '--any-time') requestArgs.push(arg); continue; } @@ -39,7 +70,7 @@ export function parseCli(args: readonly string[]): CliOptions { } const pick = Number(values.get('--pick')); if (positional.length !== 1 || !Number.isSafeInteger(pick) || pick < 1) throw new TypeError('Use telescope get DIRECTORY --pick N, with a positive whole number.'); - return { command, directory: resolve(positional[0]), pick, json, verbose }; + return { command, directory: resolve(positional[0]), pick, json, verbose, ...(switches.has('--offline') ? { offline: true } : {}) }; } export function formatSession(session: Session, directory: string): string { const lines = [`${session.target} · ${session.answer.request.kind} · ${session.answer.request.wavelengthMicrometres.join('–')} µm`, '']; @@ -51,11 +82,13 @@ export function formatSession(session: Session, directory: string): string { } if (!session.choices.length) { lines.push(`No retrievable observation. Workflow: ${session.answer.endpoint.status}.`); - if (session.answer.targetResolution.status === 'unknown') lines.push(formatAnswer(session.answer).trim()); + if (session.answer.targetResolution.status !== 'resolved') lines.push(formatAnswer(session.answer).trim()); for (const candidate of session.answer.candidates) lines.push(` ${candidate.telescope} / ${candidate.mode}: ${candidate.selectionAssessment.blockers.map(b => b.reason).join('; ') || 'No exact qualified artifact or executable qualification action.'}`); } for (const coverage of session.answer.targetCoverage) if (coverage.state !== 'observed') lines.push(`${coverage.telescope}: ${coverage.state}. ${coverage.reason}`); for (const issue of session.answer.sourceIntakeIssues ?? []) lines.push(`Source ${issue.state}: ${issue.path}. ${issue.reason}`); + for (const service of session.answer.archiveAccess?.services ?? []) lines.push(`${service.service}: ${service.state}. ${service.reason}`); + for (const record of session.answer.archiveAccess?.records ?? []) if (!record.products.length) lines.push(`Archive ${record.observation.key}: ${record.observation.target.status}. ${record.issues.join('; ')}`); lines.push('', `Saved: ${resolve(directory, 'query.json')}`); if (session.choices.length) lines.push(`Next: telescope get ${JSON.stringify(directory)} --pick N`); return `${lines.join('\n')}\n`; @@ -70,13 +103,25 @@ export async function main(args: readonly string[], root = resolve(import.meta.d let text: string, code: number; process.stdout.write = process.stderr.write.bind(process.stderr); try { - if (options.command === 'query') { + if(options.command==='project'){ + const result=await projectOutput(options.result,options.geometry,options.directory);text=options.json?JSON.stringify(result)+'\n':`Map: ${result.map}\nFigure: ${result.figure}\nEvidence: ${result.receipt}\n`;code=0; + }else if(options.command==='spatial'){ + const result=await exportSpatialObject(options.result,options.kind,options.directory);text=options.json?JSON.stringify(result)+'\n':`Object: ${result.object}\nEvidence: ${result.receipt}\n`;code=0; + }else if(options.command==='sphere'){ + const result=await exportSphere(options.result,options.directory);text=options.json?JSON.stringify(result)+'\n':`Sphere: ${result.html}\nEvidence: ${result.receipt}\n`;code=0; + }else if(options.command==='outputs'){ + const result=await listOutputs(options.result,options.structure); + text=options.json?`${JSON.stringify(result)}\n`:result.outputs.map(o=>`${o.kind}${'hdu' in o?` HDU ${o.hdu}`:''}: ${o.available?'available':'unavailable'}. ${o.reason}`).join('\n')+'\n';code=0; + }else if(options.command==='export'){ + const result=await exportOutput(options.result,options.selection,options.directory); + text=options.json?`${JSON.stringify(result)}\n`:`Data: ${result.data}\nFigure: ${result.figure}\nValues: ${result.values}\nEvidence: ${result.receipt}\n`;code=0; + }else if (options.command === 'query') { process.stderr.write('Querying observations…\n'); const session = await saveSession(root, options.requestArgs, options.directory); text = options.json ? `${JSON.stringify(session)}\n` : formatSession(session, options.directory) + (options.verbose ? `\n${formatAnswer(session.answer)}` : ''); code = session.choices.length ? 0 : 3; } else { - const result = await getSession(root, options.directory, options.pick, line => process.stderr.write(`${line}\n`)); + const result = await getSession(root, options.directory, options.pick, line => process.stderr.write(`${line}\n`), undefined, { offline: options.offline }); text = options.json ? `${JSON.stringify(result)}\n` : [`Product: ${result.product}`, `Evidence: ${result.resultPath}`, `Request: ${result.satisfaction.status}`, ...(result.reused ? ['Reused: verified existing delivery'] : []), ...Object.entries(result.satisfaction.constraints).filter(([, v]) => v.answer !== 'yes').map(([name, v]) => `Remaining ${name}: ${v.answer}. ${v.reason}`)].join('\n') + '\n'; diff --git a/tools/objects/telescopes/cube-outputs.test.mts b/tools/objects/telescopes/cube-outputs.test.mts new file mode 100644 index 0000000000..0234f4c7ee --- /dev/null +++ b/tools/objects/telescopes/cube-outputs.test.mts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { test, before, after } from 'node:test'; +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { astroqueryToolchain } from '../astronomy-packages/toolchain.mts'; +import { sciencePackage } from '../astronomy-packages/science.mts'; +import { requireArray, requireRecord } from '../../source-values.mts'; +import { pinFile, writeProductRecord } from '../product-record.mts'; +import { exportOutput, listOutputs, validateOutputRequest, type OutputRequest } from './outputs.mts'; +import { parseCli } from './cli.mts'; +let root:string; +before(async()=>{ + root=await mkdtemp(resolve(tmpdir(),'cube-outputs-')); + const tc=await astroqueryToolchain(); + execFileSync(tc.python,['-c',String.raw` +import sys +import numpy as np +from astropy.io import fits +from pathlib import Path +root=Path(sys.argv[1]) +a=np.broadcast_to(np.array([3.,5.,11.,13.,11.,13.])[:,None,None],(6,2,4)).copy();a[:,0,:2]+=10 +h=fits.ImageHDU(a,name='SCI');h.header['BUNIT']='MJy/sr' +for k,v in {'CTYPE3':'WAVE','CUNIT3':'um','CRPIX3':1,'CRVAL3':1.,'CDELT3':1.}.items():h.header[k]=v +err=fits.ImageHDU(np.full_like(a,2),name='ERR');err.header['BUNIT']='MJy/sr' +dq=fits.ImageHDU(np.zeros(a.shape,dtype='uint16'),name='DQ');dq.data[2,1,3]=1 +fits.HDUList([fits.PrimaryHDU(),h,err,dq]).writeto(root/'cube.fits') +sky=h.copy() +for k,v in {'CTYPE1':'RA---TAN','CTYPE2':'DEC--TAN','CUNIT1':'deg','CUNIT2':'deg','CRPIX1':2,'CRPIX2':1,'CRVAL1':12.,'CRVAL2':-3.,'CDELT1':-.001,'CDELT2':.001,'RADESYS':'ICRS'}.items():sky.header[k]=v +fits.HDUList([fits.PrimaryHDU(),sky,err,dq]).writeto(root/'sky.fits') +sky.header['PC1_3']=.1 +fits.HDUList([fits.PrimaryHDU(),sky,err,dq]).writeto(root/'coupled.fits') +del sky.header['PC1_3'];sky.header['CUNIT1']='kg' +fits.HDUList([fits.PrimaryHDU(),sky,err,dq]).writeto(root/'invalid-wcs.fits') +h.data=h.data[::-1];err.data=err.data[::-1];dq.data=dq.data[::-1];h.header['CRVAL3']=6.;h.header['CDELT3']=-1. +fits.HDUList([fits.PrimaryHDU(),h,err,dq]).writeto(root/'descending.fits') +fits.HDUList([fits.PrimaryHDU(),h]).writeto(root/'no-error.fits') +h.header['CTYPE3']='WAVE-TAB';h.header['CUNIT3']='um';h.header['CRVAL3']=1.;h.header['CDELT3']=1.;h.header['PS3_0']='WCS-TAB';h.header['PS3_1']='WAVE';h.header['PV3_1']=1;h.header['PV3_3']=1 +col=fits.Column(name='WAVE',format='6D',dim='(1,6)',array=np.arange(1.,7.).reshape(1,6,1)) +table=fits.BinTableHDU.from_columns([col],name='WCS-TAB');table.header['EXTVER']=1 +fits.HDUList([fits.PrimaryHDU(),h,table]).writeto(root/'tab.fits') +`,root],{env:{...process.env,...tc.env}}); + const file=resolve(root,'cube.fits'),record=resolve(root,'input.product.json'); + await writeProductRecord(record,{telescope:'Fixture',stage:'fixture',inputs:[],parameters:{},software:[]},[{path:'cube.fits',file}]); + await writeFile(resolve(root,'result.json'),JSON.stringify({schema:'cssearth-telescope-delivery@1',product:'cube.fits',record:'input.product.json',receipt:'input.product.json',facts:{target:'fixture',verified:true},request:{target:'fixture'},satisfaction:{status:'unresolved'},files:[{path:'cube.fits',...await pinFile(file)},{path:'input.product.json',...await pinFile(record)}]})); +}); +after(async()=>{await rm(root,{recursive:true,force:true});}); +async function extract(selection:OutputRequest,file='cube.fits'){ + const answer=await sciencePackage({operation:'extract',path:resolve(root,file),...selection}); + return requireRecord(requireRecord(requireArray(answer.structures)[0]).extraction); +} +const band:OutputRequest={kind:'band-image',hdu:1,band:[2.5,4.5],uncertainty:'independent'}; +const feature:OutputRequest={kind:'feature-map',hdu:1,band:[2.5,4.5],continuum:[.5,2.5,4.5,6.5],uncertainty:'independent'}; +const aperture:OutputRequest={kind:'aperture-spectrum',hdu:1,aperture:[0,0,2,1],background:[2,0,4,1],uncertainty:'independent'}; +const near=(actual:unknown,expected:number)=>assert.ok(typeof actual==='number'&&Math.abs(actual-expected)<1e-9,`${actual} != ${expected}`); +test('band means use bin overlap, preserve missing samples and support descending axes',async()=>{ + const a=await extract(band),b=await extract(band,'descending.fits');assert.deepEqual(a.values,b.values); + const v=requireArray(a.values),e=requireArray(a.sigma);near(requireArray(v[0])[0],22);near(requireArray(v[1])[0],12);assert.equal(requireArray(v[1])[3],null);near(requireArray(e[0])[0],Math.sqrt(2)); + const partial=await extract({...band,band:[2.75,4]});near(requireArray(requireArray(partial.values)[1])[0],11.8); + await assert.rejects(extract({...band,band:[.4,1]}),/outside/); + await assert.rejects(extract({...band,uncertainty:'omit'},'tab.fits'),/bin edges/); + await assert.rejects(extract(band,'no-error.fits'),/validated sample uncertainties/); +}); +test('continuum subtraction cancels a slope and propagates continuum errors',async()=>{ + const a=await extract(feature);near(requireArray(requireArray(a.values)[0])[0],8);near(requireArray(requireArray(a.sigma)[0])[0],Math.sqrt(12));assert.match(String(a.unit),/um/); + const reversed=requireArray((await extract(feature,'descending.fits')).values); + for(const [y,row] of requireArray(a.values).entries())for(const [x,v] of requireArray(row).entries()){const other=requireArray(reversed[y])[x];if(v===null)assert.equal(other,null);else near(other,Number(v));} + await assert.rejects(extract({...feature,continuum:[1,3,4,5]}),/bracket/); +}); +test('fixed aperture subtracts mean background, requires full footprints and explicit error assumptions',async()=>{ + const a=await extract(aperture);for(const v of requireArray(a.values))near(v,10);for(const v of requireArray(a.sigma))near(v,2); + const masked=await extract({...aperture,aperture:[2,1,4,2],background:'none',uncertainty:'omit'});assert.equal(requireArray(masked.values)[2],null);assert.ok(requireArray(masked.sigma).every(v=>v===null)); + await assert.rejects(extract({...aperture,background:[1,0,3,1]}),/overlap/); + await assert.rejects(extract({...aperture,aperture:[0,0,5,1]}),/in bounds/); +}); +test('all three exports publish figures, CSV and selections with source satisfaction preserved',async()=>{ + const result=resolve(root,'result.json'),options=await listOutputs(result); + for(const selection of [band,aperture,feature]){ + assert.ok(options.outputs.some(o=>o.kind===selection.kind&&o.available)); + const exported=await exportOutput(result,selection,resolve(root,selection.kind)); + assert.equal((await readFile(exported.figure)).subarray(1,4).toString(),'PNG'); + const receipt=JSON.parse(await readFile(exported.receipt,'utf8'));assert.deepEqual(receipt.parameters.selection,selection);assert.equal(receipt.parameters.sourceSatisfaction.status,'unresolved');assert.equal(receipt.parameters.measurement.uncertaintyPolicy,'independent');assert.ok((await readFile(exported.values,'utf8')).includes('standard_deviation')); + assert.ok(receipt.outputs.some((o:{path:string})=>o.path===exported.data.split('/').at(-1))); + const tc=await astroqueryToolchain(); + execFileSync(tc.python,['-c',String.raw` +import sys +import numpy as np +from astropy.io import fits +from astropy.table import QTable +from astropy import units as u +path,kind=sys.argv[1:] +if kind=='aperture-spectrum': + t=QTable.read(path) + assert t['wavelength'].unit==u.um and t['value'].unit==u.MJy/u.sr + np.testing.assert_allclose(t['value'].value,10) + np.testing.assert_allclose(t['standard_deviation'].value,2) + assert t.meta['uncertainty']=='independent' and t.meta['selection']['background']==[2,0,4,1] +else: + with fits.open(path,checksum=True) as f: + assert f[0].data.shape==(2,4) and f['MASK'].data[1,3]==1 and np.isnan(f[0].data[1,3]) + assert u.Unit(f[0].header['BUNIT'])==(u.MJy*u.um/u.sr if kind=='feature-map' else u.MJy/u.sr) + np.testing.assert_allclose(f['ERR'].data[0,0],np.sqrt(12 if kind=='feature-map' else 2)) + assert 'CTYPE1' not in f[0].header + assert f[0].verify_checksum()==1 +`,exported.data,selection.kind],{env:{...process.env,...tc.env}}); + assert.equal(receipt.parameters.software.presentation.coordinates.kind,selection.kind==='aperture-spectrum'?'spectral':'pixel'); + if(process.env.CSSEARTH_ORACLE_PYTHON){const {compareOutput}=await import('./output-oracle.mts');assert.equal((await compareOutput(exported.directory,process.env.CSSEARTH_ORACLE_PYTHON,resolve(root,selection.kind+'-oracle'))).passed,true);} + } +}); +test('CLI accepts new selectors and refuses incomplete or silently ignored selections',()=>{ + const args=['export','result.json','--hdu','1','--out','chart']; + const parsed=parseCli([...args,'--output','feature-map','--band','2.5,4.5','--continuum','0.5,2.5,4.5,6.5','--uncertainty','independent']);assert.equal(parsed.command,'export');if(parsed.command==='export')assert.deepEqual(parsed.selection,feature); + assert.throws(()=>parseCli([...args,'--output','aperture-spectrum','--aperture','0,0,2,1']),/background/); + assert.throws(()=>parseCli([...args,'--output','band-image','--band','2,3','--pixel','1,2']),/not valid/); + assert.throws(()=>validateOutputRequest({...band,band:[2,2]}),/positive increasing/); +}); + +test('Astropy output retains the source sky grid and records when celestial projection is inapplicable',async()=>{ + const {plotProduct}=await import('../astronomy-packages/plots.mts'); + const {mkdir}=await import('node:fs/promises'); + const tc=await astroqueryToolchain(); + const data=await extract({...band,uncertainty:'omit'}); + for(const file of ['sky','coupled','invalid-wcs']){ + const dir=resolve(root,file);await mkdir(dir); + const run=plotProduct(dir,'fixture',data,resolve(root,file+'.fits'),{...band,uncertainty:'omit'}); + if(file==='invalid-wcs'){await assert.rejects(run,/InvalidTransform|mismatched units/);continue;} + const result=await run,coordinates=requireRecord(requireRecord(result.presentation).coordinates); + assert.equal(coordinates.kind,file==='sky'?'celestial':'pixel'); + if(file==='coupled'){assert.match(String(coordinates.reason),/beyond/);continue;} + assert.equal(coordinates.frame,''); + execFileSync(tc.python,['-c',String.raw` +import sys +import numpy as np +from astropy.io import fits +from astropy.wcs import WCS +with fits.open(sys.argv[1],checksum=True) as f: + w=WCS(f[0].header,f) + assert w.pixel_n_dim==2 + np.testing.assert_allclose(w.pixel_to_world_values(1,0),[12.,-3.],atol=1e-10) + ra,dec=w.pixel_to_world_values(2,0) + assert ra<12 and abs(dec+3)<1e-6 + assert 'ERR' not in f and np.isnan(f[0].data[1,3]) and f['MASK'].data[1,3]==1 +`,resolve(dir,'image.fits')],{env:{...process.env,...tc.env}}); + } +}); diff --git a/tools/objects/telescopes/native-figure.mts b/tools/objects/telescopes/native-figure.mts new file mode 100644 index 0000000000..374226c472 --- /dev/null +++ b/tools/objects/telescopes/native-figure.mts @@ -0,0 +1,85 @@ +/** Decode through the existing format owners, then hand arrays to Astropy/Matplotlib. */ +import { mkdir,readFile,writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { pdsPackages } from '../astronomy-packages/pds-client.mts'; +import { astroqueryToolchain } from '../astronomy-packages/toolchain.mts'; +import { decodeIsis3Core } from '../terrestrial-layers/isis3-raster.mts'; +import { isisMetadata,pdsMetadata } from './native-metadata.mts'; +import { requireRecord,requireArray,requireString } from '../../source-values.mts'; +import type { delivery } from './outputs.mts'; +const CONVERT=String.raw` +import json,sys +from pathlib import Path +import numpy as np +from astropy.io import fits +from astropy import units as u +r=json.load(sys.stdin);meta=r['metadata'];path=Path(r['directory']) +a=np.load(path/r['array'],mmap_mode='r') if r['encoding']=='npy' else np.memmap(path/r['array'],mode='r',dtype='>,directory:string,structure?:string){ + if(/\.fits?$/iu.test(d.file)){if(structure)throw new Error('FITS inputs use --hdu');return {file:d.file};} + const observation=requireRecord(d.producing.parameters.observation),decoder=requireString(observation.decoder); + const originalLabel=requireString(observation.labelPath); + const matches=d.files.filter(f=>f.path===originalLabel||f.path.endsWith('/'+originalLabel)); + if(matches.length!==1)throw new Error('Native label is not uniquely pinned in this delivery'); + const label=resolve(d.directory,matches[0].path); + await mkdir(directory,{recursive:true});let array:string,encoding:string,shape:readonly number[]|undefined,metadata; + const companions:{kind:string;array:string;unit?:unknown}[]=[];let packages:Record={}; + if(decoder==='isis3'){ + if(structure&&structure!=='IsisCube')throw new Error('ISIS structure is IsisCube'); + const labelBytes=await readFile(label),core=decodeIsis3Core(await readFile(d.file),labelBytes); + const threshold=Buffer.from('faff7fff','hex').readFloatLE(); + for(let i=0;irequireRecord(v)).filter(v=>v.arrayFile!==undefined); + const primary=arrays.filter(v=>v.name==='IMAGE'); + const standardImage=primary.length===1&&arrays.every(v=>['IMAGE','SIGMA_MAP_IMAGE','QUALITY_MAP_IMAGE'].includes(String(v.name))); + const selected=structure?arrays.filter(v=>v.name===structure):standardImage?primary:arrays; + packages={pdr:answer.pdr}; + if(selected.length===1&&selected[0].name==='IMAGE')for(const [name,kind] of [['SIGMA_MAP_IMAGE','ERR'],['QUALITY_MAP_IMAGE','DQ']]){ + const found=arrays.filter(v=>v.name===name);if(found.length>1)throw new Error('Ambiguous native companion '+name); + if(found.length===1)companions.push({kind,array:requireString(found[0].arrayFile),unit:requireRecord(found[0].nativeMetadata).unit}); + } + if(selected.length!==1)throw new Error('Select one native array with --structure: '+arrays.map(v=>v.name).join(', ')); + array=requireString(selected[0].arrayFile);if(!/^native-\d+\.npy$/.test(array))throw new Error('Invalid decoded array path'); + encoding='npy';metadata=pdsMetadata({...decoded,structures:selected}).nativeMetadata!; + }else throw new Error(`No native figure reader for ${decoder}`); + const tc=await astroqueryToolchain(); + execFileSync(tc.python,['-c',CONVERT],{input:JSON.stringify({directory,array,encoding,shape,metadata,companions}),env:{...process.env,...tc.env},maxBuffer:1024*1024}); + return {file:resolve(directory,'native.fits'),native:{decoder,structure:metadata.structure,metadata,packages,companions,qualityPolicy:'IMAGE associates SIGMA_MAP_IMAGE as standard deviation and QUALITY_MAP_IMAGE as flags; finite zero flags only. Other structures require explicit selection.',coordinates:'Pixel coordinates; native planetary projection is not converted to celestial WCS.',conversion:'Decoded values and masked special constants; no recalibration. Spectral centers preserved as WAVE-TAB; edges are not inferred.'}}; +} diff --git a/tools/objects/telescopes/native-metadata.mts b/tools/objects/telescopes/native-metadata.mts index f4387a939a..2529cf11a8 100644 --- a/tools/objects/telescopes/native-metadata.mts +++ b/tools/objects/telescopes/native-metadata.mts @@ -1,22 +1,39 @@ /** Product metadata, never catalogue capabilities. Deliberately bounded format support. */ -import { readFitsHdus, fitsImageAccessor } from '../../fits.mts'; import { requireArray, requireRecord, requireString, requireFiniteNumber } from '../../source-values.mts'; import type { ProductFacts } from './request-satisfaction.mts'; export interface NativeMetadata { + readonly fitsHdu?: number; + readonly shape?: readonly number[]; + readonly structures?: readonly NativeMetadata[]; + readonly quality?: { readonly policy: string; readonly samples: number; readonly finite: number; readonly usable: number; readonly flagged: number; readonly invalidUncertainty: number; readonly mask: string | null }; + readonly uncertainty?: { readonly status: 'validated' | 'unknown'; readonly kind: string | null; readonly structure: string | null }; readonly structure: string; readonly units?: { readonly value: string; readonly source: string }; - readonly spectral?: { readonly centersMicrometres: readonly number[]; readonly source: string }; + readonly spectral?: { readonly axis?: number; readonly centersMicrometres: readonly number[]; readonly binEdgesMicrometres?: readonly number[]; readonly source: string; readonly usableBands?: readonly boolean[] }; readonly calibration: readonly { readonly field: string; readonly value: string }[]; readonly limitations: readonly string[]; } -export function parseNativeMetadata(raw: unknown): NativeMetadata { +export function parseNativeMetadata(raw: unknown, depth = 0): NativeMetadata { + if(depth > 1) throw new Error('Nested science structures exceed supported depth'); const v = requireRecord(raw, 'native metadata'); const units = v.units === undefined ? undefined : requireRecord(v.units); const spectral = v.spectral === undefined ? undefined : requireRecord(v.spectral); + const quality = v.quality === undefined ? undefined : requireRecord(v.quality); + const uncertainty = v.uncertainty === undefined ? undefined : requireRecord(v.uncertainty); + if (quality) for (const key of ['samples','finite','usable','flagged','invalidUncertainty']) { const n=requireFiniteNumber(quality[key]); if(!Number.isSafeInteger(n)||n<0||n>requireFiniteNumber(quality.samples))throw new Error('Invalid science quality counts'); } + if(uncertainty && !['validated','unknown'].includes(String(uncertainty.status)))throw new Error('Invalid uncertainty status'); + if(spectral?.usableBands !== undefined && (requireArray(spectral.usableBands).length!==requireArray(spectral.centersMicrometres).length || requireArray(spectral.usableBands).some(x=>typeof x!=='boolean')))throw new Error('Invalid spectral mask'); + if(v.fitsHdu!==undefined&&(!Number.isSafeInteger(v.fitsHdu)||Number(v.fitsHdu)<0))throw new Error('Invalid FITS HDU identity'); + if(spectral?.axis!==undefined&&(!Number.isSafeInteger(spectral.axis)||Number(spectral.axis)<0||Number(spectral.axis)>=requireArray(v.shape).length))throw new Error('Invalid spectral axis identity'); return { structure: requireString(v.structure), + ...(v.fitsHdu===undefined?{}:{fitsHdu:requireFiniteNumber(v.fitsHdu)}), + ...(v.shape ? {shape:requireArray(v.shape).map(n=>{const value=requireFiniteNumber(n);if(!Number.isSafeInteger(value)||value<1)throw new Error('Invalid science shape');return value;})}:{}), + ...(v.structures ? {structures:requireArray(v.structures).map(s=>parseNativeMetadata(s,depth+1))}:{}), + ...(quality ? {quality:{policy:requireString(quality.policy),samples:requireFiniteNumber(quality.samples),finite:requireFiniteNumber(quality.finite),usable:requireFiniteNumber(quality.usable),flagged:requireFiniteNumber(quality.flagged),invalidUncertainty:requireFiniteNumber(quality.invalidUncertainty),mask:quality.mask===null?null:requireString(quality.mask)}}:{}), + ...(uncertainty ? {uncertainty:{status:uncertainty.status as 'validated'|'unknown',kind:uncertainty.kind===null?null:requireString(uncertainty.kind),structure:uncertainty.structure===null?null:requireString(uncertainty.structure)}}:{}), ...(units ? { units: { value: requireString(units.value), source: requireString(units.source) } } : {}), - ...(spectral ? { spectral: { centersMicrometres: coordinates(requireArray(spectral.centersMicrometres).map(n => requireFiniteNumber(n))), source: requireString(spectral.source) } } : {}), + ...(spectral ? { spectral: { ...(spectral.axis===undefined?{}:{axis:requireFiniteNumber(spectral.axis)}), centersMicrometres: coordinates(requireArray(spectral.centersMicrometres).map(n => requireFiniteNumber(n))), source: requireString(spectral.source), ...(spectral.binEdgesMicrometres === undefined ? {} : {binEdgesMicrometres: coordinates(requireArray(spectral.binEdgesMicrometres).map(n=>requireFiniteNumber(n)),requireArray(spectral.centersMicrometres).length+1)}), ...(spectral.usableBands === undefined ? {} : {usableBands: requireArray(spectral.usableBands) as boolean[]}) } } : {}), calibration: requireArray(v.calibration).map(raw => { const row = requireRecord(raw); return { field: requireString(row.field), value: requireString(row.value) }; }), limitations: requireArray(v.limitations).map(s => requireString(s)) }; } @@ -27,68 +44,16 @@ function coordinates(values: number[], expected = values.length): number[] { return values; } const wavelengthScale = (unit: string) => ({ m: 1e6, nm: .001, um: 1, micron: 1, microns: 1, micrometer: 1, micrometers: 1, micrometre: 1, micrometres: 1, angstrom: .0001, angstroms: .0001 }[unit.trim().toLowerCase().replace(/[µμ]/gu, 'u')]); -// Supported physical unit spellings. Unknown strings are retained as limitations, not promoted. +// Read the spelling here. The shared readback validates it with Astropy before publication. function units(raw: unknown, source: string, limitations: string[]): NativeMetadata['units'] { if (raw === undefined) { limitations.push(`No data unit in ${source}.`); return undefined; } if (typeof raw !== 'string' || !raw.trim()) throw new Error(`Invalid native data unit in ${source}.`); const value = raw.trim(); - if (!['I/F', '1', 'DIMENSIONLESS', 'DN', 'count', 'counts', 'electron', 'electrons', 'Jy', 'mJy', 'MJy/sr', 'Jy/beam', 'K', 'W m-2 sr-1 um-1'].includes(value)) { - limitations.push(`Unsupported data unit ${JSON.stringify(value)} in ${source}.`); return undefined; - } return { value, source }; } type MetadataFacts = Pick; const missingResolution = 'No product-specific measured PSF or calibrated beam; pixel spacing and nominal optics do not establish achieved resolution.'; -/** FITS image WCS: separable linear WAVE/FREQ axes only. Never interpret axis 3 by position alone. */ -export function fitsMetadata(bytes: Buffer, pin: { file: string; sha256: string }): MetadataFacts { - const hdus = readFitsHdus(bytes); - const images = hdus.filter(h => h.count && (!h.header.XTENSION || h.header.XTENSION === 'IMAGE')); - const science = images.filter(h => h.header.EXTNAME === 'SCI'); - const hdu = science.length === 1 ? science[0] : images.length === 1 ? images[0] : undefined; - const limitations: string[] = [], calibration: { field: string; value: string }[] = []; - if (!hdu) return { nativeMetadata: { structure: 'unresolved', calibration, limitations: ['No unique science image HDU; metadata cannot be assigned by file order.'] } }; - const h = hdu.header, structure = `HDU ${hdus.indexOf(hdu)} (${h.EXTNAME ?? 'PRIMARY'})`; - const unit = units(h.BUNIT, `${structure}:BUNIT`, limitations); - let spectral: NativeMetadata['spectral'], intervals: [number, number][] | undefined; - const axes = hdu.dimensions.map((_, i) => i + 1).filter(i => /^(WAVE|AWAV|FREQ|VRAD|VOPT|VELO)/u.test(String(h[`CTYPE${i}`] ?? ''))); - if (axes.length > 1) throw new Error('Ambiguous FITS spectral axes.'); - if (axes.length === 1) { - const axis = axes[0], type = h[`CTYPE${axis}`], count = hdu.dimensions[axis - 1]; - const scale = typeof h[`CUNIT${axis}`] === 'string' ? type === 'WAVE' ? wavelengthScale(String(h[`CUNIT${axis}`])) - : ({ Hz: 1, kHz: 1e3, MHz: 1e6, GHz: 1e9 }[String(h[`CUNIT${axis}`])]) : undefined; - const coupled = hdu.dimensions.some((_, j) => j + 1 !== axis && (Number(h[`PC${axis}_${j + 1}`] ?? 0) !== 0 || Number(h[`CD${axis}_${j + 1}`] ?? 0) !== 0)); - if (!['WAVE', 'FREQ'].includes(String(type)) || !scale || coupled) limitations.push('Unsupported spectral WCS, missing/unsupported coordinate units, or spatially coupled wavelengths.'); - else { - const hasCD = Object.keys(h).some(k => /^CD\d+_\d+$/u.test(k)); - const step = hasCD ? h[`CD${axis}_${axis}`] : Number(h[`CDELT${axis}`]) * Number(h[`PC${axis}_${axis}`] ?? 1); - const ref = h[`CRVAL${axis}`], pixel = h[`CRPIX${axis}`]; - if (![step, ref, pixel].every(n => typeof n === 'number' && Number.isFinite(n)) || step === 0) throw new Error('Incomplete or invalid FITS spectral WCS.'); - const wavelength = (p: number) => { const coordinate = ((ref as number) + (p - (pixel as number)) * (step as number)) * scale; - if (!(coordinate > 0) || !Number.isFinite(coordinate)) throw new Error('Invalid FITS spectral coordinate.'); return type === 'FREQ' ? 299792458e6 / coordinate : coordinate; }; - const centers = coordinates(Array.from({ length: count }, (_, i) => wavelength(i + 1))); - spectral = { centersMicrometres: centers, source: `${structure}:CTYPE${axis}/CUNIT${axis}/CRVAL${axis}/CRPIX${axis}/CD or PC,CDELT` }; - const valid = new Array(count).fill(false), at = fitsImageAccessor(bytes, hdu), stride = hdu.dimensions.slice(0, axis - 1).reduce((a, b) => a * b, 1); - for (let i = 0; i < hdu.count; i++) if (Number.isFinite(at(i))) valid[Math.floor(i / stride) % count] = true; - intervals = centers.flatMap((_, i) => valid[i] ? [[Math.min(wavelength(i + .5), wavelength(i + 1.5)), Math.max(wavelength(i + .5), wavelength(i + 1.5))] as [number, number]] : []); - limitations.push('Spectral intervals are WCS pixel-bin support with finite samples, not optical bandpasses or spectral resolution; quality flags and per-pixel coverage are not assessed.'); - } - } else limitations.push('No spectral coordinate axis in the selected science HDU.'); - let angularResolutionArcsec: number | undefined; - if (h.BMAJ !== undefined || h.BMIN !== undefined) { - if (typeof h.BMAJ !== 'number' || typeof h.BMIN !== 'number' || !(h.BMAJ >= h.BMIN && h.BMIN > 0)) throw new Error('Invalid or incomplete FITS restoring beam axes.'); - for (const key of ['BMAJ', 'BMIN', 'BPA']) if (h[key] !== undefined) calibration.push({ field: `${structure}:${key}`, value: String(h[key]) }); - if (unit?.value === 'Jy/beam' && !hdus.some(h => h.header.EXTNAME === 'BEAMS') && h.CASAMBM !== true) { - angularResolutionArcsec = h.BMAJ * 3600; - limitations.push('Resolution is the product restoring-beam major-axis FWHM; this does not independently validate deconvolution or residual emission.'); - } else limitations.push('Beam metadata is not applicable as one verified restoring beam (unit or per-plane beam ambiguity).'); - } - if (angularResolutionArcsec === undefined) limitations.push(missingResolution); - return { nativeMetadata: { structure, ...(unit ? { units: unit } : {}), ...(spectral ? { spectral } : {}), calibration, limitations }, - ...(intervals?.length ? { wavelengthIntervalsMicrometres: intervals } : {}), - ...(angularResolutionArcsec === undefined ? {} : { angularResolutionArcsec, resolutionEvidence: [{ kind: 'calibrated', receipt: pin }] }) }; -} - /** ISIS groups are confined to the attached label, not binary data or original uncalibrated labels. */ function group(label: string, name: string): string { const found = [...label.matchAll(new RegExp(`^\\s*Group\\s*=\\s*${name}\\s*\\r?\\n([\\s\\S]*?)^\\s*End_Group\\b`, 'gmi'))]; @@ -105,6 +70,12 @@ function numbers(text: string): number[] { if (new Set(explicitUnits).size > 1) throw new Error('Mixed ISIS coordinate units are unsupported.'); return text.replace(/<[^<>]*>/gu, '').replace(/[()]/gu, '').split(',').map(s => { const n = Number(s.trim()); if (!s.trim() || !Number.isFinite(n)) throw new Error('Invalid ISIS numeric coordinate.'); return n; }); } +/** Named geometry backplanes may inherit spectral keywords from their input cube. */ +export function isisGeometryBands(bytes:Buffer):boolean { + const header=bytes.subarray(0,128*1024).toString('latin1').split(/^End\s*$/mu)[0]; + const names=field(group(header,'BandBin'),'Name')?.replace(/[()"]/gu,'').split(',').map(n=>n.trim().toLowerCase()); + return !!names?.length && names.every(n=>['phase angle','emission angle','incidence angle','latitude','longitude','pixel resolution'].includes(n)); +} export function isisMetadata(bytes: Buffer, bands: number, validBands?: readonly boolean[]): MetadataFacts { const header = bytes.subarray(0, 128 * 1024).toString('latin1').split(/^End\s*$/mu)[0]; const band = group(header, 'BandBin'), cal = group(header, 'RadiometricCalibration'); @@ -112,8 +83,9 @@ export function isisMetadata(bytes: Buffer, bands: number, validBands?: readonly for (const key of ['CalibrationVersion', 'OutputUnits', 'WavelengthCalibrationFile', 'BandwidthFile', 'AverageBandwidthFile', 'FlatFile']) { const value = field(cal, key); if (value) calibration.push({ field: `RadiometricCalibration:${key}`, value }); } - if (calibration.some(row => row.field.endsWith('File'))) limitations.push('Calibration file references are recorded from the pinned label; their external bytes were not read or independently validated.'); + if (calibration.some(row => row.field.endsWith('File'))) limitations.push('Calibration file references are recorded from the pinned label; external byte and applicability status is recorded separately in calibrationDependencies.'); const unit = units(field(cal, 'OutputUnits'), 'RadiometricCalibration:OutputUnits', limitations); + if(isisGeometryBands(bytes))return {nativeMetadata:{structure:'IsisCube:geometry backplanes',calibration,limitations:['Named geometry backplanes; inherited spectral keywords do not describe measured wavelengths.']}}; const center = field(band, 'Center'); let spectral: NativeMetadata['spectral'], intervals: [number, number][] | undefined; if (center) { const values = coordinates(numbers(center), bands); diff --git a/tools/objects/telescopes/native-metadata.test.mts b/tools/objects/telescopes/native-metadata.test.mts index 69f982f5f0..6e694ccefa 100644 --- a/tools/objects/telescopes/native-metadata.test.mts +++ b/tools/objects/telescopes/native-metadata.test.mts @@ -1,49 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { mkdtemp, writeFile, readFile, mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { resolve } from 'node:path'; -import { sha256 } from '../../../src/platform/sha256.mts'; -import { card } from '../../../tests/fixtures/fits/helpers.mts'; -import { fitsMetadata, isisMetadata, pdsMetadata, parseNativeMetadata } from './native-metadata.mts'; -import { qualifySourceProduct } from './qualify-source.mts'; -import { sourceRun, type SourceProduct } from './source-products.mts'; -import { parseProductFacts } from './qualified-observations.mts'; -import { assessRequest } from './request-satisfaction.mts'; -const pin = { file: 'cube.fits', sha256: 'a'.repeat(64) }; -const cube = (extra: readonly string[] = [], values = [1, 2, 3]) => { - const header = Buffer.from([card('SIMPLE', 'T'), card('BITPIX', '-32'), card('NAXIS', '3'), card('NAXIS1', '1'), card('NAXIS2', '1'), card('NAXIS3', String(values.length)), ...extra, 'END'.padEnd(80)].join('').padEnd(2880)); - const data = Buffer.alloc(2880); values.forEach((n, i) => data.writeFloatBE(n, i * 4)); return Buffer.concat([header, data]); -}; -const wave = [card('CTYPE3', "'WAVE'"), card('CUNIT3', "'nm'"), card('CRVAL3', '1000'), card('CRPIX3', '1'), card('CDELT3', '100'), card('BUNIT', "'MJy/sr'")]; +import { isisMetadata, pdsMetadata } from './native-metadata.mts'; const isis = (fields: string, calibration = '') => Buffer.from(`Object = IsisCube\nGroup = BandBin\n${fields}\nEnd_Group\nGroup = RadiometricCalibration\n${calibration}\nEnd_Group\nEnd_Object\nEnd\n`); -test('native FITS coordinates use one-based WCS, real units and retain holes in finite plane coverage', () => { - const result = fitsMetadata(cube(wave, [1, NaN, 3]), pin); - assert.deepEqual(result.nativeMetadata?.spectral?.centersMicrometres, [1, 1.1, 1.2]); - assert.deepEqual(result.wavelengthIntervalsMicrometres, [[.9500000000000001, 1.05], [1.1500000000000001, 1.25]]); - assert.equal(result.nativeMetadata?.units?.value, 'MJy/sr'); - assert.equal(assessRequest({ target: 'x', wavelengthMicrometres: [1, 1.2] }, { target: 'x', verified: true, ...result }).constraints.wavelength?.answer, 'partial'); - assert.deepEqual(parseNativeMetadata(result.nativeMetadata), result.nativeMetadata); -}); -test('descending frequency axes convert to wavelength; coupled and unsupported WCS stay unknown', () => { - const base = [card('CTYPE3', "'FREQ'"), card('CUNIT3', "'GHz'"), card('CRVAL3', '300'), card('CRPIX3', '1'), card('CDELT3', '-1')]; - const result = fitsMetadata(cube(base), pin); - assert.ok(result.nativeMetadata!.spectral!.centersMicrometres[1] > result.nativeMetadata!.spectral!.centersMicrometres[0]); - assert.equal(fitsMetadata(cube([...wave, card('PC3_1', '.1')]), pin).wavelengthIntervalsMicrometres, undefined); - assert.equal(fitsMetadata(cube(wave.map(c => c.startsWith('CTYPE3') ? card('CTYPE3', "'WAVE-TAB'") : c)), pin).wavelengthIntervalsMicrometres, undefined); - assert.throws(() => fitsMetadata(cube(wave.filter(c => !c.startsWith('CRPIX3'))), pin), /Incomplete/); -}); -test('a product restoring beam requires both axes and applicable units; pixel sampling never becomes a PSF', () => { - const beam = [card('BUNIT', "'Jy/beam'"), card('BMAJ', '0.001'), card('BMIN', '0.0001'), card('BPA', '45')]; - const facts = fitsMetadata(cube(beam), pin); - assert.equal(facts.angularResolutionArcsec, 3.6); - assert.deepEqual(facts.resolutionEvidence, [{ kind: 'calibrated', receipt: pin }]); - assert.equal(fitsMetadata(cube([card('CDELT1', '0.00001')]), pin).angularResolutionArcsec, undefined); - assert.throws(() => fitsMetadata(cube(beam.filter(c => !c.startsWith('BMIN'))), pin), /beam axes/); - assert.equal(fitsMetadata(cube([...beam, card('CASAMBM', 'T')]), pin).angularResolutionArcsec, undefined); - assert.equal(fitsMetadata(cube(beam.map(c => c.startsWith('BUNIT') ? card('BUNIT', "'DN'") : c)), pin).angularResolutionArcsec, undefined); -}); test('ISIS centers are count-checked and never inflated to continuous coverage without widths', () => { const result = isisMetadata(isis('Center = (1000,\n 1200, 1400) \nOriginalBand = (1, 2, 3)', 'OutputUnits = I/F'), 3); assert.deepEqual(result.nativeMetadata?.spectral?.centersMicrometres, [1, 1.2, 1.4000000000000001]); @@ -53,7 +12,7 @@ test('ISIS centers are count-checked and never inflated to continuous coverage w assert.throws(() => isisMetadata(isis('Center = (1, 2, 2) '), 3), /monotonic/); assert.equal(isisMetadata(isis('Center = (1, 2, 3)'), 3).nativeMetadata?.spectral, undefined); assert.equal(isisMetadata(isis('Center = (1, 2, 3) '), 3).nativeMetadata?.spectral, undefined); - assert.equal(isisMetadata(isis('Center = (1, 2, 3) ', 'OutputUnits = nonsense'), 3).nativeMetadata?.units, undefined); + assert.equal(isisMetadata(isis('Center = (1, 2, 3) ', 'OutputUnits = nonsense'), 3).nativeMetadata?.units?.value, 'nonsense'); // The shared Astropy boundary validates the recorded spelling. }); test('ISIS band widths preserve spectral gaps and exclude empty bands', () => { const result = isisMetadata(isis('Center = (1, 2, 3) \nWidth = (100, 100, 100) '), 3, [true, false, true]); @@ -69,31 +28,6 @@ test('PDS optical filters and units must belong to the decoded array, never anot decoded.metadata.opticalFilters.push(decoded.metadata.opticalFilters[0]); assert.throws(() => pdsMetadata(decoded), /Ambiguous/); }); -test('native qualification persists metadata, pins beam evidence and invalidates changed calibration bytes', async () => { - const root = await mkdtemp(resolve(tmpdir(), 'native-metadata-')); - try { - const bytes = cube([...wave.map(c => c.startsWith('BUNIT') ? card('BUNIT', "'Jy/beam'") : c), card('BMAJ', '0.001'), card('BMIN', '0.0005')]); - await mkdir(resolve(root, 'source')); await writeFile(resolve(root, pin.file), bytes); - const product: SourceProduct = { id: 'cube', target: 'x', telescope: 'fixture', mode: 'cube', kind: 'cube', archiveProductId: 'native', decoder: 'fits-image', identity: { SIMPLE: true }, units: 'unchecked wrong units', meaning: 'fixture', citation: 'https://example.org', limitations: [], - files: [{ role: 'science', path: pin.file, origin: 'https://example.org/cube.fits', bytes: bytes.length, sha256: sha256(bytes) }] }; - const first = await qualifySourceProduct(root, product); - const report = JSON.parse(await readFile(resolve(root, 'output/telescopes/x/cube/decoded.json'), 'utf8')); - const facts = parseProductFacts(report.facts); - assert.equal(facts.nativeMetadata?.units?.value, 'Jy/beam'); - assert.equal(assessRequest({ target: 'x', wavelengthMicrometres: [1, 1.2], angularResolutionArcsec: 4 }, facts).status, 'fulfilled'); - assert.equal((await qualifySourceProduct(root, product)).reused, true); - const changed = Buffer.from(bytes); changed.write(card('BMAJ', '0.002'), bytes.indexOf('BMAJ')); - await writeFile(resolve(root, pin.file), changed); - await assert.rejects(qualifySourceProduct(root, product), /manifest pin/); - const revised = { ...product, files: [{ ...product.files[0], sha256: sha256(changed) }] }; - assert.notDeepEqual((await sourceRun(product)).inputs, (await sourceRun(revised)).inputs); - assert.equal((await qualifySourceProduct(root, revised)).reused, false); - const after = JSON.parse(await readFile(resolve(root, 'output/telescopes/x/cube/decoded.json'), 'utf8')); - assert.equal(after.facts.angularResolutionArcsec, 7.2); - assert.equal(first.reused, false); - } finally { await rm(root, { recursive: true, force: true }); } -}); - test('PDS band coordinates require explicit band cardinality and wavelength units', () => { const decoded = { metadata: {}, structures: [{ name: 'QUBE', shape: [3, 2, 2], nativeMetadata: { unit: 'I/F', bands: 3, centers: [1000, 1100, 1200], wavelengthUnit: 'nm' } }] }; assert.deepEqual(pdsMetadata(decoded).nativeMetadata?.spectral?.centersMicrometres, [1, 1.1, 1.2]); @@ -113,3 +47,8 @@ test('mixed units and coordinate overflow cannot silently qualify a spectral axi assert.throws(() => isisMetadata(isis('Center = (1 , 2 , 3 )'), 3), /Mixed/); assert.throws(() => isisMetadata(isis('Center = (1e308) '), 1), /Invalid native/); }); + +test('named geometry planes cannot masquerade as a spectral cube',()=>{ + const f=isisMetadata(isis('Center = (1,1) \nOriginalBand = (97,97)\nName = (Latitude, Longitude)'),2); + assert.equal(f.nativeMetadata?.spectral,undefined);assert.match(f.nativeMetadata!.structure,/geometry/); +}); diff --git a/tools/objects/telescopes/output-handoffs.test.mts b/tools/objects/telescopes/output-handoffs.test.mts new file mode 100644 index 0000000000..baa4066399 --- /dev/null +++ b/tools/objects/telescopes/output-handoffs.test.mts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp,writeFile,readFile,rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { astroqueryToolchain } from '../astronomy-packages/toolchain.mts'; +import { pdsPackages } from '../astronomy-packages/pds-client.mts'; +import { pinFile,writeProductRecord } from '../product-record.mts'; +import { exportOutput,listOutputs } from './outputs.mts'; +import { exportSpatialObject } from './spatial-handoff.mts'; +import { parseCli } from './cli.mts'; + +test('PDS arrays retain integer flags, special constants and associated uncertainty through figure export',async()=>{ + const root=await mkdtemp(resolve(tmpdir(),'native-output-')); + try{ + const array=(name:string,offset:number,type:string,unit?:string,missing=false)=>`${name}${offset}2Last Index Fastest${type}${unit?`${unit}`:''}${name==='IMAGE'?'0.5100':''}Line21Sample22${missing?'-999':''}`; + const xml=`data.bin${array('IMAGE',0,'IEEE754LSBSingle','W m-2',true)}${array('SIGMA_MAP_IMAGE',16,'IEEE754LSBSingle','W m-2')}${array('QUALITY_MAP_IMAGE',32,'UnsignedByte')}`; + const bytes=Buffer.alloc(36);[10,20,-999,40,1,2,3,4].forEach((v,i)=>bytes.writeFloatLE(v,i*4));bytes.set([0,2,0,0],32); + const label=resolve(root,'label.xml'),data=resolve(root,'data.bin'),record=resolve(root,'input.json');await writeFile(label,xml);await writeFile(data,bytes); + const decoded=await pdsPackages({operation:'decode-product',labelPath:label,arrayDirectory:resolve(root,'arrays')}); + assert.equal(decoded.decoded?.structures.find(s=>s.name==='QUALITY_MAP_IMAGE')?.dtype,'uint8'); + await writeProductRecord(record,{telescope:'Fixture',stage:'fixture',inputs:[],parameters:{observation:{decoder:'pds-product',labelPath:'label.xml'}},software:[]},[{path:'data.bin',file:data},{path:'label.xml',file:label}]); + const result=resolve(root,'result.json');await writeFile(result,JSON.stringify({schema:'cssearth-telescope-delivery@1',product:'data.bin',record:'input.json',receipt:'input.json',facts:{target:'fixture',verified:true},request:{target:'fixture'},satisfaction:{status:'unresolved'},files:await Promise.all(['data.bin','label.xml','input.json'].map(async path=>({path,...await pinFile(resolve(root,path))})))})); + const choices=await listOutputs(result);assert.ok(choices.outputs.some(o=>o.kind==='image'&&o.available)); + const output=await exportOutput(result,{kind:'image',hdu:0,structure:'IMAGE'},resolve(root,'export')); + const tc=await astroqueryToolchain(); + execFileSync(tc.python,['-c',`import sys,numpy as np +from astropy.io import fits +from astropy import units as u +with fits.open(sys.argv[1]) as f: + np.testing.assert_equal(f[0].data,[[105,np.nan],[np.nan,120]]) + np.testing.assert_equal(f['ERR'].data,[[1,np.nan],[np.nan,4]]) + assert u.Unit(f[0].header['BUNIT']).is_equivalent(u.W/u.m**2) +a=np.load(sys.argv[2]);assert a.dtype==np.dtype('uint8');np.testing.assert_equal(a,[[0,2],[0,0]])`,output.data,resolve(root,'arrays/native-2.npy')],{env:{...process.env,...tc.env}}); + const evidence=JSON.parse(await readFile(output.receipt,'utf8'));assert.equal(evidence.parameters.sourceSatisfaction.status,'unresolved'); + await writeFile(data,Buffer.alloc(36));await assert.rejects(listOutputs(result),/pin mismatch/); + }finally{await rm(root,{recursive:true,force:true});} +}); + +test('large file-backed extraction crosses a row chunk boundary without losing the last samples',async()=>{ + const root=await mkdtemp(resolve(tmpdir(),'large-output-')),tc=await astroqueryToolchain(); + try{ + const file=resolve(root,'wide.fits'); + execFileSync(tc.python,['-c',`import sys,numpy as np +from astropy.io import fits +h=fits.PrimaryHDU(np.ones((2,1,1000003),dtype='float32'));h.data[1]*=3 +for k,v in {'BUNIT':'Jy','CTYPE3':'WAVE','CUNIT3':'um','CRVAL3':1.,'CRPIX3':1.,'CDELT3':1.}.items():h.header[k]=v +h.writeto(sys.argv[1])`,file],{env:{...process.env,...tc.env}}); + const {sciencePackage}=await import('../astronomy-packages/science.mts'); + const answer=await sciencePackage({operation:'extract',path:file,hdu:0,kind:'band-image',band:[.5,2.5],arrayDirectory:resolve(root,'arrays')}); + assert.ok(!JSON.stringify(answer).includes('"values":[')); + execFileSync(tc.python,['-c',`import sys,numpy as np +a=np.load(sys.argv[1],mmap_mode='r');assert a.shape==(1,1000003);np.testing.assert_equal(a,2)`,resolve(root,'arrays/values.npy')],{env:{...process.env,...tc.env}}); + }finally{await rm(root,{recursive:true,force:true});} +}); + +test('physical handoffs refuse spectral deliveries and incompatible selectors',async()=>{ + const root=await mkdtemp(resolve(tmpdir(),'spatial-refusal-')); + try{ + const path=resolve(root,'result.json');await writeFile(path,JSON.stringify({schema:'cssearth-telescope-delivery@1',facts:{kind:'cube'}})); + await assert.rejects(exportSpatialObject(path,'volume',resolve(root,'output')),/does not establish depth/); + assert.equal(parseCli(['export','object.json','--output','points','--out','out']).command,'spatial'); + assert.throws(()=>parseCli(['export','object.json','--output','volume','--hdu','1','--out','out']),/only/); + }finally{await rm(root,{recursive:true,force:true});} +}); + + +test('inactive sphere images are removed without changing geometry or active textures',async()=>{ + const {clearInactiveImageBindings}=await import('./sphere-assets.mts'); + const properties=[{name:'--surface-image',value:'url("/surface.webp")'},{name:'--interior-image',value:'url("/interior.webp"), url("/surface.webp")'},{name:'--transform',value:'rotateY(24deg) scale(2)'},{name:'--different-image',value:'url("/interior.webp-extra")'}]; + const original=structuredClone(properties),result=clearInactiveImageBindings(properties,[{url:'/interior.webp'}]); + assert.deepEqual(result.inactiveImageProperties,['--interior-image']); + assert.equal(result.properties[1].value,'none, url("/surface.webp")'); + for(const index of [0,2,3])assert.equal(result.properties[index],properties[index]); + assert.deepEqual(properties,original); +}); diff --git a/tools/objects/telescopes/output-oracle.mts b/tools/objects/telescopes/output-oracle.mts new file mode 100644 index 0000000000..a709f2ad7b --- /dev/null +++ b/tools/objects/telescopes/output-oracle.mts @@ -0,0 +1,118 @@ +/** Test-only, independently implemented references. Never calls the production cube reducer. */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { mkdir } from 'node:fs/promises'; +import { requireRecord } from '../../source-values.mts'; +export const ORACLE_PYTHON=String.raw` +import json,sys,hashlib,csv +from pathlib import Path +import numpy as np +import astropy, specutils, photutils +from astropy import units as u +from astropy.io import fits +from astropy.wcs import WCS +from specutils import Spectrum +from specutils.analysis import line_flux +from photutils.aperture import RectangularAperture, aperture_photometry +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +r=json.load(sys.stdin); directory=Path(r['directory']); out=Path(r['out']) +record=json.loads((directory/'output.product.json').read_text()); selection=record['parameters']['selection']; kind=selection['kind'] +for item in record['outputs']: + if hashlib.sha256((directory/item['path']).read_bytes()).hexdigest()!=item['sha256']: raise ValueError('Output pin mismatch') +delivery=next(i for i in record['inputs'] if i['role']=='delivery'); path=Path(delivery['identity']) +if hashlib.sha256(path.read_bytes()).hexdigest()!=delivery['sha256']: raise ValueError('Delivery pin mismatch') +d=json.loads(path.read_text()); source=path.parent/d['product']; pin=next(i for i in d['files'] if i['path']==d['product']) +if hashlib.sha256(source.read_bytes()).hexdigest()!=pin['sha256']: raise ValueError('Source pin mismatch') +with fits.open(source) as hdus: + h=hdus[selection['hdu']]; data=np.array(h.data,dtype=float); header=h.header + if data.ndim!=3: raise ValueError('This oracle currently checks three-dimensional FITS cubes') + unit=u.Unit(header['BUNIT']); mask=~np.isfinite(data) + if 'DQ' in hdus: mask|=hdus['DQ'].data!=0 + if 'ERR' in hdus: mask|=~np.isfinite(hdus['ERR'].data)|(hdus['ERR'].data<0) + w=WCS(header,hdus,fix=False); sw=w.wcs.spec + if sw!=2 or not str(w.wcs.ctype[sw]).startswith('WAVE') or '-TAB' in str(w.wcs.ctype[sw]): raise ValueError('Oracle requires a separable wavelength WCS with bin edges') + n=data.shape[0] + def wave(p): + coordinates=np.tile(w.wcs.crpix-1,(len(p),1)); coordinates[:,2]=p + return (w.all_pix2world(coordinates,0)[:,sw]*u.Unit(w.world_axis_units[sw])).to_value(u.um) + centers=wave(np.arange(n)); edges=wave(np.arange(n+1)-.5) +descending=edges[0]>edges[-1] +if descending: data=data[::-1];mask=mask[::-1];centers=centers[::-1];edges=edges[::-1] + +def integrate(array,interval,unit): + # Supply explicit clipped edges to specutils, without its mask interpolation. + a,b=interval; chosen=(edges[:-1]a); indices=np.flatnonzero(chosen) + if not len(indices) or aedges[-1]: raise ValueError('Oracle interval outside source') + clipped=np.clip(edges[indices[0]:indices[-1]+2],a,b) + flux=np.moveaxis(np.where(np.isfinite(array[chosen]),array[chosen],0),0,-1)*unit + # line_flux sums every flux dimension: call it on each one-dimensional spectrum. + result=np.array([line_flux(Spectrum(flux=vector,spectral_axis=clipped*u.um,bin_specification='edges')).value for vector in flux.reshape(-1,flux.shape[-1])]).reshape(flux.shape[:-1]) + return result,mask[chosen].any(axis=0) + +if kind=='image': + plane=selection['plane']; plane=data.shape[0]-1-plane if descending else plane + reference=data[plane];bad=mask[plane];expected_unit=unit;owner='Direct Astropy FITS plane' +elif kind=='spectrum': + x,y=selection['pixel'];reference=data[:,y,x];bad=mask[:,y,x];expected_unit=unit;owner='Direct Astropy FITS pixel spectrum' +elif kind=='band-image': + reference,bad=integrate(data,selection['band'],unit);reference/=np.diff(selection['band'])[0];expected_unit=unit;owner='specutils.line_flux / band width' +elif kind=='feature-map': + a,b,c,d=selection['continuum']; low,high=selection['band'] + left,lbad=integrate(data,[a,b],unit);left/=b-a + right,rbad=integrate(data,[c,d],unit);right/=d-c + coordinate=centers[:,None,None] + xleft,_=integrate(coordinate,[a,b],u.dimensionless_unscaled);xleft/=b-a + xright,_=integrate(coordinate,[c,d],u.dimensionless_unscaled);xright/=d-c + # Evaluate the continuum independently on the entire cube, then integrate its residual. + baseline=left+(right-left)*(coordinate-xleft)/(xright-xleft) + reference,bad=integrate(data-baseline,[low,high],unit);bad|=lbad|rbad + expected_unit=unit*u.um;owner='specutils.line_flux of continuum-subtracted spectra' +elif kind=='aperture-spectrum': + def aperture(box): + x0,y0,x1,y1=box; region=RectangularAperture(((x0+x1-1)/2,(y0+y1-1)/2),x1-x0,y1-y0) + values=[float(aperture_photometry(np.where(np.isfinite(plane),plane,0),region,method='center')['aperture_sum'][0])/region.area for plane in data] + return np.array(values),mask[:,y0:y1,x0:x1].any(axis=(1,2)) + reference,bad=aperture(selection['aperture']) + if selection['background']!='none': + background,bgmask=aperture(selection['background']);reference-=background;bad|=bgmask + expected_unit=unit;owner='Photutils rectangular aperture sums / areas' +else: raise ValueError('Unknown output for oracle') +reference=np.where(bad,np.nan,reference) +rows=list(csv.DictReader((directory/'values.csv').open())) +actual=np.array([float(row['value']) if row['value'] else np.nan for row in rows]).reshape(reference.shape) +# CSV follows the original channel order, including descending wavelength cubes. +if kind in ('spectrum','aperture-spectrum') and len(rows)>1 and float(rows[0]['wavelength_um'])>float(rows[-1]['wavelength_um']): actual=actual[::-1] +mask_equal=bool(np.array_equal(np.isfinite(actual),np.isfinite(reference))) +valid=np.isfinite(actual)&np.isfinite(reference) +if not valid.any(): raise ValueError('No comparable valid samples') +delta=actual-reference; maximum=float(np.max(np.abs(delta[valid])));scale=max(float(np.max(np.abs(reference[valid]))),1e-30) +units_equal=u.Unit(record['parameters'].get('measurement',{}).get('unit',record['parameters']['metadata']['units']['value']))==expected_unit +passed=mask_equal and units_equal and maximum<=1e-10*scale +report={'passed':passed,'kind':kind,'reference':owner,'sourceSha256':pin['sha256'],'comparedSamples':int(valid.sum()),'maskEqual':mask_equal,'unitsEqual':bool(units_equal),'maxAbsoluteError':maximum,'maxErrorOverPeak':maximum/scale,'toleranceOverPeak':1e-10,'versions':{'specutils':specutils.__version__,'photutils':photutils.__version__,'astropy':astropy.__version__},'limits':['Independent extraction arithmetic; FITS/WCS decoding still shares Astropy.','This compares values, units and masks, not calibration accuracy or feature significance.','Uncertainty propagation is separately checked against analytic fixtures; this comparison does not validate unknown covariance.']} +plt.rcParams.update({'font.size':10}) +if actual.ndim==2: + fig,axes=plt.subplots(1,3,figsize=(12,4),layout='constrained') + vmin=float(np.nanmin(reference));vmax=float(np.nanmax(reference)) + for ax,array,title in zip(axes,[actual,reference,delta],['Telescope API',owner,'API − reference']): + image=ax.imshow(array,origin='lower',interpolation='nearest',cmap='RdBu_r' if title=='API − reference' else 'viridis',**({'vmin':vmin,'vmax':vmax} if title!='API − reference' else {}));fig.colorbar(image,ax=ax,shrink=.65);ax.set_title(title,fontsize=9);ax.set_xlabel('x pixel');ax.set_ylabel('y pixel') +else: + fig,axes=plt.subplots(2,1,figsize=(9,6),layout='constrained',sharex=True) + axes[0].plot(centers,actual,label='Telescope API');axes[0].plot(centers,reference,'--',label=owner);axes[0].legend(fontsize=8);axes[0].set_ylabel(str(expected_unit)) + axes[1].plot(centers,delta);axes[1].set_ylabel('API − reference');axes[1].set_xlabel('Wavelength (µm)') +fig.suptitle(f"{kind}: independent arithmetic comparison\nmax |difference| = {maximum:.3g} {expected_unit}") +fig.savefig(out/'comparison.png',dpi=150);plt.close(fig) +(out/'comparison.json').write_text(json.dumps(report,indent=2)+'\n') +json.dump(report,sys.stdout) +if not passed: sys.exit(1) +`; +export async function compareOutput(directory:string,python:string,out:string):Promise>{ + await mkdir(out,{recursive:true}); + return requireRecord(JSON.parse(execFileSync(python,['-c',ORACLE_PYTHON],{input:JSON.stringify({directory:resolve(directory),out:resolve(out)}),encoding:'utf8',maxBuffer:4*1024*1024}))); +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){ + const [directory,python,out]=process.argv.slice(2);if(!directory||!python||!out)throw new Error('Usage: output-oracle.mts OUTPUT_DIRECTORY ORACLE_PYTHON REPORT_DIRECTORY'); + console.log(JSON.stringify(await compareOutput(directory,python,out))); +} diff --git a/tools/objects/telescopes/outputs.mts b/tools/objects/telescopes/outputs.mts new file mode 100644 index 0000000000..a0d9b5ef14 --- /dev/null +++ b/tools/objects/telescopes/outputs.mts @@ -0,0 +1,118 @@ +/** A small final stage: verified delivery -> explicit output selection -> pinned figure and numeric values. */ +import { readFile, mkdir, mkdtemp, rename, rm, rmdir, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { nativeFigureInput } from './native-figure.mts'; +import { dirname, resolve, relative, isAbsolute } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { requireRecord, requireArray, requireString, requireFiniteNumber } from '../../source-values.mts'; +import { pinFile, writeProductRecord, parseProductRecord } from '../product-record.mts'; +import { sha256 } from '../../../src/platform/sha256.mts'; +import { sciencePackage } from '../astronomy-packages/science.mts'; +import { plotProduct } from '../astronomy-packages/plots.mts'; +import { parseNativeMetadata, type NativeMetadata } from './native-metadata.mts'; +export interface OutputChoice { readonly kind:OutputRequest['kind']|'body-map'|'sphere'|'points';readonly available:boolean;readonly reason:string;readonly hdu?:number;readonly structure?:string;readonly shape?:readonly number[];readonly parameters?:readonly string[] } +export interface OutputRequest { + readonly kind:'image'|'spectrum'|'band-image'|'aperture-spectrum'|'feature-map'; + readonly hdu:number; readonly structure?:string; readonly plane?:number; readonly pixel?:readonly [number,number]; + readonly band?:readonly number[]; readonly aperture?:readonly number[]; + readonly background?:'none'|readonly number[]; readonly continuum?:readonly number[]; + readonly uncertainty?:'omit'|'independent'; +} +export function validateOutputRequest(request:OutputRequest):void { + const fields:Record={image:['plane'],spectrum:['pixel'],'band-image':['band','uncertainty'],'aperture-spectrum':['aperture','background','uncertainty'],'feature-map':['band','continuum','uncertainty']}; + if(!Object.hasOwn(fields,request.kind)||!Number.isSafeInteger(request.hdu)||request.hdu<0)throw new TypeError('Choose a supported output and a nonnegative HDU index'); + for(const key of Object.keys(request))if(!['kind','hdu','structure',...fields[request.kind]].includes(key))throw new TypeError(`${key} is not valid for ${request.kind}`); + if(request.structure!==undefined&&(typeof request.structure!=='string'||!request.structure.trim()))throw new TypeError('Native structure must be nonempty'); + const tuple=(value:unknown,count:number,integer=false)=>Array.isArray(value)&&value.length===count&&value.every(n=>typeof n==='number'&&Number.isFinite(n)&&(!integer||Number.isSafeInteger(n)&&n>=0)); + if(request.plane!==undefined&&(!Number.isSafeInteger(request.plane)||request.plane<0))throw new TypeError('Plane must be a nonnegative integer'); + if(request.kind==='spectrum'&&!tuple(request.pixel,2,true))throw new TypeError('A spectrum requires --pixel X,Y'); + if(['band-image','feature-map'].includes(request.kind)&&(!tuple(request.band,2)||request.band![0]<=0||request.band![0]>=request.band![1]))throw new TypeError('A band requires two positive increasing wavelengths'); + if(request.kind==='feature-map'&&!tuple(request.continuum,4))throw new TypeError('A feature map requires two bracketing continuum bands'); + if(request.kind==='aperture-spectrum'&&(!tuple(request.aperture,4,true)||request.background!=='none'&&!tuple(request.background,4,true)))throw new TypeError('Choose --aperture X0,Y0,X1,Y1 and --background X0,Y0,X1,Y1 or none'); + if(request.uncertainty!==undefined&&!['omit','independent'].includes(request.uncertainty))throw new TypeError('Uncertainty takes omit or independent'); +} +function beneath(root:string,path:string):string { + const file=resolve(root,path),rel=relative(root,file); + if(isAbsolute(path)||!rel||rel==='..'||rel.startsWith('../'))throw new Error('Delivery path escapes its directory'); + return file; +} +export async function delivery(resultPath:string){ + const path=resolve(resultPath),directory=dirname(path),bytes=await readFile(path),record=requireRecord(JSON.parse(bytes.toString('utf8'))); + if(record.schema!=='cssearth-telescope-delivery@1')throw new Error('Expected a telescope delivery result.json'); + const files=requireArray(record.files).map(raw=>{const f=requireRecord(raw);return {path:requireString(f.path),sha256:requireString(f.sha256),bytes:requireFiniteNumber(f.bytes)};}); + if(!files.length||new Set(files.map(f=>f.path)).size!==files.length)throw new Error('Delivery files must be unique and pinned'); + const realDirectory=await realpath(directory); + for(const expected of files){ + const file=beneath(directory,expected.path);beneath(realDirectory,relative(realDirectory,await realpath(file))); + const actual=await pinFile(file);if(actual.sha256!==expected.sha256||actual.bytes!==expected.bytes)throw new Error(`Delivery pin mismatch: ${expected.path}`); + } + const productPath=requireString(record.product),product=files.find(f=>f.path===productPath); + if(!product)throw new Error('The chosen product is absent from the delivery pins'); + for(const key of ['record','receipt'])if(!files.some(f=>f.path===requireString(record[key])))throw new Error('Delivery evidence is not pinned'); + const producing=parseProductRecord(JSON.parse(await readFile(beneath(directory,requireString(record.record)),'utf8'))); + // New deliveries bind output paths to their producer's root. Legacy deliveries retain strict unique-suffix matching. + const outputRoot=record.outputRoot===undefined?undefined:beneath(directory,requireString(record.outputRoot)); + const outputPins=producing.outputs.map(output=>{ + const exact=outputRoot===undefined?undefined:relative(directory,beneath(outputRoot,output.path)); + const matches=files.filter(f=>f.sha256===output.sha256&&f.bytes===output.bytes&&(exact===undefined?(f.path===output.path||f.path.endsWith('/'+output.path)):f.path===exact)); + if(matches.length!==1)throw new Error('Producing output is absent or ambiguous in delivery');return matches[0]; + }); + if(!outputPins.some(f=>f.path===product.path))throw new Error('Product is not bound by its producing record'); + const facts=requireRecord(record.facts);if(facts.verified!==true)throw new Error('The delivery is not verified'); + return {path,directory,record,files,product,producing,telescope:producing.telescope,file:beneath(directory,productPath),target:requireString(facts.target),pin:{sha256:sha256(bytes),bytes:bytes.length}}; +} +function choices(structures:readonly NativeMetadata[]):OutputChoice[]{ + const result:OutputChoice[]=[]; + for(const s of structures){ + if(s.fitsHdu===undefined||!s.shape)continue; + const dimensions=s.shape,spatial=dimensions.length>=2&&dimensions.slice(0,-3).every(n=>n===1),spectral=s.spectral?.axis===dimensions.length-3; + const supported=spatial&&(s.spectral?.axis===undefined?(dimensions.length===2||dimensions.at(-3)===1):spectral),usable=(s.quality?.usable??0)>0; + const common={hdu:s.fitsHdu,structure:s.structure,shape:dimensions}; + result.push({...common,kind:'image',available:supported&&usable,parameters:s.spectral?.axis===undefined?[]:['plane'],reason:!usable?'No usable samples.':!supported?'Select two spatial axes and a separable leading wavelength axis.':'Native image coordinates; masks retained. A cube requires an explicit zero-based plane.'}); + result.push({...common,kind:'spectrum',available:supported&&spectral&&usable,parameters:['pixel'],reason:spectral?'One explicitly selected pixel; supplied uncertainties retain their meaning. No aperture integration or covariance assumption.':'No qualified leading wavelength axis.'}); + const cube=supported&&spectral&&usable&&!!s.units; + for(const kind of ['band-image','aperture-spectrum','feature-map'] as const){ + const edges=kind==='aperture-spectrum'||!!s.spectral?.binEdgesMicrometres; + result.push({...common,kind,available:cube&&edges,parameters:kind==='aperture-spectrum'?['aperture','background','uncertainty']:kind==='feature-map'?['band','continuum','uncertainty']:['band','uncertainty'],reason:!cube?'Requires a usable cube with qualified units and leading wavelength axis.':!edges?'Qualified bin edges are required; tabulated centers alone cannot establish band integration.':kind==='aperture-spectrum'?'Fixed-region mean spectrum with explicit background choice. Independent-sample uncertainty is opt-in.':kind==='band-image'?'Wavelength-bin-weighted mean; complete selected coverage is required per pixel.':'Continuum-subtracted wavelength integral with two explicit bracketing bands; positive emission, negative absorption.'}); + } + } + return [...result, + {kind:'body-map',available:false,reason:'Export a 2D measurement, then telescope project its output.product.json with explicit navigation. Publication remains a separate scientific check.'}, + {kind:'sphere',available:false,reason:'Use telescope export MAP/map.fits.product.json --output sphere after projection; native pixels are insufficient.'}, + {kind:'points',available:false,reason:'Export an existing physical object.json with --output points or volume. A spectral cube requires a scientific reconstruction first; wavelength or radial velocity is not distance.'}]; +} +export async function listOutputs(resultPath:string,structure?:string){ + const d=await delivery(resultPath); + const scratch=await mkdtemp(resolve(tmpdir(),'telescope-native-')); + try{ + const input=await nativeFigureInput(d,scratch,structure); + const metadata=await sciencePackage({operation:'fits',path:input.file}); + const structures=requireArray(metadata.structures).map(s=>parseNativeMetadata(s)); + return {target:d.target,source:d.product,outputs:choices(structures),...(input.native?{native:input.native}: {})}; + }finally{await rm(scratch,{recursive:true,force:true});} +} +export async function exportOutput(resultPath:string,request:OutputRequest,outputDirectory:string){ + validateOutputRequest(request); + const d=await delivery(resultPath); + const destination=resolve(outputDirectory),staging=`${destination}.${randomUUID()}.partial`; + // A fresh output directory preserves earlier selections and their evidence. + await mkdir(dirname(destination),{recursive:true});await mkdir(destination);await mkdir(staging); + try{ + const input=await nativeFigureInput(d,resolve(staging,'native'),request.structure); + if(input.native&&request.hdu!==0)throw new Error('Decoded native arrays use --hdu 0'); + const answer=await sciencePackage({...request,operation:'extract',path:input.file,arrayDirectory:resolve(staging,'arrays'),x:request.pixel?.[0],y:request.pixel?.[1]}); + const rows=requireArray(answer.structures).map(v=>requireRecord(v)),found=rows.filter(s=>s.fitsHdu===request.hdu); + if(found.length!==1||found[0].extraction===undefined)throw new Error('Selected HDU is not an unambiguous science array'); + const data=requireRecord(found[0].extraction); + const plotted=await plotProduct(staging,d.target,data,input.file,{...request}); + const fresh=await delivery(resultPath);if(fresh.pin.sha256!==d.pin.sha256)throw new Error('Delivery changed while producing output'); + const softwareFiles=['outputs.mts','native-figure.mts','native-metadata.mts','../astronomy-packages/pds-client.mts','../terrestrial-layers/isis3-raster.mts','../astronomy-packages/science.mts','../astronomy-packages/plots.mts','../astronomy-packages/cube-outputs.mts','../astronomy-packages/requirements.lock']; + const implementation=sha256(Buffer.concat(await Promise.all(softwareFiles.map(name=>readFile(new URL(name,import.meta.url)))))); + const run={telescope:d.telescope,stage:'telescope-output',inputs:[{role:'delivery',identity:d.path,...d.pin},...d.files.map(f=>({role:'qualified input',identity:resolve(d.directory,f.path),sha256:f.sha256,bytes:f.bytes}))],parameters:{selection:request,...(input.native?{native:input.native}:{}),definition:data.definition??(request.kind==='image'?'Native sampled image plane; not a registered surface map.':'Single-pixel spectrum; no spatial integration.'),measurement:{unit:data.unit,arithmetic:data.arithmetic??'native samples',uncertaintyPolicy:data.uncertaintyPolicy??'recorded',maskPolicy:data.maskPolicy??'native sample mask'},sourceRequest:d.record.request,sourceSatisfaction:d.record.satisfaction,metadata:parseNativeMetadata(found[0]),software:plotted},software:[{name:'cssEarth telescope outputs',version:implementation},{name:'Astropy',version:'8.0.1'},{name:'Matplotlib',version:'3.11.2'},...Object.entries(input.native?.packages??{}).map(([name,version])=>({name,version}))]}; + const names=requireArray(plotted.files).map(v=>requireString(v)); + await writeProductRecord(resolve(staging,'output.product.json'),run,names.map(path=>({path,file:beneath(staging,path)}))); + await rm(resolve(staging,'arrays'),{recursive:true,force:true});await rm(resolve(staging,'native'),{recursive:true,force:true}); + await rmdir(destination);await rename(staging,destination); + return {directory:destination,figure:resolve(destination,'figure.png'),values:resolve(destination,'values.csv'),data:resolve(destination,names.includes('image.fits')?'image.fits':'spectrum.ecsv'),receipt:resolve(destination,'output.product.json'),sourceSatisfaction:d.record.satisfaction}; + }catch(error){await rm(staging,{recursive:true,force:true});await rmdir(destination).catch(()=>{});throw error;} +} diff --git a/tools/objects/telescopes/product-science.mts b/tools/objects/telescopes/product-science.mts new file mode 100644 index 0000000000..2f20108c13 --- /dev/null +++ b/tools/objects/telescopes/product-science.mts @@ -0,0 +1,66 @@ +/** One readback owner for native source files and reducer products. */ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { sciencePackage } from '../astronomy-packages/science.mts'; +import { isisMetadata, pdsMetadata, parseNativeMetadata, type NativeMetadata } from './native-metadata.mts'; +import { calibrationDependencies, verifyCalibrationDependencies } from './calibration-dependencies.mts'; +import { parseProductFacts } from './qualified-observations.mts'; +import { pinFile } from '../product-record.mts'; +import { requireArray, requireRecord, requireString } from '../../source-values.mts'; +import { decodeIsis3Core } from '../terrestrial-layers/isis3-raster.mts'; +import type { ProductFacts } from './request-satisfaction.mts'; +export interface ScienceProduct { readonly file:string; readonly format:'fits'|'isis3'|'pds'; readonly target:string; readonly label?:string; readonly decoded?:unknown; readonly region?: import('./vo/contracts.mts').IcrsCircle } +function intersection(left:readonly (readonly [number,number])[],right:readonly (readonly [number,number])[]): [number,number][] { + return left.flatMap(([a,b])=>right.flatMap(([c,d])=>Math.max(a,c)<=Math.min(b,d)?[[Math.max(a,c),Math.min(b,d)] as [number,number]]:[])); +} +export async function readProductScience(root:string, product:ScienceProduct, options:{resolveCalibrations?:boolean}={}):Promise> { + const before=await pinFile(resolve(root,product.file));let facts:Partial, references:{field:string;value:string}[]=[],header:Record={}; + if(product.format==='fits'){ + const answer=await sciencePackage({operation:'fits',path:resolve(root,product.file), ...(product.region ? { region: product.region } : {})}); + const rows=requireArray(answer.structures).map(v=>requireRecord(v)); + const structures=rows.map(r=>parseNativeMetadata(r)), first=structures[0]; + let intervals=rows[0]?.wavelengthIntervalsMicrometres; + for(const row of rows.slice(1))intervals=intervals===undefined||row.wavelengthIntervalsMicrometres===undefined?undefined:intersection(parseProductFacts({target:product.target,verified:true,wavelengthIntervalsMicrometres:intervals}).wavelengthIntervalsMicrometres!,parseProductFacts({target:product.target,verified:true,wavelengthIntervalsMicrometres:row.wavelengthIntervalsMicrometres}).wavelengthIntervalsMicrometres!); + const beams=rows.map(r=>r.angularResolutionArcsec), beam=beams.length&&beams.every(n=>typeof n==='number'&&Number.isFinite(n)&&n>0)?Math.max(...beams as number[]):undefined; + const multi:NativeMetadata={structure:'multiple science arrays',structures,calibration:structures.flatMap(s=>s.calibration),limitations:['Each science array is qualified separately; top-level coverage is their intersection. No measurements are averaged.']}; + facts=parseProductFacts({target:product.target,verified:true,nativeMetadata:structures.length===1?first:multi, + ...(rows.length === 1 && rows[0]!.regionCoverage ? { regionCoverage: rows[0]!.regionCoverage } : {}), + kind:structures.every(s=>s.shape && (s.shape.length>3?2+s.shape.slice(0,-2).filter(n=>n!==1).length:s.shape.length)===3)?'cube':structures.every(s=>s.shape && (s.shape.length>3?2+s.shape.slice(0,-2).filter(n=>n!==1).length:s.shape.length)===2)?'image':undefined, + ...(intervals===undefined?{}:{wavelengthIntervalsMicrometres:intervals}), + ...(beam===undefined?{}:{angularResolutionArcsec:beam,resolutionEvidence:[{kind:'calibrated',receipt:{file:product.file,sha256:before.sha256}}]})}); + references=requireArray(answer.references).map(r=>{const row=requireRecord(r);return {field:requireString(row.field),value:requireString(row.value)};});header=requireRecord(answer.primary); + }else{ + if(product.format==='isis3'){ + const bytes=await readFile(resolve(root,product.file)),label=product.label?await readFile(resolve(root,product.label)):bytes,core=decodeIsis3Core(bytes,label),valid=new Array(core.bands).fill(false),minimum=Buffer.from('faff7fff','hex').readFloatLE(); + let finite=0;for(let i=0;i=minimum){finite++;valid[Math.floor(i/(core.width*core.height))]=true;} + facts=isisMetadata(label,core.bands,valid); + facts={...facts,nativeMetadata:{...facts.nativeMetadata!,quality:{policy:'finite non-special ISIS core; uncertainty and quality arrays absent',samples:core.data.length,finite,usable:finite,flagged:core.data.length-finite,invalidUncertainty:0,mask:null},uncertainty:{status:'unknown',kind:null,structure:null}}}; + references=facts.nativeMetadata!.calibration.filter(r=>r.field.endsWith('File')); + header=core.identity; + }else facts=pdsMetadata(product.decoded); + const meta=facts.nativeMetadata!; + if(meta.units){const result=await sciencePackage({operation:'units',units:[meta.units.value]}),value=requireArray(result.units)[0]; + if(value===null){facts={...facts,nativeMetadata:{...meta,units:undefined,limitations:[...meta.limitations,`Astropy cannot validate unit ${meta.units.value}.`]}};}} + } + if(options.resolveCalibrations!==false && references.length){ + const dependencies=await calibrationDependencies(root,references,header); + facts={...facts,calibrationDependencies:dependencies}; + // A matched VIMS reference must reproduce the product's time-dependent coordinate vector. + if(product.format==='isis3'&&facts.nativeMetadata?.spectral){ + const band=dependencies.find(r=>r.field==='RadiometricCalibration:BandwidthFile'&&r.file); + if(band){try{ + const calBytes=await readFile(resolve(root,band.file!)),cal=decodeIsis3Core(calBytes,calBytes,false),centers=facts.nativeMetadata.spectral.centersMicrometres; + const bytes=await readFile(resolve(root,product.label??product.file)); + const original=/OriginalBand\s*=\s*\(([^)]+)\)/u.exec(bytes.subarray(0,128*1024).toString('latin1'))?.[1].split(',').map(Number); + if(original?.length===centers.length){const plane=cal.width*cal.height; + const matches=original.every((b,i)=>{if(!Number.isInteger(b)||b<1||b>cal.bands)return false;const values=cal.data.subarray((b-1)*plane,b*plane);const mean=values.reduce((a,b)=>a+b,0)/plane;return Math.abs(mean-centers[i])<=Math.max(1e-5,Math.abs(centers[i])*1e-5);}); + if(!matches)throw new Error('Recorded calibration coordinates contradict the product'); + facts={...facts,calibrationDependencies:dependencies.map(d=>d===band?{...d,applicability:'matched',reason:'Exact recorded band indices reproduce the calibrated wavelength centers within label rounding.'}:d)}; + } + }catch(error){if(String(error).includes('contradict'))throw error;facts={...facts,calibrationDependencies:dependencies.map(d=>d===band?{...d,reason:`Pinned reference could not establish coordinate applicability: ${String(error)}`}:d)};}} + } + } + if(!await verifyCalibrationDependencies(root,facts.calibrationDependencies??[]))throw new Error('Calibration bytes changed during scientific readback'); + const after=await pinFile(resolve(root,product.file));if(after.sha256!==before.sha256||after.bytes!==before.bytes)throw new Error('Product changed during scientific readback'); + return facts; +} diff --git a/tools/objects/telescopes/product-science.test.mts b/tools/objects/telescopes/product-science.test.mts new file mode 100644 index 0000000000..43d6e0653f --- /dev/null +++ b/tools/objects/telescopes/product-science.test.mts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { test, before, after } from 'node:test'; +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { astroqueryToolchain } from '../astronomy-packages/toolchain.mts'; +import { readProductScience } from './product-science.mts'; +import { calibrationDependencies, verifyCalibrationDependencies, calibrationOrigin } from './calibration-dependencies.mts'; +import { qualifySourceProduct } from './qualify-source.mts'; +import { pinFile, writeProductRecord, productRecordPath } from '../product-record.mts'; +import { recordQualification, QUALIFICATION_SCHEMA, type QualificationResult } from './qualify.mts'; +import { loadQualifiedObservations } from './qualified-observations.mts'; +import type { SourceProduct } from './source-products.mts'; +let root:string; +before(async()=>{ + root=await mkdtemp(resolve(tmpdir(),'science-readback-')); + const tc=await astroqueryToolchain(); + execFileSync(tc.python,['-c',String.raw` +import sys +import numpy as np +from astropy.io import fits +from pathlib import Path +root=Path(sys.argv[1]) +def image(name='SCI',version=1): + h=fits.ImageHDU(np.ones((3,2,2),dtype='float32'),name=name); h.header['EXTVER']=version + h.header['BUNIT']='erg / (s cm2 Angstrom)' if name=='SCI' else 'erg / (s cm2 Angstrom)' + if name=='SCI': + for k,v in {'CTYPE1':'RA---TAN','CTYPE2':'DEC--TAN','CTYPE3':'WAVE','CUNIT1':'deg','CUNIT2':'deg','CUNIT3':'nm','CRPIX1':1,'CRPIX2':1,'CRPIX3':1,'CRVAL1':0,'CRVAL2':0,'CRVAL3':1000,'CDELT1':.1,'CDELT2':.1,'CDELT3':100}.items():h.header[k]=v + return h +def save(name,rows):fits.HDUList([fits.PrimaryHDU()]+rows).writeto(root/name) +sci=image();err=image('ERR');dq=image('DQ');dq.data=np.zeros((3,2,2),dtype='uint16');del dq.header['BUNIT'];dq.data[1]=1 +save('masked.fits',[sci,err,dq]);save('output.fits',[sci,err,dq]) +primary=fits.PrimaryHDU(np.ones((1,1,2,2),dtype='float32'));primary.header['BUNIT']='Jy/beam';primary.header['BMAJ']=.001;primary.header['BMIN']=.0005 +primary.writeto(root/'singleton.fits') +raw=(root/'singleton.fits').read_bytes();i=raw.index(b'EXTEND =');raw=raw[:i]+b"MEMBEROUS= 'uid://A001/X35f5/Xa'".ljust(80)+raw[i+80:];(root/'warning.fits').write_bytes(raw) +a=sci.copy();a.name='A';b=sci.copy();b.name='B';save('ambiguous.fits',[a,b,err]) +invalid=err.copy();invalid.header['BUNIT']='K';save('bad-unit.fits',[sci,invalid]) +invalid=err.copy();invalid.data=np.ones((2,2),dtype='float32');save('bad-shape.fits',[sci,invalid]) +invalid=err.copy();invalid.data[0,0,0]=-1;save('negative.fits',[sci,invalid]) +invalid=err.copy();del invalid.header['BUNIT'];save('missing-unit.fits',[sci,invalid]) +var=err.copy();var.name='VAR';var.header['BUNIT']='erg2 / (s2 cm4 Angstrom2)';save('variance.fits',[sci,var]) +ivar=err.copy();ivar.name='IVAR';ivar.header['BUNIT']='s2 cm4 Angstrom2 / erg2';ivar.data[1]=0;save('ivar.fits',[sci,ivar]) +other=image(version=2);other.header['CRVAL3']=1100;save('multi.fits',[sci,err,other,image('ERR',2)]) +log=sci.copy();log.header['CTYPE3']='FREQ-LOG';log.header['CUNIT3']='Hz';log.header['CRVAL3']=3e14;log.header['CDELT3']=1e12;save('log.fits',[log]) +tab=sci.copy();tab.header['CTYPE3']='WAVE-TAB';tab.header['CUNIT3']='um';tab.header['CRVAL3']=1.;tab.header['CDELT3']=1.;tab.header['PS3_0']='WCS-TAB';tab.header['PS3_1']='WAVE';tab.header['PV3_1']=1;tab.header['PV3_3']=1 +col=fits.Column(name='WAVE',format='3D',dim='(1,3)',array=np.array([[[1.],[1.3],[1.9]]])) +table=fits.BinTableHDU.from_columns([col],name='WCS-TAB');table.header['EXTVER']=1;save('tab.fits',[tab,table]) +beam=sci.copy();beam.header['BUNIT']='mJy/beam' +columns=[fits.Column(name='BMAJ',format='E',unit='arcsec',array=[1.,2.,3.]),fits.Column(name='BMIN',format='E',unit='arcsec',array=[.5,1.,1.5]),fits.Column(name='CHAN',format='J',array=[0,1,2]),fits.Column(name='POL',format='J',array=[0,0,0])] +beams=fits.BinTableHDU.from_columns(columns,name='BEAMS');save('beams.fits',[beam,beams]) +beams.data['CHAN'][2]=1;save('bad-beams.fits',[beam,beams]) +` ,root],{env:{...process.env,...tc.env}}); +}); +after(async()=>{await rm(root,{recursive:true,force:true});}); +const read=(name:string)=>readProductScience(root,{file:name,format:'fits',target:'test'},{resolveCalibrations:false}); +test('Astropy units and associated ERR/DQ control sampled coverage',async()=>{ + const f=await read('masked.fits');assert.equal(f.nativeMetadata?.uncertainty?.status,'validated');assert.equal(f.nativeMetadata?.quality?.usable,8);assert.equal(f.nativeMetadata?.quality?.flagged,4); + assert.deepEqual(f.nativeMetadata?.spectral?.usableBands,[true,false,true]);assert.equal(f.wavelengthIntervalsMicrometres?.length,2);assert.equal(f.nativeMetadata?.units?.value,'erg / (s cm2 Angstrom)'); +}); +test('shape, unit and unmasked negative uncertainty contradictions are refused',async()=>{ + await assert.rejects(read('ambiguous.fits'),/Ambiguous science association/); + await assert.rejects(read('bad-unit.fits'),/units disagree/);await assert.rejects(read('bad-shape.fits'),/shape mismatch/);await assert.rejects(read('negative.fits'),/Negative unmasked/); + const unknown=await read('missing-unit.fits');assert.equal(unknown.nativeMetadata?.uncertainty?.status,'unknown');assert.equal(unknown.nativeMetadata?.quality?.usable,0);assert.deepEqual(unknown.wavelengthIntervalsMicrometres,[]); +}); +test('variance and inverse variance carry correct dimensional checks',async()=>{ + assert.equal((await read('variance.fits')).nativeMetadata?.uncertainty?.kind,'variance'); + const f=await read('ivar.fits');assert.equal(f.nativeMetadata?.uncertainty?.kind,'inverse-variance');assert.deepEqual(f.nativeMetadata?.spectral?.usableBands,[true,false,true]); +}); +test('multiple science EXTVERs preserve separate facts and use intersection, not an invented mosaic',async()=>{ + const f=await read('multi.fits');assert.equal(f.nativeMetadata?.structures?.length,2);assert.ok(f.wavelengthIntervalsMicrometres![0][0]>1.04);assert.ok(f.wavelengthIntervalsMicrometres![0][1]<1.26); +}); +test('nonlinear and tabulated spectral coordinates are owned by WCSLIB',async()=>{ + const log=await read('log.fits');assert.equal(log.nativeMetadata?.spectral?.centersMicrometres.length,3);assert.ok(log.nativeMetadata!.spectral!.centersMicrometres[0]>log.nativeMetadata!.spectral!.centersMicrometres[1]); + const tab=await read('tab.fits');assert.deepEqual(tab.nativeMetadata?.spectral?.centersMicrometres,[1,1.3,1.9]);assert.equal(tab.wavelengthIntervalsMicrometres,undefined); +}); +test('per-plane beams require complete channel identity and use the worst usable major axis',async()=>{ + assert.equal((await read('beams.fits')).angularResolutionArcsec,3);await assert.rejects(read('bad-beams.fits'),/every channel/); +}); +test('source and reducer paths publish identical scientific metadata and reducer mutations are refused',async()=>{ + const name='masked.fits',pin=await pinFile(resolve(root,name)); + const source:SourceProduct={id:'test-source',target:'test',telescope:'Fixture',mode:'cube',kind:'cube',archiveProductId:'test',decoder:'fits-image',identity:{SIMPLE:true},units:'not used',meaning:'test',citation:'https://example.org',limitations:[],files:[{role:'science',path:name,origin:'https://example.org/masked.fits',...pin}]}; + await qualifySourceProduct(root,source);const report=JSON.parse(await readFile(resolve(root,'output/telescopes/test/test-source/decoded.json'),'utf8')); + const file=resolve(root,name),receipt=productRecordPath(file);await writeProductRecord(receipt,{telescope:'Fixture',stage:'fixture',inputs:[],parameters:{},software:[]},[{path:name,file}]); + const result:QualificationResult={schema:QUALIFICATION_SCHEMA,target:'test',telescope:'Fixture',mode:'cube',observation:'test',program:'test',product:file,receipt,configuration:{kind:'spitzer-irac-channel' as const,channel:1}}; + await recordQualification(root,result);const loaded=(await loadQualifiedObservations(root,'test'))[0];assert.deepEqual(loaded.facts.nativeMetadata,report.facts.nativeMetadata);assert.deepEqual(loaded.facts.wavelengthIntervalsMicrometres,report.facts.wavelengthIntervalsMicrometres); + await writeFile(file,'changed');await assert.rejects(recordQualification(root,result),/pins are invalid/); +}); +test('exact calibration references are pinned, bounded, and cannot be silently repinned after mutation',async()=>{ + assert.equal(calibrationOrigin('$cassini/../other'),undefined);assert.equal(calibrationOrigin('crds://jwst_bad/../../secret'),undefined); + const refs=[{field:'test',value:'$cassini/calibration/test.cub'}];let count=0; + const fetcher:typeof fetch=async()=>{count++;return new Response('calibration bytes');}; + const first=await calibrationDependencies(root,refs,{}, {fetcher});assert.equal(first[0].status,'pinned');assert.ok(await verifyCalibrationDependencies(root,first)); + await calibrationDependencies(root,refs,{}, {fetcher});assert.equal(count,1); + await writeFile(resolve(root,first[0].file!),'changed');assert.equal(await verifyCalibrationDependencies(root,first),false);await assert.rejects(calibrationDependencies(root,refs,{}, {fetcher}),/integrity mismatch/); + const large=await calibrationDependencies(root,[{field:'test',value:'$cassini/calibration/large.cub'}],{}, {maxFileBytes:2,fetcher:async()=>new Response('too big',{headers:{'Content-Length':'7'}})});assert.equal(large[0].status,'unresolved'); +}); + +test('singleton ancillary FITS axes remain an image and unrelated malformed keyword warnings are retained',async()=>{ + const f=await read('singleton.fits');assert.equal(f.kind,'image');assert.equal(f.nativeMetadata?.quality?.usable,4); + const warning=await read('warning.fits');assert.equal(warning.kind,'image');assert.ok(warning.nativeMetadata?.limitations.some(l=>l.includes('MEMBEROUS'))); +}); + +test('delivery outputs preserve masked spectrum gaps, label plots, and refuse changed evidence',async()=>{ + const {listOutputs,exportOutput}=await import('./outputs.mts'); + const file=resolve(root,'output.fits'),record=resolve(root,'output-input.product.json'),result=resolve(root,'result.json'); + await writeProductRecord(record,{telescope:'Fixture',stage:'fixture',inputs:[],parameters:{},software:[]},[{path:'output.fits',file}]); + await writeFile(result,JSON.stringify({schema:'cssearth-telescope-delivery@1',product:'output.fits',record:'output-input.product.json',receipt:'output-input.product.json',facts:{target:'test',verified:true},request:{target:'test'},satisfaction:{status:'unresolved'},files:[{path:'output.fits',...await pinFile(file)},{path:'output-input.product.json',...await pinFile(record)}]})); + const options=await listOutputs(result);assert.ok(options.outputs.some(o=>o.kind==='spectrum'&&o.available));assert.ok(options.outputs.some(o=>o.kind==='sphere'&&!o.available)); + await assert.rejects(exportOutput(result,{kind:'image',hdu:1},resolve(root,'missing-plane')),/explicit/); + const image=await exportOutput(result,{kind:'image',hdu:1,plane:0},resolve(root,'image-output')); + assert.equal((await readFile(image.figure)).subarray(1,4).toString(),'PNG');assert.equal(JSON.parse(await readFile(image.receipt,'utf8')).telescope,'Fixture'); + const spectrum=await exportOutput(result,{kind:'spectrum',hdu:1,pixel:[0,0]},resolve(root,'spectrum-output')); + const rows=(await readFile(spectrum.values,'utf8')).trim().split(/\r?\n/u);assert.equal(rows.length,4);assert.match(rows[2],/,,/); + const tc=await astroqueryToolchain();execFileSync(tc.python,['-c',String.raw` +import sys +from astropy.table import QTable +from astropy import units as u +t=QTable.read(sys.argv[1]);assert list(t['value'].mask)==[False,True,False] +assert list(t['standard_deviation'].mask)==[False,True,False] +assert t['wavelength'].unit==u.um and t['value'].unit==u.erg/(u.s*u.cm**2*u.AA) +`,spectrum.data],{env:{...process.env,...tc.env}}); + await writeFile(file,'changed');await assert.rejects(listOutputs(result),/pin mismatch/); +}); + +test('output CLI selections remain explicit and sampling is reproducible',async()=>{ + const {parseCli}=await import('./cli.mts');const {shuffled}=await import('./survey-delivery.mts'); + const args=['export','result.json','--output','spectrum','--hdu','1','--pixel','25,27','--out','chart']; + const parsed=parseCli(args);assert.equal(parsed.command,'export');if(parsed.command==='export')assert.deepEqual(parsed.selection,{kind:'spectrum',hdu:1,pixel:[25,27]}); + assert.throws(()=>parseCli(['export','result.json','--output','image','--hdu','-1','--out','chart']),/nonnegative/); + assert.deepEqual(shuffled([1,2,3,4,5,6],'seed'),shuffled([1,2,3,4,5,6],'seed'));assert.equal(new Set(shuffled([1,2,3,4,5,6],'seed')).size,6); +}); diff --git a/tools/objects/telescopes/projection.mts b/tools/objects/telescopes/projection.mts new file mode 100644 index 0000000000..b87d3befd1 --- /dev/null +++ b/tools/objects/telescopes/projection.mts @@ -0,0 +1,85 @@ +/** An explicit navigation step between a pinned 2D measurement and an existing body-map contract. */ +import { readFile,writeFile,mkdir,rm,rmdir,rename,realpath } from 'node:fs/promises'; +import { resolve,dirname,relative,isAbsolute } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { requireArray,requireRecord,requireString,requireFiniteNumber } from '../../source-values.mts'; +import { pinFile,parseProductRecord,sameRun,type ProductInput } from '../product-record.mts'; +import { parseBodyMapProduct } from '../body-map-product.mts'; +import { assertBodyMapPlanes,bodyMapProductRecord,formatProductRecord } from '../body-map-publication.mts'; +import { sha256 } from '../../../src/platform/sha256.mts'; +import { astroqueryToolchain } from '../astronomy-packages/toolchain.mts'; +import { projectWithPlanetMapper } from '../astronomy-packages/projection.mts'; +import { delivery } from './outputs.mts'; + +export function localOutput(root:string,name:string):string { + const path=resolve(root,name),rel=relative(root,path); + if(isAbsolute(name)||!rel||rel==='..'||rel.startsWith('../'))throw new Error('Output escapes its product directory'); + return path; +} +export async function verifiedProduct(path:string){ + const file=resolve(path),bytes=await readFile(file),record=parseProductRecord(JSON.parse(bytes.toString())),root=dirname(file); + const realRoot=await realpath(root); + for(const output of record.outputs)localOutput(realRoot,relative(realRoot,await realpath(localOutput(root,output.path)))); + if(!await sameRun(record,record,name=>localOutput(root,name)))throw new Error('Product output pins changed or are missing'); + return {file,root,record,pin:{sha256:sha256(bytes),bytes:bytes.length}}; +} +export function parseGeometry(raw:unknown,root:string){ + const g=requireRecord(raw);if(g.schema!=='cssearth-navigation-input@1')throw new TypeError('Expected cssearth-navigation-input@1'); + for(const key of Object.keys(g))if(!['schema','observer','kernels','registration','width','height','maximumEmissionDegrees'].includes(key))throw new TypeError(`Unknown navigation option ${key}`); + const width=requireFiniteNumber(g.width),height=requireFiniteNumber(g.height),maximumEmissionDegrees=requireFiniteNumber(g.maximumEmissionDegrees); + if(!Number.isInteger(width)||!Number.isInteger(height)||width<4||height<2||width*height>260000||maximumEmissionDegrees<=0||maximumEmissionDegrees>=90)throw new RangeError('Use a bounded map grid and emission limit strictly between 0 and 90 degrees'); + const kernels=requireArray(g.kernels).map(value=>{const k=requireRecord(value),file=resolve(root,requireString(k.file)),bytes=requireFiniteNumber(k.bytes),digest=requireString(k.sha256);if(!/^[a-f0-9]{64}$/u.test(digest)||!Number.isSafeInteger(bytes)||bytes<1)throw new TypeError('Kernels require byte counts and SHA256 pins');return {file,bytes,sha256:digest,source:requireString(k.source),role:requireString(k.role)};}); + if(!kernels.length||kernels.filter(k=>k.role==='rotation').length!==1||new Set(kernels.map(k=>k.file)).size!==kernels.length)throw new TypeError('Pin an ordered unique kernel set with exactly one rotation kernel'); + const registration=requireRecord(g.registration),method=requireString(registration.method); + for(const key of Object.keys(registration))if(!['method','explanation','parameters','evidence'].includes(key))throw new TypeError(`Unknown registration option ${key}`); + if(method==='wcs'&®istration.parameters!==undefined)throw new TypeError('WCS registration does not accept disc parameters'); + if(method!=='wcs'&&method!=='disc')throw new TypeError('Registration must explicitly choose wcs or disc'); + const parameters=method==='disc'?requireArray(registration.parameters).map(v=>requireFiniteNumber(v)):undefined; + if(parameters&&(parameters.length!==4||parameters[2]<=0))throw new TypeError('Disc registration takes x, y, radius and rotation in PlanetMapper coordinates'); + const explanation=requireString(registration.explanation); + const evidence=registration.evidence===undefined?undefined:requireRecord(registration.evidence); + const evidencePin=evidence?{file:resolve(root,requireString(evidence.file)),sha256:requireString(evidence.sha256),bytes:requireFiniteNumber(evidence.bytes)}:undefined; + if(method==='disc'&&(!evidencePin||!/^[a-f0-9]{64}$/u.test(evidencePin.sha256)||!Number.isSafeInteger(evidencePin.bytes)||evidencePin.bytes<1))throw new TypeError('Disc registration requires a pinned evidence file'); + return {observer:requireString(g.observer),kernels,registration:{method,explanation,...(parameters?{parameters}: {}),...(evidencePin?{evidence:evidencePin}:{})},width,height,maximumEmissionDegrees}; +} +async function checkPins(inputs:readonly ProductInput[]){for(const input of inputs){const actual=await pinFile(input.identity);if(actual.sha256!==input.sha256||actual.bytes!==input.bytes)throw new Error(`Input pin mismatch: ${input.identity}`);}} + +export async function projectOutput(recordPath:string,geometryPath:string,outputDirectory:string){ + const source=await verifiedProduct(recordPath); + if(source.record.stage!=='telescope-output'||!source.record.outputs.some(o=>o.path==='image.fits'))throw new TypeError('project requires a telescope export image product record'); + const deliveryPin=source.record.inputs.find(i=>i.role==='delivery');if(!deliveryPin)throw new Error('Measurement has no source delivery'); + const d=await delivery(deliveryPin.identity);if(d.pin.sha256!==deliveryPin.sha256)throw new Error('Measurement source delivery changed'); + const geometryFile=resolve(geometryPath),geometryBytes=await readFile(geometryFile),geometry=parseGeometry(JSON.parse(geometryBytes.toString()),dirname(geometryFile)); + const inputs:ProductInput[]=[{role:'measurement record',identity:source.file,...source.pin},...source.record.outputs.map(o=>({role:'measurement output',identity:localOutput(source.root,o.path),bytes:o.bytes,sha256:o.sha256})), + ...source.record.inputs,{role:'navigation choices',identity:geometryFile,bytes:geometryBytes.length,sha256:sha256(geometryBytes)},...geometry.kernels.map(k=>({role:`SPICE ${k.role}: ${k.source}`,identity:k.file,bytes:k.bytes,sha256:k.sha256}))]; + if(geometry.registration.evidence)inputs.push({role:'registration evidence',identity:geometry.registration.evidence.file,sha256:geometry.registration.evidence.sha256,bytes:geometry.registration.evidence.bytes}); + await checkPins(inputs); + const destination=resolve(outputDirectory),staging=`${destination}.${randomUUID()}.partial`; + await mkdir(dirname(destination),{recursive:true});await mkdir(destination);await mkdir(staging); + try{ + const selection=requireRecord(source.record.parameters.selection); + const nav=await projectWithPlanetMapper({directory:staging,geometry,image:localOutput(source.root,'image.fits'),source:d.file,target:d.target,quantity:requireString(source.record.parameters.definition)}); + const rotation=geometry.kernels.find(k=>k.role==='rotation')!,plane=await readFile(resolve(staging,'map.fits')); + const metadata=requireRecord(source.record.parameters.metadata),facts=requireRecord(d.record.facts); + // Mapping does not turn sampling or a nominal capability into measured resolution. + // The map retains explicitly typed sampling; publication cannot treat it as PSF evidence. + const spectral=requireRecord(metadata.spectral??{}),band=selection.band; + const sampling=requireFiniteNumber(nav.samplingArcsec); + const resolution={majorArcsec:sampling,minorArcsec:sampling,basis:'Native angular sampling from the registered disc scale; achieved PSF/beam resolution remains unknown.',evidence:{kind:'sampling'}}; + const product=parseBodyMapProduct({schema:'cssearth-body-map@1',definition:{quantity:requireString(source.record.parameters.definition),units:requireString(nav.units),timeDependence:'instantaneous-state', + ...(Array.isArray(band)?{wavelengthIntervalsMicrometres:[band]}:selection.kind==='image'&&Array.isArray(spectral.centersMicrometres)&&typeof selection.plane==='number'?{wavelengthIntervalsMicrometres:[[spectral.centersMicrometres[selection.plane],spectral.centersMicrometres[selection.plane]]]}:{}), + method:{measurement:source.record.parameters.measurement,selection:Object.fromEntries(Object.entries(selection).filter(([key])=>key!=='hdu')),projection:{owner:'PlanetMapper',interpolation:'nearest',latitude:'planetocentric',shape:nav.shape,uncertainty:nav.uncertainty}},source:source.file}, + frame:{body:d.target,radiusKm:nav.radiusKm,rotation:{model:rotation.file,sha256:rotation.sha256,bodyCode:nav.bodyCode}},grid:{width:geometry.width,height:geometry.height,longitude:'east-positive-from-0',rows:'north-to-south'}, + planes:{file:'map.fits',sha256:sha256(plane),value:'VALUE',uncertainty:'SIGMA'},mask:{maximumEmissionDegrees:geometry.maximumEmissionDegrees,missing:'NaN'}, + observations:[{id:nav.observation,telescope:d.telescope,instrument:nav.instrument,midTimeJd:nav.midTimeJd,startTimeJd:nav.startTimeJd,endTimeJd:nav.endTimeJd,startIso:nav.startIso,endIso:nav.endIso,exposureSeconds:nav.exposureSeconds,rangeKm:nav.rangeKm,subObserver:nav.subObserver,angularResolution:resolution}]}); + assertBodyMapPlanes(plane,product);const bytes=Buffer.from(JSON.stringify(product,null,2)+'\n');await writeFile(resolve(staging,'map.fits.body-map.json'),bytes); + await writeFile(resolve(staging,'navigation.json'),JSON.stringify({...nav,sourceRequest:d.record.request,sourceSatisfaction:d.record.satisfaction,sourceResolution:{angularResolutionArcsec:facts.angularResolutionArcsec??null,evidence:facts.resolutionEvidence??[]},publication:'not-evaluated'},null,2)+'\n'); + const names=['navigation.json','texture.png','poles.png','figure.png'],extras=await Promise.all(names.map(async path=>({path,bytes:await readFile(resolve(staging,path))}))); + const implementation=sha256(Buffer.concat(await Promise.all(['projection.mts','../astronomy-packages/projection.mts','../body-map-product.mts','../body-map-publication.mts'].map(path=>readFile(new URL(path,import.meta.url)))))); + const software=[{name:'cssEarth projection',version:implementation},...Object.entries(requireRecord(nav.software)).map(([name,v])=>({name,version:requireString(v)}))]; + const record=bodyMapProductRecord(product,plane,bytes,inputs,software,(await astroqueryToolchain()).digest,extras); + await checkPins(inputs);await writeFile(resolve(staging,'map.fits.product.json'),formatProductRecord(record)); + await rmdir(destination);await rename(staging,destination); + return {directory:destination,map:resolve(destination,'map.fits'),figure:resolve(destination,'figure.png'),receipt:resolve(destination,'map.fits.product.json'),sourceSatisfaction:d.record.satisfaction,registration:geometry.registration,publication:'not-evaluated' as const}; + }catch(error){await rm(staging,{recursive:true,force:true});await rmdir(destination).catch(()=>{});throw error;} +} diff --git a/tools/objects/telescopes/projection.test.mts b/tools/objects/telescopes/projection.test.mts new file mode 100644 index 0000000000..dd75f9a881 --- /dev/null +++ b/tools/objects/telescopes/projection.test.mts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp,writeFile,rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { parseGeometry,verifiedProduct,localOutput } from './projection.mts'; +import { parseCli } from './cli.mts'; +import { writeProductRecord } from '../product-record.mts'; +import { exportSphere } from './sphere.mts'; +const geometry={schema:'cssearth-navigation-input@1',observer:'JWST',kernels:[{file:'rotation.tpc',role:'rotation',bytes:10,sha256:'a'.repeat(64),source:'https://naif.jpl.nasa.gov/'}],registration:{method:'wcs',explanation:'Header WCS; no independently fitted centre'},width:360,height:180,maximumEmissionDegrees:65}; +test('navigation refuses implicit centering, unpinned disc registration and invalid map budgets',()=>{ + assert.equal(parseGeometry(geometry,'/tmp').registration.method,'wcs'); + for(const override of [{registration:{method:'automatic'}},{registration:{method:'disc',parameters:[0,0,4,0],explanation:'fit'}},{registration:{method:'wcs',explanation:'header',parameters:[0,0,4,0]}},{width:1000000},{maximumEmissionDegrees:90},{kernels:[]},{radius:1}])assert.throws(()=>parseGeometry({...geometry,...override},'/tmp')); +}); +test('CLI separates measurement, navigation and sphere; selectors cannot leak between stages',()=>{ + assert.equal(parseCli(['project','output.product.json','--geometry','navigation.json','--out','map']).command,'project'); + assert.equal(parseCli(['export','map.fits.product.json','--output','sphere','--out','sphere']).command,'sphere'); + assert.throws(()=>parseCli(['project','result.json','--hdu','1','--geometry','nav','--out','map'])); + assert.throws(()=>parseCli(['export','map.json','--output','sphere','--plane','1','--out','sphere'])); +}); +test('projection and sphere cannot consume changed outputs or raw sky-image records',async()=>{ + const root=await mkdtemp(resolve(tmpdir(),'map-pins-')); + try{ + const file=resolve(root,'image.fits'),record=resolve(root,'output.product.json');await writeFile(file,'original'); + await writeProductRecord(record,{telescope:'Fixture',stage:'telescope-output',inputs:[],parameters:{},software:[]},[{path:'image.fits',file}]); + await assert.rejects(exportSphere(record,resolve(root,'sphere')),/registered body-map/); + await writeFile(file,'changed');await assert.rejects(verifiedProduct(record),/pins changed/); + assert.throws(()=>localOutput(root,'../outside'),/escapes/); + }finally{await rm(root,{recursive:true,force:true});} +}); diff --git a/tools/objects/telescopes/qualification-routes.mts b/tools/objects/telescopes/qualification-routes.mts index eb063ed54d..c89aa9e45e 100644 --- a/tools/objects/telescopes/qualification-routes.mts +++ b/tools/objects/telescopes/qualification-routes.mts @@ -1,6 +1,7 @@ import { flagValue } from '../../cli-arguments.mts'; import { JWST_CUBE_COVERAGE } from '../jwst/imaging/bands.mts'; import { bandOfFilters } from '../jwst/imaging/archive.mts'; +import { queryCapabilities, type CapabilityRequest } from './query.mts'; export interface QualificationObservation { readonly id: string; @@ -22,6 +23,7 @@ export interface QualificationObservation { } export type QualificationConfiguration = + | { readonly kind: 'archive-acquisition'; readonly key: string; readonly request: import('./query.mts').CapabilityRequest } | { readonly kind: 'source-product'; readonly id: string } | { readonly kind: 'spitzer-irac-channel'; readonly channel: number } | { readonly kind: 'jwst-band'; readonly band: string; readonly wavelengthMicrometres: readonly [number, number] } @@ -160,6 +162,12 @@ export function supportsQualificationRoute(telescope: string, mode: string, conf /** Parse only the instrument-specific part of a qualification command, through the same route that emitted it. */ export function qualificationConfigurationFromArguments(telescope: string, mode: string, args: readonly string[]): QualificationConfiguration { + if (flagValue(args, '--acquisition')) { + const request: unknown = JSON.parse(required(args, '--request')); + // The public validator checks all constraints before any archive IO. Invalid structures throw here. + queryCapabilities(request as CapabilityRequest, { ledgers: [], capabilities: [], targetCatalogue: [], targetAssociations: [], bodyMaps: [] }); + return { kind: 'archive-acquisition', key: required(args, '--acquisition'), request: request as CapabilityRequest }; + } if (flagValue(args, '--source-product')) return { kind: 'source-product', id: required(args, '--source-product') }; if (flagValue(args, '--pds-lidvid')) return { kind: 'pds-product', targetLid: required(args, '--pds-target-lid'), targetName: required(args, '--pds-target-name'), lidvid: required(args, '--pds-lidvid') }; const route = routeFor(telescope, mode); diff --git a/tools/objects/telescopes/qualified-observations.mts b/tools/objects/telescopes/qualified-observations.mts index 1e68ac34f6..ad45e0317f 100644 --- a/tools/objects/telescopes/qualified-observations.mts +++ b/tools/objects/telescopes/qualified-observations.mts @@ -1,4 +1,5 @@ /** Durable result of the public qualifier. A small index of exact local artifacts, not an archive ledger. */ +import { parseCalibrationDependencies, verifyCalibrationDependencies } from './calibration-dependencies.mts'; import { parseNativeMetadata } from './native-metadata.mts'; import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { resolve, relative } from 'node:path'; @@ -8,6 +9,7 @@ import { readProductRecord, sameRun } from '../product-record.mts'; import { assessInput, assessRequest, type ProductFacts } from './request-satisfaction.mts'; import type { CapabilityRequest } from './query.mts'; import { parseResolutionEvidence } from '../resolution-evidence.mts'; +import { parseRegion } from './vo/contracts.mts'; export interface QualifiedObservation { readonly target: string; readonly telescope: string; readonly mode: string; readonly observation: string; readonly program: string; @@ -18,6 +20,11 @@ export function parseProductFacts(raw: unknown): ProductFacts { const value = requireRecord(raw, 'product facts'); if (typeof value.verified !== 'boolean') throw new TypeError('Product verification must be explicit.'); const facts: { -readonly [K in keyof ProductFacts]: ProductFacts[K] } = { target: requireString(value.target, 'product target'), verified: value.verified }; + if (value.regionCoverage !== undefined) { + const r = requireRecord(value.regionCoverage), usablePixelCenters = requireFiniteNumber(r.usablePixelCenters), invalidPixelCenters = requireFiniteNumber(r.invalidPixelCenters); + if (r.answer !== 'partial' && r.answer !== 'unknown' || ![usablePixelCenters,invalidPixelCenters].every(n => Number.isSafeInteger(n) && n >= 0)) throw new TypeError('Invalid region assessment.'); + facts.regionCoverage = { region: parseRegion(r.region), answer: r.answer, reason: requireString(r.reason), usablePixelCenters, invalidPixelCenters }; + } if (value.kind !== undefined) { if (!['image', 'cube', 'spectrum', 'table', 'photometry', 'events', 'strips'].includes(String(value.kind))) throw new TypeError('Unknown qualified product kind.'); facts.kind = value.kind as ProductFacts['kind']; @@ -44,13 +51,15 @@ export function parseProductFacts(raw: unknown): ProductFacts { } if (value.resolutionEvidence !== undefined) facts.resolutionEvidence = requireArray(value.resolutionEvidence).map(parseResolutionEvidence); if (value.nativeMetadata !== undefined) facts.nativeMetadata = parseNativeMetadata(value.nativeMetadata); + if(value.calibrationDependencies !== undefined) facts.calibrationDependencies = parseCalibrationDependencies(value.calibrationDependencies); return facts; } -const implementation = async () => sha256(Buffer.concat(await Promise.all(['./qualify.mts', '../jwst/cubes/resolution.mts', '../jwst/cubes/spec3.mts', '../jwst/sample-agreement.mts', '../jwst/requirements.lock'] +const implementation = async () => sha256(Buffer.concat(await Promise.all(['./vo/package.mts', './vo/contracts.mts', './vo/discovery.mts', './vo/access.mts', './vo/bridge.mts', './vo/qualify.mts', '../astronomy-packages/client.mts', './qualify.mts', './product-science.mts', './native-metadata.mts', './calibration-dependencies.mts', '../astronomy-packages/science.mts', '../astronomy-packages/requirements.lock', '../jwst/cubes/resolution.mts', '../jwst/cubes/spec3.mts', '../jwst/sample-agreement.mts', '../jwst/requirements.lock'] .map(path => readFile(new URL(path, import.meta.url)))))); export async function rememberQualification(root: string, result: QualifiedObservation): Promise { const locations = Object.fromEntries((['product', 'receipt', 'productRecord', 'outputRoot'] as const).map(key => [key, relative(root, resolve(root, result[key]))])); const facts = parseProductFacts(result.facts), bound = facts.angularResolutionBound; + if(!await verifyCalibrationDependencies(root, facts.calibrationDependencies ?? []))throw new Error('Calibration dependency pin mismatch'); const resolutionEvidence = await Promise.all((facts.resolutionEvidence ?? []).map(async evidence => { if (!evidence.receipt) return evidence; const file = resolve(root, evidence.receipt.file), actual = await sha256File(file); @@ -58,7 +67,7 @@ export async function rememberQualification(root: string, result: QualifiedObser return { ...evidence, receipt: { ...evidence.receipt, file: relative(root, file) } }; })); const pins = await Promise.all([result.product, result.receipt, result.productRecord, ...(bound ? [bound.receipt] : []), - ...resolutionEvidence.flatMap(evidence => evidence.receipt ? [evidence.receipt.file] : [])].map(async file => ({ path: relative(root, resolve(root, file)), ...(await sha256File(resolve(root, file))) }))); + ...resolutionEvidence.flatMap(evidence => evidence.receipt ? [evidence.receipt.file] : []), ...(facts.calibrationDependencies ?? []).flatMap(r=>r.file?[r.file]:[])].map(async file => ({ path: relative(root, resolve(root, file)), ...(await sha256File(resolve(root, file))) }))); const portableFacts = { ...facts, ...(facts.resolutionEvidence ? { resolutionEvidence } : {}), ...(bound ? { angularResolutionBound: { ...bound, receipt: relative(root, resolve(root, bound.receipt)) } } : {}) }; const value = { ...result, ...locations, schema: 'cssearth-qualified-observation@1', facts: portableFacts, implementation: await implementation(), pins }; const text = `${JSON.stringify(value, null, 2)}\n`, directory = resolve(root, 'output/telescopes', result.target, 'qualifications'); @@ -84,6 +93,7 @@ export async function loadQualifiedObservations(root: string, target: string): P if (!record || !await sameRun(record, record, name => resolve(root, fields.outputRoot!, name))) continue; if (!record.outputs.some(output => resolve(root, fields.outputRoot!, output.path) === resolve(root, fields.product!))) continue; const facts = parseProductFacts(value.facts); + if(!await verifyCalibrationDependencies(root,facts.calibrationDependencies ?? []))continue; if (facts.angularResolutionBound && !requireArray(value.pins).some(raw => requireRecord(raw).path === facts.angularResolutionBound!.receipt)) continue; if (facts.resolutionEvidence?.some(evidence => evidence.receipt && !requireArray(value.pins).some(raw => { const pin = requireRecord(raw); return pin.path === evidence.receipt!.file && pin.sha256 === evidence.receipt!.sha256; diff --git a/tools/objects/telescopes/qualify-source.mts b/tools/objects/telescopes/qualify-source.mts index d1b7372622..40219ccf99 100644 --- a/tools/objects/telescopes/qualify-source.mts +++ b/tools/objects/telescopes/qualify-source.mts @@ -1,5 +1,7 @@ /** Acquire and qualify one exact package observation. Decoding establishes readability, never calibration or map registration. */ -import { fitsMetadata, isisMetadata, pdsMetadata } from './native-metadata.mts'; +import { readProductScience } from './product-science.mts'; +import { parseProductFacts } from './qualified-observations.mts'; +import { verifyCalibrationDependencies } from './calibration-dependencies.mts'; import type { ProductFacts } from './request-satisfaction.mts'; import { sourceHeaders } from './source-transfer.mts'; import { decodeIsis3Core } from '../terrestrial-layers/isis3-raster.mts'; @@ -56,7 +58,7 @@ export function inspectFits(bytes: Buffer, identity: SourceProduct['identity'], const arrays = rice ? [{ dimensions: [rice.width, rice.height], count: rice.values.length, at: (i: number) => rice.values[i]! }] : hdus.filter(hdu => hdu.count && hdu.header.XTENSION !== 'BINTABLE' && hdu.header.XTENSION !== 'TABLE').map(hdu => ({ dimensions: hdu.dimensions, count: hdu.count, at: fitsImageAccessor(bytes, hdu) })); if (!arrays.length) throw new Error('FITS product contains no supported image.'); - if (!arrays.some(array => array.dimensions.length === (kind === 'cube' ? 3 : 2))) throw new Error(`FITS array dimensions do not establish the declared ${kind} product kind.`); + if (!arrays.some(array => (array.dimensions.length>3?2+array.dimensions.slice(2).filter(n=>n!==1).length:array.dimensions.length) === (kind === 'cube' ? 3 : 2))) throw new Error(`FITS array dimensions do not establish the declared ${kind} product kind.`); const structures = arrays.map(array => { let finite = 0, min = Infinity, max = -Infinity; for (let i = 0; i < array.count; i++) { const n = array.at(i); if (Number.isFinite(n)) { finite++; min = Math.min(min, n); max = Math.max(max, n); } } @@ -68,16 +70,18 @@ export function inspectFits(bytes: Buffer, identity: SourceProduct['identity'], export async function qualifySourceProduct(root: string, product: SourceProduct) { assertPinnedLabel(product); for (const file of product.files) await acquireSourceFile(root, file); - const run = await sourceRun(product), receipt = sourceReceipt(product); - await assertInputPins(run.inputs, new Map(product.files.map(file => [file.origin, inside(root, file.path)]))); + let run = await sourceRun(product); const receipt = sourceReceipt(product); + await assertInputPins(run.inputs.filter(p=>p.role!=='calibration dependency'), new Map(product.files.map(file => [file.origin, inside(root, file.path)]))); const previous = await readProductRecord(resolve(root, receipt)); + const oldFacts = await readFile(resolve(root, `${dirname(receipt)}/decoded.json`),'utf8').then(t=>parseProductFacts(requireRecord(JSON.parse(t)).facts),()=>undefined).catch(()=>undefined); + if(oldFacts && !await verifyCalibrationDependencies(root,oldFacts.calibrationDependencies??[]))throw new Error('Calibration dependency pin mismatch'); + if(oldFacts)run=await sourceRun(product,oldFacts.calibrationDependencies); if (previous && sourceRecordComplete(previous, product) && await sameRun(previous, run, path => inside(root, path))) return { product: product.files.find(file => file.role === 'science')!.path, receipt, reused: true }; let decoded: unknown; let metadata: Partial = {}; if (product.decoder === 'fits-image') { const science = product.files.find(file => file.role === 'science')!, bytes = await readFile(inside(root, science.path)); decoded = inspectFits(bytes, product.identity, product.kind); - metadata = fitsMetadata(bytes, { file: science.path, sha256: science.sha256 }); } else if (product.decoder === 'isis3') { const bytes = await readFile(inside(root,product.files.find(f=>f.role==='science')!.path)); @@ -89,9 +93,9 @@ export async function qualifySourceProduct(root: string, product: SourceProduct) const validBands = new Array(core.bands).fill(false); // ISIS Real special pixels lie below VALID_MIN4 (0xff7ffffa); retain valid zero/negative noise. const threshold=Buffer.from('faff7fff','hex').readFloatLE(); - for(let i=0;i=threshold){finite++;min=Math.min(min,n);max=Math.max(max,n);validBands[Math.floor(i/(core.width*core.height))]=true;} } + for(let i=0;i=threshold){finite++;min=Math.min(min,n);max=Math.max(max,n);} } if(!finite) throw new Error('ISIS core contains no finite non-special samples.'); - metadata = isisMetadata(label, core.bands, validBands); + decoded={standard:'ISIS3',metadata:{identity:core.identity,scaling:{base:core.base,multiplier:core.multiplier}},structures:[{name:'Core',shape:core.bands===1?[core.height,core.width]:[core.bands,core.height,core.width],elements:core.data.length,finite,missing:core.data.length-finite,minimum:min,maximum:max}]}; } else { const labelPath = inside(root, product.labelPath ?? product.files.find(file => file.role === 'label')!.path), label = (await readFile(labelPath)).subarray(0, 128 * 1024).toString('latin1').split(/^END\s*$/imu)[0]!; @@ -99,19 +103,22 @@ export async function qualifySourceProduct(root: string, product: SourceProduct) for (const [key, expected] of Object.entries(product.identity)) if ((pds4 ? requireRecord(pds4ProductIdentity(label))[key] : pds3Keyword(label, key, [])) !== String(expected)) throw new Error(`PDS identity mismatch for ${key}.`); await assertPdsDependencies(root, product); decoded = (await pdsPackages({ operation: 'decode-product', labelPath })).decoded; - metadata = pdsMetadata(decoded); + const structures = requireArray(requireRecord(decoded).structures).map(value => requireRecord(value)); if (!structures.some(s => product.kind === 'table' ? s.kind === 'table' : requireArray(s.shape).length === (product.kind === 'cube' ? 3 : 2))) throw new Error('Decoded structures do not establish the declared product kind.'); } + const scienceFile = product.files.find(f=>f.role==='science')!; + metadata = await readProductScience(root,{file:scienceFile.path,format:product.decoder==='fits-image'?'fits':product.decoder==='isis3'?'isis3':'pds',target:product.target,label:product.labelPath,decoded}); + run=await sourceRun(product,metadata.calibrationDependencies); // Recheck after the decoder: the receipt may only attest the exact bytes it read. - await assertInputPins(run.inputs, new Map(product.files.map(file => [file.origin, inside(root, file.path)]))); + await assertInputPins(run.inputs.filter(p=>p.role!=='calibration dependency'), new Map(product.files.map(file => [file.origin, inside(root, file.path)]))); const report = `${dirname(receipt)}/decoded.json`; await mkdir(resolve(root, dirname(receipt)), { recursive: true }); await writeFile(resolve(root, report), `${JSON.stringify({ schema: 'cssearth-decoded-source@1', observation: product.id, archiveProductId: product.archiveProductId, decoded, facts: { target: product.target, verified: true, kind: product.kind, result: 'telescope-product', ...metadata }, meaning: product.meaning, limitations: product.limitations, acceptance: 'Input pins and header identity agree; complete supported arrays decoded. Native metadata are validated only for supported product conventions. External calibration accuracy and scientific suitability are not independently established; measurement descriptions remain source declarations.' }, null, 2)}\n`); const science = product.files.find(file => file.role === 'science')!; - await writeProductRecord(resolve(root, receipt), run, [...product.files.map(file => ({ path: file.path, file: inside(root, file.path) })), { path: report, file: resolve(root, report) }], + await writeProductRecord(resolve(root, receipt), run, [...product.files.map(file => ({ path: file.path, file: inside(root, file.path) })), { path: report, file: resolve(root, report) }, ...(metadata.calibrationDependencies??[]).flatMap(d=>d.file?[{path:d.file,file:resolve(root,d.file)}]:[])], [{ kind: 'archive-origin', receipt, product: science.path, establishes: 'Manifest-pinned archive bytes, matching header identity and complete supported numeric structure decoding. No local recalibration, archive comparison or surface registration is claimed.' }]); return { product: science.path, receipt, reused: false }; } diff --git a/tools/objects/telescopes/qualify.mts b/tools/objects/telescopes/qualify.mts index 6f55c8450b..5ac24774cb 100644 --- a/tools/objects/telescopes/qualify.mts +++ b/tools/objects/telescopes/qualify.mts @@ -5,7 +5,8 @@ import { qualifySourceProduct } from './qualify-source.mts'; import { writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { rememberQualification } from './qualified-observations.mts'; -import { openSpectralCube } from '../jwst/cubes/spectral-cube.mts'; +import { readProductScience } from './product-science.mts'; +import { pdsPackages } from '../astronomy-packages/pds-client.mts'; import { measureCubeResolution } from '../jwst/cubes/resolution.mts'; import { readFitsFileHdus, type FitsHeader } from '../../fits.mts'; import type { ProductFacts } from './request-satisfaction.mts'; @@ -21,7 +22,7 @@ import { compareTemplates } from '../naco/compare.mts'; import { pinFrames, reduceProgram as reduceNacoProgram } from '../naco/reduce.mts'; import { qualifyPdsArchiveProduct } from '../pds/archive-final.mts'; import { buildPdsLedger } from '../pds/archive-ledger.mts'; -import { productRecordPath, readProductRecord } from '../product-record.mts'; +import { productRecordPath, readProductRecord, sameRun } from '../product-record.mts'; import { compareChannel, receiptPath } from '../spitzer/compare.mts'; import { defaultDataRoot, pinProgram, writeSpitzerProgram } from '../spitzer/archive.mts'; import { refreshLocalLedger as refreshSpitzerLedger } from '../spitzer/archive-ledger.mts'; @@ -140,6 +141,16 @@ const QUALIFIERS: Readonly { + if (request.configuration.kind === 'archive-acquisition') { + const configuration = request.configuration; + if (configuration.request.target !== request.target) throw new Error('Acquisition target differs from qualification target.'); + const inputs = await loadQueryInputs(root, configuration.request, request.observation); + const spec = inputs.vo?.records.flatMap(r => r.products).find(p => p.key === configuration.key && p.observation.key === request.observation && p.observation.service === request.telescope && `native-${p.kind}` === request.mode); + if (!spec) throw new Error('The saved archive acquisition is no longer available. Query again.'); + const { qualifyVoProduct } = await import('./vo/qualify.mts'); + const result = await qualifyVoProduct(root, spec); + return { schema: QUALIFICATION_SCHEMA, ...result, configuration }; + } if (request.configuration.kind === 'source-product') { const { answer } = await indexedObservation(root, request, [0.000001, 1_000_000]); const id = request.configuration.id; @@ -181,10 +192,18 @@ export function fitsObservationInterval(header: FitsHeader): Pick { - let facts: ProductFacts = { target: result.target, verified: true, kind: 'image', result: 'telescope-product' }; + const recordPath = result.configuration.kind === 'pds-product' ? result.receipt : productRecordPath(result.product); + const record = await readProductRecord(recordPath); + if(!record || !await sameRun(record,record,path=>resolve(dirname(result.product),path)))throw new Error('Producing record or output pins are invalid'); + let decoded:unknown; + if(result.configuration.kind==='pds-product'){ + const labels=record.outputs.filter(o=>o.path.endsWith('.xml')); + if(labels.length!==1)throw new Error('PDS producing record must identify one pinned observation label'); + decoded=(await pdsPackages({operation:'decode-product',labelPath:resolve(dirname(result.product),labels[0].path)})).decoded; + } + let facts: ProductFacts = { target: result.target, verified: true, kind: result.configuration.kind==='jwst-band'?'cube':'image', result: 'telescope-product', + ...await readProductScience(root,{file:result.product,format:result.configuration.kind==='pds-product'?'pds':'fits',target:result.target,decoded}) }; if (result.configuration.kind === 'jwst-band') { - const cube = await openSpectralCube(result.product); - facts = { ...facts, kind: 'cube', wavelengthIntervalsMicrometres: [[cube.wavelength(0), cube.wavelength(cube.planes - 1)]] }; const resolution = await measureCubeResolution(result.product); if (resolution.bound) facts = { ...facts, angularResolutionBound: resolution.bound }; } @@ -192,9 +211,9 @@ export async function recordQualification(root: string, result: QualificationRes const headers = await readFitsFileHdus(result.product), header = headers[0]!.header; facts = { ...facts, ...fitsObservationInterval(header) }; } - const record = await readProductRecord(productRecordPath(result.product)); const evidence = record?.evidence.findLast(entry => entry.receiptPin !== undefined); const qualified = { ...result, receipt: evidence ? resolve(dirname(result.product), evidence.receipt) : result.receipt }; + if(!await sameRun(record,record,path=>resolve(dirname(result.product),path)))throw new Error('Producing outputs changed during qualification'); await rememberQualification(root, { ...qualified, facts, productRecord: result.configuration.kind === 'pds-product' ? result.receipt : productRecordPath(result.product), outputRoot: dirname(result.product) }); return qualified; } diff --git a/tools/objects/telescopes/query.mts b/tools/objects/telescopes/query.mts index f280b7b099..95800c8a48 100644 --- a/tools/objects/telescopes/query.mts +++ b/tools/objects/telescopes/query.mts @@ -1,4 +1,6 @@ import { inputWavelengths } from './recipe-request.mts'; +import { parseLimits, parseRegion } from './vo/contracts.mts'; +import { loadVoInputs, voCandidates, type VoInputs, type VoProductCandidate } from './vo/bridge.mts'; import { loadQualifiedObservations, matchingProduct, type QualifiedObservation } from './qualified-observations.mts'; import { assessInput, assessRequest, type RequestSatisfaction } from './request-satisfaction.mts'; import { parseAcceptedAssumptions, type ResolutionAssumption } from '../resolution-evidence.mts'; @@ -147,6 +149,9 @@ export interface Candidate { } export interface CapabilityRequest { + readonly region?: import('./vo/contracts.mts').IcrsCircle; + readonly spectralFrame?: 'barycentric'; + readonly transferLimits?: import('./vo/contracts.mts').TransferLimits; readonly continuumMicrometres?: readonly [readonly [number,number],readonly [number,number]]; readonly acceptedAssumptions?: readonly ResolutionAssumption[]; readonly target: string; @@ -167,6 +172,7 @@ export interface CapabilityRequest { /** Everything the query reads, already loaded: it does no input or output of its own. */ export interface QueryInputs { + readonly vo?: VoInputs; readonly sourceIntakeIssues?: readonly SourceIntakeIssue[]; readonly ledgers: readonly { readonly telescope: string; readonly path: string; readonly value: unknown }[]; readonly capabilities: readonly ModeCapability[]; @@ -180,6 +186,8 @@ export interface QueryInputs { } export interface CapabilityAnswer { + readonly archiveProducts?: readonly VoProductCandidate[]; + readonly archiveAccess?: VoInputs; readonly sourceIntakeIssues?: readonly SourceIntakeIssue[]; readonly target: string; readonly request: CapabilityRequest; @@ -212,7 +220,7 @@ export interface ObservationSelection { export type SelectionBlockerCode = 'incomplete-request' | 'candidate' | 'constraint' | 'toolkit' | 'body-map' | 'programme'; export interface SelectionBlocker { readonly code: SelectionBlockerCode; readonly reason: string; readonly constraint?: string } -export interface SelectionAssessment { readonly candidate?: Candidate; readonly blockers: readonly SelectionBlocker[] } +export interface SelectionAssessment { readonly candidate?: Candidate; readonly archiveProduct?: VoProductCandidate; readonly blockers: readonly SelectionBlocker[] } export class ObservationSelectionError extends Error { readonly blockers: readonly SelectionBlocker[]; @@ -249,6 +257,9 @@ const missingRequestFields = (request: CapabilityRequest): string[] => [...(requ ...(request.kind ? [] : ['product kind (--kind)']), ...(request.result ? [] : ['requested result (--result telescope-product|body-map)'])]; const requestArguments = (request: CapabilityRequest): string[] => ['--target', request.target, '--wavelength', request.wavelengthMicrometres.join(','), + ...(request.region ? ['--icrs-circle', [request.region.raDegrees, request.region.decDegrees, request.region.radiusDegrees].join(',')] : []), + ...(request.spectralFrame ? ['--spectral-frame', request.spectralFrame] : []), + ...(request.transferLimits ? ['--max-science-bytes', String(request.transferLimits.scienceBytes), '--max-metadata-bytes', String(request.transferLimits.metadataBytes), '--max-link-depth', String(request.transferLimits.nestedEdges), '--max-link-requests', String(request.transferLimits.metadataRequests), '--max-expanded-bytes', String(request.transferLimits.expandedBytes), '--max-package-members', String(request.transferLimits.packageMembers)] : []), ...(request.continuumMicrometres ? ['--continuum', request.continuumMicrometres.flat().join(',')] : []), ...(request.acceptedAssumptions?.length ? ['--accept-assumptions', request.acceptedAssumptions.join(',')] : []), ...(!request.time ? [] : 'any' in request.time ? ['--any-time'] : ['--from', request.time.fromIso, '--to', request.time.toIso]), @@ -875,6 +886,11 @@ const UNKNOWN_UNTIL_READ = (target: string, mode: TargetMode): string[] => [ `Whether ${target} was resolved at all in a given exposure, and how much of it the field of view held.`]; export function queryCapabilities(request: CapabilityRequest, inputs: QueryInputs): CapabilityAnswer { + const requestFields = new Set(['target','wavelengthMicrometres','continuumMicrometres','acceptedAssumptions','time','angularResolutionArcsec','surfaceResolutionKm','resolutionElements','rangeKm','bodyRadiusKm','kind','result','region','spectralFrame','transferLimits']); + for (const key of Object.keys(request)) if (!requestFields.has(key)) throw new TypeError(`Unsupported scientific request constraint ${key}.`); + if (request.region !== undefined) parseRegion(request.region); + if (request.transferLimits !== undefined) parseLimits(request.transferLimits); + if (request.spectralFrame !== undefined && request.spectralFrame !== 'barycentric') throw new TypeError('Only an explicit barycentric spectral frame is supported.'); if (request.wavelengthMicrometres.length !== 2 || !request.wavelengthMicrometres.every(Number.isFinite) || !(request.wavelengthMicrometres[0] > 0 && request.wavelengthMicrometres[1] >= request.wavelengthMicrometres[0])) throw new RangeError('A request states its wavelengths in micrometres, shortest first.'); for (const key of ['angularResolutionArcsec', 'surfaceResolutionKm', 'resolutionElements', 'rangeKm', 'bodyRadiusKm'] as const) { const value = request[key]; @@ -889,7 +905,7 @@ export function queryCapabilities(request: CapabilityRequest, inputs: QueryInput if (request.result && !(REQUESTED_RESULTS as readonly string[]).includes(request.result)) throw new TypeError(`Unknown requested result ${request.result}.`); inputWavelengths(request); const targetResolution = resolveTarget(request.target, inputs.targetCatalogue); - if (targetResolution.status === 'unknown') return { target: request.target, request, targetResolution, candidates: [], unassignedEvidence: [], targetCoverage: [], withoutTheTarget: [], + if (targetResolution.status !== 'resolved') return { target: request.target, request, targetResolution, candidates: [], unassignedEvidence: [], targetCoverage: [], withoutTheTarget: [], endpoint: { status: 'unknown-target', selectableCandidates: 0, blockerCodes: ['unknown-target'] } }; const target = targetResolution.canonical.id, canonicalRequest: CapabilityRequest = { ...request, target }; const capabilities = new Map(inputs.capabilities.map(entry => [`${entry.telescope} :: ${entry.mode}`, entry] as const)); @@ -933,14 +949,15 @@ export function queryCapabilities(request: CapabilityRequest, inputs: QueryInput const rank = (candidate: Candidate) => candidate.meetsConstraints.wavelength?.answer === 'yes' ? 0 : candidate.meetsConstraints.wavelength?.answer === 'partial' ? 1 : 2; const assessed: Candidate[] = candidates.map(candidate => ({ ...candidate, selectionAssessment: workflowAssessment(canonicalRequest, target, candidate) })); assessed.sort((a, b) => rank(a) - rank(b) || (`${a.telescope}${a.mode}` < `${b.telescope}${b.mode}` ? -1 : 1)); - const selectableCandidates = assessed.filter(candidate => candidate.selectionAssessment.selectable).length; + const archiveProducts = voCandidates(canonicalRequest, inputs.vo, inputs.qualifiedProducts ?? []); + const selectableCandidates = assessed.filter(candidate => candidate.selectionAssessment.selectable).length + archiveProducts.filter(p => p.product && p.satisfaction.status !== 'refused').length; const incompleteIndex = !assessed.length && targetCoverageResults.some(entry => entry.state === 'not-searched' || entry.state === 'unanswered'); const status = missingRequestFields(canonicalRequest).length ? 'request-incomplete' : selectableCandidates ? 'selectable-candidates' : incompleteIndex ? 'index-incomplete' : 'no-selectable-candidate'; const coverageBlockers = !assessed.length ? targetCoverageResults.flatMap(entry => entry.state === 'not-searched' ? ['target-index-unavailable' as const] : entry.state === 'unanswered' ? ['archive-query-unanswered' as const] : entry.state === 'unsupported-products' ? ['archive-products-unsupported' as const] : []) : []; const withoutTheTarget = targetCoverageResults.filter(entry => entry.state === 'searched-empty').map(({ telescope, ledger, reason }) => ({ telescope, ledger, reason })); - return { sourceIntakeIssues: inputs.sourceIntakeIssues, target, request: canonicalRequest, targetResolution, unassignedEvidence: unassigned, targetCoverage: targetCoverageResults, withoutTheTarget, candidates: assessed, + return { ...(inputs.vo ? { archiveAccess: inputs.vo, archiveProducts } : {}), sourceIntakeIssues: inputs.sourceIntakeIssues, target, request: canonicalRequest, targetResolution, unassignedEvidence: unassigned, targetCoverage: targetCoverageResults, withoutTheTarget, candidates: assessed, endpoint: { status, selectableCandidates, blockerCodes: [...new Set([...coverageBlockers, ...assessed.flatMap(candidate => candidate.selectionAssessment.blockers.map(blocker => blocker.code))])] } }; } @@ -951,6 +968,12 @@ export function assessObservationSelection(answer: CapabilityAnswer, telescope: const missing = missingRequestFields(request); const matches = answer.candidates.filter(candidate => candidate.telescope === telescope && candidate.mode === mode); const blockers: SelectionBlocker[] = missing.map(reason => ({ code: 'incomplete-request', reason: `The request is missing ${reason}.` })); + const archive = answer.archiveProducts?.find(p => p.acquisitionKey === programme && p.observation.service === telescope && `native-${p.observation.kind}` === mode); + if (archive) { + if (!archive.product) blockers.push({ code: 'programme', reason: 'The exact archive product has not been qualified.' }); + for (const [constraint, verdict] of Object.entries(archive.satisfaction.constraints)) if (verdict.answer === 'no') blockers.push({ code: 'constraint', constraint, reason: verdict.reason }); + return { archiveProduct: archive, blockers }; + } if (matches.length !== 1) return { blockers: [...blockers, { code: 'candidate', reason: matches.length ? `${telescope} ${mode} is ambiguous.` : `${telescope} ${mode} is not a candidate for ${answer.target}.` }] }; const candidate = matches[0]!; for (const [constraint, verdict_] of Object.entries(candidate.meetsConstraints)) if (verdict_.answer === 'no') @@ -971,6 +994,13 @@ export function assessObservationSelection(answer: CapabilityAnswer, telescope: export function selectObservation(answer: CapabilityAnswer, telescope: string, mode: string, programme: string): ObservationSelection { const assessment = assessObservationSelection(answer, telescope, mode, programme); if (assessment.blockers.length) throw new ObservationSelectionError(telescope, mode, programme, assessment.blockers); + if (assessment.archiveProduct) { + const archive = assessment.archiveProduct, snapshot = answer.archiveAccess?.records.find(r => r.observation.key === archive.observation.key)?.snapshot; + return { schema: OBSERVATION_SELECTION_SCHEMA, request: answer.request, telescope, mode, programme, product: archive.product!, satisfaction: archive.satisfaction, + toolkitLevel: 'source-qualified', constraints: archive.satisfaction.constraints, bodyMapSupport: { answer: 'no', reason: 'Native archive qualification does not establish a body-map author.' }, + unresolved: Object.entries(archive.satisfaction.constraints).flatMap(([constraint, verdict]) => verdict.answer === 'partial' || verdict.answer === 'unknown' ? [{ constraint, answer: verdict.answer, reason: verdict.reason }] : []), + evidence: { ledger: snapshot?.response.raw.path ?? archive.observation.snapshot, archiveDate: snapshot?.response.fetchedAt ?? 'unknown', receipts: [archive.product!.receipt], targetAssociations: [], bodyMaps: [], investigations: [] } }; + } const request = answer.request, candidate = assessment.candidate!; const product = matchingProduct(candidate.qualifiedProducts ?? [], request, programme); const source = candidate.observations?.records?.find(record => record.programme === programme); @@ -989,7 +1019,10 @@ export function selectObservation(answer: CapabilityAnswer, telescope: string, m export const LEDGER_TELESCOPES: readonly string[] = Object.keys(ADAPTERS); /** Read everything the query needs from the repository. The query itself reads nothing. */ -export async function loadQueryInputs(root: string, target: string): Promise { +/** The string overload retains legacy archive loading; the full request also searches bounded VO services. */ +export async function loadQueryInputs(root: string, targetOrRequest: string | CapabilityRequest, selectedObservation?: string): Promise { + if (typeof targetOrRequest !== 'string') queryCapabilities(targetOrRequest, { ledgers: [], capabilities: [], targetCatalogue: [], targetAssociations: [], bodyMaps: [] }); + const target = typeof targetOrRequest === 'string' ? targetOrRequest : targetOrRequest.target; // These package descriptors are the source of the application's generated catalogue. Reading them keeps this CLI usable // in a clean checkout, before `prepare` has emitted site/prepared-object-catalog.mts. const objectRoot = resolve(root, 'src/objects'), targetCatalogue: TargetCatalogueEntry[] = []; @@ -999,8 +1032,9 @@ export async function loadQueryInputs(root: string, target: string): Promise name.endsWith('/source/nebula.json')).map(name => resolve(objectRoot, name)), resolve(objectRoot, 'local-group/prepared/catalogue.json'), resolve(objectRoot, 'galaxy-clusters/prepared/catalogue.json')]; @@ -1036,7 +1070,8 @@ export async function loadQueryInputs(root: string, target: string): Promise { if (hasErrorCode(error, 'ENOENT', 'ENOTDIR')) return undefined; throw error; }); const sourceIntakeIssues: SourceIntakeIssue[] = []; const sourceProducts = resolution.status === 'resolved' ? await loadSourceProducts(root, canonicalTarget, sourceIntakeIssues) : []; - return { sourceIntakeIssues, ledgers, capabilities, targetCatalogue, targetAssociations, associationFailures, bodyMaps, qualifiedProducts: resolution.status === 'resolved' ? await loadQualifiedObservations(root, canonicalTarget) : [], sourceProducts, ...(investigations === undefined ? {} : { investigations: { path: investigationPath, value: investigations } }) }; + const vo = typeof targetOrRequest !== 'string' && resolution.status === 'resolved' ? await loadVoInputs(root, { ...targetOrRequest, target: canonicalTarget }, targetCatalogue, selectedObservation) : undefined; + return { ...(vo ? { vo } : {}), sourceIntakeIssues, ledgers, capabilities, targetCatalogue, targetAssociations, associationFailures, bodyMaps, qualifiedProducts: resolution.status === 'resolved' ? await loadQualifiedObservations(root, canonicalTarget) : [], sourceProducts, ...(investigations === undefined ? {} : { investigations: { path: investigationPath, value: investigations } }) }; } const LEVEL_WORDS: Readonly> = Object.freeze({ none: 'no toolkit', @@ -1045,6 +1080,7 @@ const LEVEL_WORDS: Readonly> = Object.freeze({ none proven: 'locally produced with accepted evidence' }); export function formatAnswer(answer: CapabilityAnswer): string { + if (answer.targetResolution.status === 'ambiguous') return `workflow: unknown-target\nblocker codes: unknown-target\n\nAmbiguous target ${answer.targetResolution.requested}: ${answer.targetResolution.candidates.map(entry => `${entry.name} (${entry.id})`).join(', ')}. Use a canonical id.\n`; if (answer.targetResolution.status === 'unknown') return `workflow: unknown-target\nblocker codes: unknown-target\n\nNo shipped object matches ${answer.targetResolution.requested}.${answer.targetResolution.suggestions.length ? ` Did you mean ${answer.targetResolution.suggestions.map(entry => `${entry.name} (${entry.id})`).join(', ')}?` : ''}\n`; const lines = [`workflow: ${answer.endpoint.status}; ${answer.endpoint.selectableCandidates} of ${answer.candidates.length} candidate mode(s) can proceed to explicit selection.`, @@ -1112,6 +1148,11 @@ When qualification is the only blocker, the answer may provide a telescope:quali Use --json for JSON. With the package script, use pnpm --silent telescope:query ... --json for JSON-only stdout.`; export function requestFromArguments(args: readonly string[]): CapabilityRequest { + const circle = flagValue(args, '--icrs-circle')?.split(',').map(Number), spectralFrame = flagValue(args, '--spectral-frame'); + if (circle && circle.length !== 3) throw new TypeError('--icrs-circle requires RA,DEC,RADIUS in degrees.'); + if (spectralFrame !== undefined && spectralFrame !== 'barycentric') throw new TypeError('--spectral-frame requires barycentric.'); + const limitFlags = { scienceBytes: '--max-science-bytes', metadataBytes: '--max-metadata-bytes', nestedEdges: '--max-link-depth', metadataRequests: '--max-link-requests', expandedBytes: '--max-expanded-bytes', packageMembers: '--max-package-members' }; + const limits = Object.fromEntries(Object.entries(limitFlags).flatMap(([key, flag]) => flagValue(args, flag) === undefined ? [] : [[key, Number(flagValue(args, flag))]])); const target = flagValue(args, '--target'), wavelength = flagValue(args, '--wavelength'); if (!target || !wavelength) throw new Error(QUERY_HELP); const range = wavelength.split(',').map(Number); @@ -1124,7 +1165,9 @@ export function requestFromArguments(args: readonly string[]): CapabilityRequest const assumptions = flagValue(args, '--accept-assumptions'); const continuum = flagValue(args, '--continuum')?.split(',').map(Number); if (continuum && (continuum.length !== 4 || !continuum.every(Number.isFinite))) throw new TypeError('--continuum requires four finite wavelength bounds.'); - return { target, ...(continuum ? { continuumMicrometres: [[continuum[0]!, continuum[1]!], [continuum[2]!, continuum[3]!]] as const } : {}), ...(assumptions === undefined ? {} : { acceptedAssumptions: parseAcceptedAssumptions(assumptions.split(',')) }), + return { target, ...(circle ? { region: parseRegion({ frame: 'icrs', shape: 'circle', raDegrees: circle[0], decDegrees: circle[1], radiusDegrees: circle[2] }) } : {}), + ...(spectralFrame ? { spectralFrame } : {}), ...(Object.keys(limits).length ? { transferLimits: parseLimits(limits) } : {}), + ...(continuum ? { continuumMicrometres: [[continuum[0]!, continuum[1]!], [continuum[2]!, continuum[3]!]] as const } : {}), ...(assumptions === undefined ? {} : { acceptedAssumptions: parseAcceptedAssumptions(assumptions.split(',')) }), wavelengthMicrometres: [range[0]!, range[1]!], ...(anyTime ? { time: { any: true as const } } : from && to ? { time: { fromIso: from, toIso: to } } : {}), ...(numberFlag(args, '--min-arcsec') === undefined ? {} : { angularResolutionArcsec: numberFlag(args, '--min-arcsec')! }), ...(numberFlag(args, '--min-km') === undefined ? {} : { surfaceResolutionKm: numberFlag(args, '--min-km')! }), @@ -1139,7 +1182,7 @@ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').at(-1 if (args.includes('--help') || args.includes('-h')) { process.stdout.write(`${QUERY_HELP}\n`); process.exitCode = 0; } else { const request = requestFromArguments(args); - const answer = queryCapabilities(request, await loadQueryInputs(resolve(import.meta.dirname, '../../..'), request.target)); + const answer = queryCapabilities(request, await loadQueryInputs(resolve(import.meta.dirname, '../../..'), request)); const telescope = flagValue(args, '--select-telescope'), mode = flagValue(args, '--select-mode'), programme = flagValue(args, '--program'); if ([telescope, mode, programme].some(Boolean) && ![telescope, mode, programme].every(Boolean)) throw new TypeError('--select-telescope, --select-mode and --program are given together.'); process.stdout.write(telescope && mode && programme ? `${JSON.stringify(selectObservation(answer, telescope, mode, programme), null, 2)}\n` diff --git a/tools/objects/telescopes/query.test.mts b/tools/objects/telescopes/query.test.mts index 1b469708f2..b40694fa9f 100644 --- a/tools/objects/telescopes/query.test.mts +++ b/tools/objects/telescopes/query.test.mts @@ -88,6 +88,16 @@ test('a JWST cube mode takes its coverage from the bands, and touching bands bec assert.equal(candidate(answer, 'NIRSPEC/IFU').meetsConstraints.wavelength?.answer, 'yes'); }); +test('a colliding designation remains ambiguous through the capability query', () => { + const answer = queryCapabilities({ target: '2009 RE26', wavelengthMicrometres: [0.5, 0.7] }, inputs([], { targetCatalogue: [ + { id: 'emilylakdawalla', name: 'Emilylakdawalla', aliases: ['2009 RE26'] }, + { id: 'other-object', name: 'Other object', aliases: ['2009RE26'] }, + ] })); + assert.equal(answer.targetResolution.status, 'ambiguous'); + assert.equal(answer.endpoint.status, 'unknown-target'); + assert.match(formatAnswer(answer), /Ambiguous target 2009 RE26/u); +}); + test('a gap between the two intervals of a mode is not coverage', () => { const ask = (from: number, to: number) => candidate(queryCapabilities({ target: 'hd-181327', wavelengthMicrometres: [from, to] }, inputs([{ telescope: 'jwst', value: JWST_LEDGER }])), 'NIRCAM/CORON').meetsConstraints.wavelength!; diff --git a/tools/objects/telescopes/request-satisfaction.mts b/tools/objects/telescopes/request-satisfaction.mts index 600b0a9d67..26fd2b6d4a 100644 --- a/tools/objects/telescopes/request-satisfaction.mts +++ b/tools/objects/telescopes/request-satisfaction.mts @@ -1,10 +1,13 @@ +import type { CalibrationDependency } from './calibration-dependencies.mts'; import type { NativeMetadata } from './native-metadata.mts'; import { inputWavelengths } from './recipe-request.mts'; /** Product facts answer a request; catalogue capabilities and successful decoding alone do not. */ import type { CapabilityRequest, ConstraintVerdict, ProductKind, RequestedResult } from './query.mts'; import { supportsMeasuredResolution, PROFILE_ASSUMPTIONS, RESOLUTION_ASSUMPTIONS, type ResolutionEvidence } from '../resolution-evidence.mts'; export interface ProductFacts { + readonly regionCoverage?: { readonly region: import('./vo/contracts.mts').IcrsCircle; readonly answer: 'partial' | 'unknown'; readonly reason: string; readonly usablePixelCenters: number; readonly invalidPixelCenters: number }; readonly nativeMetadata?: NativeMetadata; + readonly calibrationDependencies?: readonly CalibrationDependency[]; readonly verified: boolean; readonly target: string; readonly kind?: ProductKind; @@ -35,6 +38,9 @@ export function assessRequest(request: CapabilityRequest, facts: ProductFacts): target: verdict(request.target === facts.target, `Product target: ${facts.target}.`), }; const ranges = facts.wavelengthIntervalsMicrometres; + if (request.region) constraints.region = facts.regionCoverage && JSON.stringify(facts.regionCoverage.region) === JSON.stringify(request.region) + ? { answer: facts.regionCoverage.answer, reason: facts.regionCoverage.reason } : unknown('Usable-pixel coverage of the requested ICRS circle has not been qualified.'); + if (request.spectralFrame) constraints.spectralFrame = unknown('The product spectral reference frame has not been qualified.'); if (!ranges?.length) constraints.wavelength = unknown('No qualified wavelength interval is stated; a central wavelength does not establish band coverage.'); else { const [from, to] = facts.result === 'body-map' ? request.wavelengthMicrometres : inputWavelengths(request); let end = from, started = false; diff --git a/tools/objects/telescopes/selected-product.mts b/tools/objects/telescopes/selected-product.mts index 75f2b8f89f..c9e7630d28 100644 --- a/tools/objects/telescopes/selected-product.mts +++ b/tools/objects/telescopes/selected-product.mts @@ -2,12 +2,16 @@ import { resolve } from 'node:path'; import { readProductRecord, pinFile, type ProductInput } from '../product-record.mts'; import { loadSourceProducts, sourceQualifiedObservations } from './source-products.mts'; -import { loadQualifiedObservations } from './qualified-observations.mts'; +import { loadQualifiedObservations, type QualifiedObservation } from './qualified-observations.mts'; import type { ObservationSelection } from './query.mts'; export async function selectedProductInput(root:string, selection:ObservationSelection) { const stated=selection.product; if(!stated)throw new Error('The selection has no qualified artifact; qualify and select a product first.'); - const current=[...await loadQualifiedObservations(root,selection.request.target), ...sourceQualifiedObservations(await loadSourceProducts(root,selection.request.target))].find(p=>p.product===stated.product && p.productRecord===stated.productRecord && p.receipt===stated.receipt && p.program===selection.programme && p.telescope===selection.telescope && p.mode===selection.mode && p.observation===stated.observation); + if(stated.program!==selection.programme||stated.telescope!==selection.telescope||stated.mode!==selection.mode||stated.target!==selection.request.target)throw new Error('Selection differs from its qualified artifact.'); + return qualifiedProductInput(root,stated); +} +export async function qualifiedProductInput(root:string, stated:QualifiedObservation) { + const current=[...await loadQualifiedObservations(root,stated.target), ...sourceQualifiedObservations(await loadSourceProducts(root,stated.target))].find(p=>p.product===stated.product && p.productRecord===stated.productRecord && p.receipt===stated.receipt && p.program===stated.program && p.telescope===stated.telescope && p.mode===stated.mode && p.observation===stated.observation); if(!current)throw new Error('The selected artifact or its qualification receipt is stale.'); const file=resolve(root,current.product),pin=await pinFile(file),record=await readProductRecord(resolve(root,current.productRecord)); if(!record?.outputs.some(o=>resolve(root,current.outputRoot,o.path)===file && o.sha256===pin.sha256 && o.bytes===pin.bytes))throw new Error('Selected artifact is not the qualified output.'); diff --git a/tools/objects/telescopes/session.mts b/tools/objects/telescopes/session.mts index 321f8a257b..624bd4f722 100644 --- a/tools/objects/telescopes/session.mts +++ b/tools/objects/telescopes/session.mts @@ -6,7 +6,7 @@ import { hasErrorCode, requireArray, requireRecord, requireString } from '../../ import { pinFile, readProductRecord, type ProductRecord } from '../product-record.mts'; import { loadQueryInputs, queryCapabilities, requestFromArguments, selectObservation, assessObservationSelection, type CapabilityAnswer, type CapabilityRequest, type QueryInputs } from './query.mts'; -import { matchingProduct, type QualifiedObservation } from './qualified-observations.mts'; +import { matchingProduct, loadQualifiedObservations, type QualifiedObservation } from './qualified-observations.mts'; import { qualifyObservation, type QualificationRequest } from './qualify.mts'; import { selectedProductInput } from './selected-product.mts'; import { assessRequest, type ProductFacts } from './request-satisfaction.mts'; @@ -14,12 +14,13 @@ import type { QualificationConfiguration } from './qualification-routes.mts'; export const SESSION_SCHEMA = 'cssearth-telescope-session@1'; export interface Choice { + readonly acquisitionKey?: string; readonly pick: number; readonly telescope: string; readonly mode: string; readonly observation: string; readonly program: string; readonly state: 'ready' | 'qualify'; readonly configuration?: QualificationConfiguration; readonly product?: QualifiedObservation; readonly sourceId?: string; } -export const choiceKey = (choice: Pick): string => - JSON.stringify([choice.telescope, choice.mode, choice.observation, choice.program]); +export const choiceKey = (choice: Pick): string => + JSON.stringify(choice.acquisitionKey ? ['vo-acquisition@1', choice.acquisitionKey] : [choice.telescope, choice.mode, choice.observation, choice.program]); /** Only offer observations that have a current artifact or an API-provided qualification action. */ export function observationChoices(answer: CapabilityAnswer): Choice[] { @@ -37,6 +38,13 @@ export function observationChoices(answer: CapabilityAnswer): Choice[] { configuration: action.configuration, ...(action.configuration.kind === 'source-product' ? { sourceId: action.configuration.id } : {}), state: 'qualify' }); } } + for (const candidate of answer.archiveProducts ?? []) { + if (candidate.satisfaction.status === 'refused' || !candidate.product && !candidate.action) continue; + const configuration = candidate.action?.configuration; + add({ acquisitionKey: candidate.acquisitionKey, telescope: candidate.observation.service, mode: candidate.product?.mode ?? `native-${candidate.observation.kind}`, + observation: candidate.observation.key, program: candidate.acquisitionKey, state: candidate.product ? 'ready' : 'qualify', + ...(configuration ? { configuration } : {}), ...(candidate.product ? { product: candidate.product } : {}) }); + } return [...choices.values()]; } @@ -45,10 +53,12 @@ export interface Session { readonly target: string; readonly choices: readonly Choice[]; readonly answer: CapabilityAnswer; } export interface SessionServices { + readonly loadRequest?: (root: string, request: CapabilityRequest, selectedObservation?: string) => Promise; readonly load: (root: string, target: string) => Promise; readonly qualify: (root: string, request: QualificationRequest) => Promise; } -const services: SessionServices = { load: loadQueryInputs, qualify: qualifyObservation }; +const services: SessionServices = { load: loadQueryInputs, loadRequest: loadQueryInputs, qualify: qualifyObservation }; +const loadForRequest = (api: SessionServices, root: string, request: CapabilityRequest, selectedObservation?: string) => api.loadRequest ? api.loadRequest(root, request, selectedObservation) : api.load(root, request.target); const emptyInputs: QueryInputs = { ledgers: [], capabilities: [], targetCatalogue: [], targetAssociations: [], bodyMaps: [] }; export function sessionRequest(args: readonly string[]): CapabilityRequest { const request = requestFromArguments(args); @@ -76,7 +86,7 @@ export async function saveSession(root: string, args: readonly string[], directo const path = resolve(directory, 'query.json'); try { await readFile(path); throw new Error(`${path} already exists. Use a new --out directory to preserve its numbered choices.`); } catch (error) { if (!hasErrorCode(error, 'ENOENT')) throw error; } - const answer = queryCapabilities(request, await api.load(root, request.target)); + const answer = queryCapabilities(request, await loadForRequest(api, root, request)); const session: Session = { schema: SESSION_SCHEMA, createdAt: new Date().toISOString(), arguments: args, target: answer.target, choices: observationChoices(answer), answer }; const temporary = `${path}.${randomUUID()}.partial`; try { await writeFile(temporary, `${JSON.stringify(session, null, 2)}\n`); await rename(temporary, path); } @@ -93,7 +103,7 @@ function readSavedChoice(value: unknown, pick: number) { if (!Number.isSafeInteger(pick) || pick < 1 || pick > choices.length) throw new TypeError(`--pick must be between 1 and ${choices.length}.`); const choice = requireRecord(choices[pick - 1], 'saved choice'); if (choice.pick !== pick) throw new TypeError('Saved choice numbering is inconsistent.'); - return { args, target: requireString(session.target, 'saved target'), key: choiceKey({ telescope: requireString(choice.telescope), mode: requireString(choice.mode), + return { args, ...(choice.acquisitionKey === undefined ? {} : { selectedObservation: requireString(choice.observation) }), target: requireString(session.target, 'saved target'), key: choiceKey({ ...(choice.acquisitionKey === undefined ? {} : { acquisitionKey: requireString(choice.acquisitionKey) }), telescope: requireString(choice.telescope), mode: requireString(choice.mode), observation: requireString(choice.observation), program: requireString(choice.program) }) }; } interface FilePin { readonly bytes: number; readonly sha256: string } @@ -108,13 +118,12 @@ function relativeFile(root: string, file: string): string { return path; } async function resolveArtifact(root: string, answer: CapabilityAnswer, choice: Choice): Promise { - const selection = selectObservation(answer, choice.telescope, choice.mode, choice.program); if (!choice.product) throw new Error('The route did not produce a current, selectable artifact.'); // Bind this observation even when a program contains several qualified observations. - const selected = await selectedProductInput(root, { ...selection, product: choice.product }); + const selected = await selectedProductInput(root, { ...selectObservation(answer, choice.telescope, choice.mode, choice.program), product: choice.product }); const q = selected.qualification; return { file: selected.file, receipt: resolve(root, q.receipt), record: resolve(root, q.productRecord), outputRoot: resolve(root, q.outputRoot), facts: selected.facts, - extraEvidence: [...(q.facts.angularResolutionBound ? [resolve(root, q.facts.angularResolutionBound.receipt)] : []), + extraEvidence: [...(q.facts.calibrationDependencies??[]).flatMap(d=>d.file?[resolve(root,d.file)]:[]), ...(q.facts.angularResolutionBound ? [resolve(root, q.facts.angularResolutionBound.receipt)] : []), ...(q.facts.resolutionEvidence ?? []).flatMap(e => e.receipt ? [resolve(root, e.receipt.file)] : [])] }; } @@ -145,11 +154,18 @@ async function exportArtifact(root: string, destination: string, artifact: Artif return { files, record }; } -export async function getSession(root: string, directory: string, pick: number, progress: (text: string) => void = () => {}, api: SessionServices = services) { +export async function getSession(root: string, directory: string, pick: number, progress: (text: string) => void = () => {}, api: SessionServices = services, options: { readonly offline?: boolean } = {}) { return locked(directory, async () => { const saved = readSavedChoice(JSON.parse(await readFile(resolve(directory, 'query.json'), 'utf8')), pick), request = sessionRequest(saved.args); + if (options.offline) { + const resultPath = resolve(directory, `pick-${pick}`, 'result.json'), { delivery } = await import('./outputs.mts'); + const local = await delivery(resultPath), { canonical } = await import('./vo/contracts.mts'); + if (local.record.choice !== saved.key || canonical(local.record.request) !== canonical({ ...request, target: saved.target })) throw new Error('Offline delivery differs from its saved scientific request or choice.'); + progress('Replaying the pinned local delivery; no remote archive was refreshed.'); + return { resultPath, product: local.file, satisfaction: assessRequest({ ...request, target: saved.target }, (await import('./qualified-observations.mts')).parseProductFacts(local.record.facts)), reused: true, replay: 'pinned-local-artifact' as const }; + } progress('Revalidating the saved observation'); - let inputs = await api.load(root, request.target), answer = queryCapabilities(request, inputs); + let inputs = await loadForRequest(api, root, request, saved.selectedObservation), answer = queryCapabilities(request, inputs); if (answer.target !== saved.target) throw new Error('Target identity changed since the saved query. Save a new query.'); let choice = observationChoices(answer).find(entry => choiceKey(entry) === saved.key); if (!choice) throw new Error('The saved observation is no longer available for this request. Save a new query to inspect current blockers.'); @@ -158,7 +174,8 @@ export async function getSession(root: string, directory: string, pick: number, progress(`Qualifying ${choice.telescope} ${choice.mode}: ${choice.observation}`); const qualification = { target: answer.target, telescope: choice.telescope, mode: choice.mode, observation: choice.observation, configuration: choice.configuration }; await locked(resolve(root, 'output/telescopes'), () => api.qualify(root, qualification)); - inputs = await api.load(root, answer.target); answer = queryCapabilities(request, inputs); + inputs = choice.acquisitionKey ? { ...inputs, qualifiedProducts: await loadQualifiedObservations(root, answer.target) } : await loadForRequest(api, root, { ...request, target: answer.target }); + answer = queryCapabilities(request, inputs); choice = observationChoices(answer).find(entry => choiceKey(entry) === saved.key); if (!choice || choice.state !== 'ready') throw new Error('Qualification did not produce a selectable artifact for the original request. Re-query for the current verdict.'); } @@ -171,7 +188,7 @@ export async function getSession(root: string, directory: string, pick: number, throw new Error('Saved result identity differs. Preserve it and use a new query directory.'); const { files, record } = await exportArtifact(root, dirname(resultPath), artifact, progress, true); const product = `files/${relativeFile(root, artifact.file)}`; - const result = { schema: 'cssearth-telescope-delivery@1', choice: saved.key, request: answer.request, observation: choice.observation, product, + const result = { schema: 'cssearth-telescope-delivery@1', choice: saved.key, request: answer.request, observation: choice.observation, product, outputRoot: resolve(root) === resolve(artifact.outputRoot) ? 'files' : `files/${relativeFile(root, artifact.outputRoot)}`, receipt: `files/${relativeFile(root, artifact.receipt)}`, record: `files/${relativeFile(root, artifact.record)}`, facts: artifact.facts, satisfaction, evidence: record.evidence, files, reused: true }; const temporary = `${resultPath}.${randomUUID()}.partial`; @@ -184,7 +201,7 @@ export async function getSession(root: string, directory: string, pick: number, try { const { files, record } = await exportArtifact(root, staging, artifact, progress); const product = `files/${relativeFile(root, artifact.file)}`; - const result = { schema: 'cssearth-telescope-delivery@1', choice: saved.key, request: answer.request, observation: choice.observation, product, + const result = { schema: 'cssearth-telescope-delivery@1', choice: saved.key, request: answer.request, observation: choice.observation, product, outputRoot: resolve(root) === resolve(artifact.outputRoot) ? 'files' : `files/${relativeFile(root, artifact.outputRoot)}`, receipt: `files/${relativeFile(root, artifact.receipt)}`, record: `files/${relativeFile(root, artifact.record)}`, facts: artifact.facts, satisfaction, evidence: record.evidence, files, reused: false }; await writeFile(resolve(staging, 'result.json'), `${JSON.stringify(result, null, 2)}\n`); diff --git a/tools/objects/telescopes/source-intake.mts b/tools/objects/telescopes/source-intake.mts index 8d56f11bb4..a2abaff471 100644 --- a/tools/objects/telescopes/source-intake.mts +++ b/tools/objects/telescopes/source-intake.mts @@ -1,3 +1,4 @@ +import { isisGeometryBands } from './native-metadata.mts'; /** Inventory native products already pinned by a body package. Header reads are discovery only; qualification verifies whole-file pins. */ import { sourceHeaders } from './source-transfer.mts'; import { isis3CoreHeader } from '../terrestrial-layers/isis3-raster.mts'; @@ -7,7 +8,7 @@ import { resolve, dirname, basename } from 'node:path'; import { sha256 } from '../../../src/platform/sha256.mts'; import { requireArray, requireRecord, requireString, requireFiniteNumber, hasErrorCode } from '../../source-values.mts'; import { readFitsHeader } from '../../fits.mts'; -import { pds4ProductIdentity, pds4Blocks, pds4Elements, pds4Field, pds3Keyword, pds3Values } from '../pds-labels.mts'; +import { pds4ProductIdentity, pds4Blocks, pds4Elements, pds4Field, pds3Keyword, pds3Values, pds3TimeIso } from '../pds-labels.mts'; import { inside, type SourceFile, type SourceProduct } from './source-products.mts'; export interface SourceIntakeIssue { readonly path: string; readonly state: 'unavailable' | 'unsupported' | 'incomplete'; readonly reason: string } const LIMIT = 128 * 1024; @@ -53,13 +54,14 @@ export async function intakeSources(root:string,target:string,existing:readonly const telescope=component('Spacecraft')??component('Telescope'),instrument=component('Instrument'); product={id:file.id,target,telescope:telescope?pds4Field(telescope,'name'):'Source archive',mode:`${instrument?pds4Field(instrument,'name'):'PDS4'}/${kind}`,kind,decoder:'pds-product',archiveProductId:`${identity.logical_identifier}::${identity.version_id}`,labelPath:file.path,files:[{...file,role:'label'},...dependencies.map(f=>({...f,role:f.path===science.path?'science':'support'}))],identity,units:'Label-specified units',meaning:'Native PDS4 numeric product with every referenced file pinned.',citation:file.origin,limitations:['Decoding and byte integrity do not establish measurement suitability or surface registration.']}; } else if(/^Object\s*=\s*IsisCube/mu.test(text)) { + if(isisGeometryBands(bytes)){issues.push({path:file.path,state:'unsupported',reason:'Named geometry backplanes are ancillary data, not a science observation.'});continue;} const header=isis3CoreHeader(bytes),kind=header.bands===1?'image':'cube'; const science=header.coreFile?files.find(f=>resolve(f.path).toLowerCase()===resolve(dirname(file.path),header.coreFile!).toLowerCase()):file; if(!science)throw new Error(`Unpinned ISIS Core ${header.coreFile}.`); product={id:file.id,target,telescope:header.identity.SpacecraftName??'Source archive',mode:`${header.identity.InstrumentId??'ISIS3'}/${kind}`,kind,decoder:'isis3',archiveProductId:file.origin,labelPath:file.path,files:science===file?[file]:[{...file,role:'label'},{...science,role:'science'}],identity:header.identity, units:'Native ISIS values',meaning:'Native ISIS3 numeric core; original labels and metadata remain in the pinned file.',citation:file.origin,limitations:['Core decoding does not establish calibrated scientific suitability, achieved resolution or body-map registration.']}; } else if(text.startsWith('SIMPLE =')) { - const header=readFitsHeader(bytes).header, axes=Number(header.NAXIS); + const header=readFitsHeader(bytes).header, rawAxes=Number(header.NAXIS),axes=rawAxes>3?2+Array.from({length:rawAxes-2},(_,i)=>Number(header[`NAXIS${i+3}`])).filter(n=>n!==1).length:rawAxes; if(axes!==2&&axes!==3){issues.push({path:file.path,state:'unsupported',reason:`FITS primary has ${axes} axes; extension-only products need an explicit observation declaration.`});continue;} const identity=Object.fromEntries(['SIMPLE','BITPIX','NAXIS','NAXIS1','NAXIS2','NAXIS3','OBJECT','TELESCOP','INSTRUME','OBS_ID'].filter(key=>header[key]!==undefined).map(key=>[key,header[key]!])) as SourceProduct['identity']; product={id:file.id,target,telescope:String(header.TELESCOP??'Source archive'),mode:String(header.INSTRUME??'FITS')+`/${axes===3?'cube':'image'}`,kind:axes===3?'cube':'image',decoder:'fits-image',archiveProductId:file.origin,files:[file],identity, @@ -74,7 +76,11 @@ export async function intakeSources(root:string,target:string,existing:readonly const dependencies:SourceFile[]=[{...file,role:'label'}]; let science:SourceFile|undefined; for(const key of [...new Set(pointers)]) { const pointer=pds3Values(label,key),name=pointer?.[0];if(!name)throw new Error(`Unreadable ${key} pointer.`); - if(/^\d+(?:\s*)?$/u.test(name)){if(!/HISTORY|HEADER|STRUCTURE/u.test(key))science=file;continue;} + if(/^\d+(?:\s*)?$/u.test(name)){ + const recordBytes=Number(pds3Keyword(label,'RECORD_BYTES',[])??1),offset=(Number(name.split(/\s/u)[0])-1)*(name.includes('')?1:recordBytes); + if(!Number.isSafeInteger(offset)||offset<0||offset>=file.bytes)throw new Error(`Attached ${key} pointer lies outside the pinned file; an extracted label is not the complete observation.`); + if(!/HISTORY|HEADER|STRUCTURE/u.test(key))science=file;continue; + } const wanted=resolve(dirname(file.path),name).toLowerCase(),dependency=files.find(f=>resolve(f.path).toLowerCase()===wanted); if(!dependency)throw new Error(`Unpinned ${key} dependency ${name}.`); if(!dependencies.some(f=>f.path===dependency.path))dependencies.push({...dependency,role:'support'}); @@ -84,9 +90,9 @@ export async function intakeSources(root:string,target:string,existing:readonly const identity=Object.fromEntries(['PDS_VERSION_ID','DATA_SET_ID','PRODUCT_ID','TARGET_NAME','INSTRUMENT_ID'].flatMap(key=>{const v=pds3Keyword(label,key,[]);return v===undefined?[]:[[key,v]];})); if(!identity.PRODUCT_ID)throw new Error('PDS product identity is absent.'); const inputFiles=dependencies.map(f=>({...f,role:f.path===science!.path?'science':f.role})); - const start=pds3Keyword(label,'START_TIME',[]),stop=pds3Keyword(label,'STOP_TIME',[]),iso=(v:string)=>{const doy=/^(\d{4})-(\d{3})T(.*)$/u.exec(v);if(doy){const day=Number(doy[2]);if(day<1||day>366)throw new Error('Invalid day of year');v=`${new Date(Date.UTC(Number(doy[1]),0,day)).toISOString().slice(0,10)}T${doy[3]}`;}return new Date(/[zZ]|[+-]\d\d:\d\d$/u.test(v)?v:`${v}Z`).toISOString();}; + const start=pds3Keyword(label,'START_TIME',[]),stop=pds3Keyword(label,'STOP_TIME',[]); product={id:file.id,target,telescope:pds3Keyword(label,'INSTRUMENT_HOST_NAME',[])??'Source archive',mode:`${pds3Keyword(label,'INSTRUMENT_ID',[])??'PDS3'}/${kind}`,kind,decoder:'pds-product',archiveProductId:`${identity.DATA_SET_ID??''}:${identity.PRODUCT_ID}`,files:inputFiles,identity, - labelPath:file.path,...(start&&stop?{startIso:iso(start),endIso:iso(stop)}:{}),units:pds3Keyword(label,'UNIT',[])??'not stated',meaning:`Native PDS3 ${kind}; preserve every labeled structure, including uncertainty and quality.`,citation:file.origin, + labelPath:file.path,...(start&&stop?{startIso:pds3TimeIso(start),endIso:pds3TimeIso(stop)}:{}),units:pds3Keyword(label,'UNIT',[])??'not stated',meaning:`Native PDS3 ${kind}; preserve every labeled structure, including uncertainty and quality.`,citation:file.origin, limitations:['Header metadata is discovery evidence until complete source pins and decoded structures are checked.','Decoding does not establish scientific suitability, optical resolution or surface registration.']}; } products.push(product);product.files.forEach(f=>used.add(f.path)); diff --git a/tools/objects/telescopes/source-intake.test.mts b/tools/objects/telescopes/source-intake.test.mts index ad5bfb56f3..3b683b3c48 100644 --- a/tools/objects/telescopes/source-intake.test.mts +++ b/tools/objects/telescopes/source-intake.test.mts @@ -9,11 +9,11 @@ const label=(pointer:string)=>`PDS_VERSION_ID = PDS3\nPRODUCT_ID = "TEST"\nDATA_ test('attached labels and detached tables become complete pinned products; unpinned dependencies remain explicit',async()=>{ const root=await mkdtemp(resolve(tmpdir(),'source-intake-')),source=resolve(root,'src/objects/test/source');await mkdir(source,{recursive:true}); try{ - const files=[['image.img',label('^IMAGE = 32\n^SIGMA_MAP_IMAGE = 64\n^QUALITY_MAP_IMAGE = 96')],['table.lbl',label('^TABLE = "data.tab"')],['data.tab','1,2\n'],['bad.lbl',label('^TABLE = "missing.tab"')]]; + const files=[['image.img',label('^IMAGE = 32\n^SIGMA_MAP_IMAGE = 64\n^QUALITY_MAP_IMAGE = 96')],['table.lbl',label('^TABLE = "data.tab"')],['data.tab','1,2\n'],['bad.lbl',label('^TABLE = "missing.tab"')],['extract.lbl',label('RECORD_BYTES = 512\n^IMAGE = 54')]]; const inputs=[];for(const [path,bytes] of files){await writeFile(resolve(source,path!),bytes!);inputs.push({id:path!.replace('.','-'),path,origin:`https://example.org/${path}`,expectedBytes:Buffer.byteLength(bytes!),expectedSha256:sha256(bytes!)});} await writeFile(resolve(source,'manifest.json'),JSON.stringify({inputs}));const issues:SourceIntakeIssue[]=[],products=await intakeSources(root,'test',[],issues); assert.equal(products.length,2);assert.equal(products.find(p=>p.kind==='image')!.labelPath,'src/objects/test/source/image.img');assert.equal(products.find(p=>p.kind==='image')!.files.length,1); - assert.equal(products.find(p=>p.kind==='table')!.kind,'table');assert.equal(products.find(p=>p.kind==='table')!.files.length,2);assert.match(issues[0]!.reason,/Unpinned/); + assert.equal(products.find(p=>p.kind==='table')!.kind,'table');assert.equal(products.find(p=>p.kind==='table')!.files.length,2);assert.ok(issues.some(i=>/Unpinned/.test(i.reason)));assert.ok(issues.some(i=>/outside the pinned/.test(i.reason))); }finally{await rm(root,{recursive:true,force:true});} }); diff --git a/tools/objects/telescopes/source-products.mts b/tools/objects/telescopes/source-products.mts index 8d83ca4db5..9d6b95a9d5 100644 --- a/tools/objects/telescopes/source-products.mts +++ b/tools/objects/telescopes/source-products.mts @@ -8,6 +8,7 @@ import { pinFile, readProductRecord, sameRun, type ProductRecord, type ProductRu import { sourcePds3Observations } from '../pds/source-observations.mts'; import type { ProductKind } from './query.mts'; import { parseProductFacts, type QualifiedObservation } from './qualified-observations.mts'; +import { verifyCalibrationDependencies, type CalibrationDependency } from './calibration-dependencies.mts'; import type { ProductFacts } from './request-satisfaction.mts'; export const SOURCE_PRODUCTS_SCHEMA = 'cssearth-source-observations@1'; @@ -98,12 +99,12 @@ export function parseSourceProducts(value: unknown, manifestValue: unknown, targ }); } export const sourceReceipt = (product: SourceProduct) => `output/telescopes/${product.target}/${product.id}/qualification.product.json`; -export async function sourceRun(product: SourceProduct): Promise { +export async function sourceRun(product: SourceProduct, dependencies: readonly CalibrationDependency[] = []): Promise { assertPinnedLabel(product); - const sources = ['source-intake.mts', 'source-transfer.mts', '../operations-acquisition.ts', '../terrestrial-layers/isis3-raster.mts', 'source-products.mts', 'qualify-source.mts', 'native-metadata.mts', 'qualified-observations.mts', 'request-satisfaction.mts', '../../fits.mts', '../../fits-rice.mts', '../pds3-labels.mts', '../pds/source-observations.mts', '../pds-labels.mts', '../product-record.mts', '../astronomy-packages/pds-client.mts', '../astronomy-packages/pds-toolchain.json']; + const sources = ['source-intake.mts', 'source-transfer.mts', '../operations-acquisition.ts', '../terrestrial-layers/isis3-raster.mts', 'source-products.mts', 'qualify-source.mts', 'native-metadata.mts', 'product-science.mts', 'calibration-dependencies.mts', '../astronomy-packages/science.mts', '../astronomy-packages/requirements.lock', 'qualified-observations.mts', 'request-satisfaction.mts', '../../fits.mts', '../../fits-rice.mts', '../pds3-labels.mts', '../pds/source-observations.mts', '../pds-labels.mts', '../product-record.mts', '../astronomy-packages/pds-client.mts', '../astronomy-packages/pds-toolchain.json']; const digest = createHash('sha256'); for (const path of sources) digest.update(path).update(await readFile(resolve(import.meta.dirname, path))); - return { telescope: product.telescope, stage: 'source-qualification', inputs: product.files.map(file => ({ role: file.role, identity: file.origin, bytes: file.bytes, sha256: file.sha256 })), + return { telescope: product.telescope, stage: 'source-qualification', inputs: product.files.map(file => ({ role: file.role, identity: file.origin, bytes: file.bytes, sha256: file.sha256 })).concat(dependencies.filter(d=>d.status==='pinned').map(d=>({role:'calibration dependency',identity:d.origin!,bytes:d.bytes!,sha256:d.sha256!}))), parameters: { observation: product }, software: [{ name: 'cssEarth source qualification', version: digest.digest('hex') }, { name: 'Node.js', version: process.version }] }; } export async function loadSourceProducts(root: string, target: string, issues: SourceIntakeIssue[] = []): Promise { @@ -128,12 +129,14 @@ export async function loadSourceProducts(root: string, target: string, issues: S let record; try { record = await readProductRecord(resolve(root, receipt)); } catch { loaded.push({ ...product, qualified: false, receipt, receiptProblem: 'Qualification receipt is malformed.' }); continue; } let qualified = false, receiptProblem: string | undefined; + const savedFacts = record ? await readFile(resolve(root, `output/telescopes/${target}/${product.id}/decoded.json`),'utf8').then(t=>parseProductFacts(requireRecord(JSON.parse(t)).facts),()=>undefined).catch(()=>undefined):undefined; if (record) { - qualified = sourceRecordComplete(record, product) && await sameRun(record, await sourceRun(product), path => inside(root, path)); + qualified = sourceRecordComplete(record, product) && await sameRun(record, await sourceRun(product,savedFacts?.calibrationDependencies), path => inside(root, path)); for (const file of product.files) { const pin = await pinFile(inside(root, file.path)).catch(() => null); qualified &&= pin !== null && pin.bytes === file.bytes && pin.sha256 === file.sha256; } + qualified &&= await verifyCalibrationDependencies(root,savedFacts?.calibrationDependencies??[]); if (!qualified) receiptProblem = 'The qualification receipt is stale: inputs, parameters, implementation, runtime or output bytes changed.'; } const facts = qualified ? parseProductFacts(requireRecord(JSON.parse(await readFile(resolve(root, `output/telescopes/${target}/${product.id}/decoded.json`), 'utf8')), 'decoded product').facts) : undefined; diff --git a/tools/objects/telescopes/spatial-handoff.mts b/tools/objects/telescopes/spatial-handoff.mts new file mode 100644 index 0000000000..277282d52d --- /dev/null +++ b/tools/objects/telescopes/spatial-handoff.mts @@ -0,0 +1,57 @@ +/** Export the existing physical point/volume package. This does not infer depth from a spectral cube. */ +import { readFile,writeFile,mkdir,rm,rmdir,rename,realpath } from 'node:fs/promises'; +import { resolve,dirname,relative,isAbsolute } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +import { requireRecord,requireString } from '../../source-values.mts'; +import { sha256 } from '../../../src/platform/sha256.mts'; +import { writeProductRecord,type ProductInput } from '../product-record.mts'; + +export async function exportSpatialObject(objectPath:string,kind:'points'|'volume',outputDirectory:string){ + const source=resolve(objectPath),root=dirname(source),bytes=await readFile(source),descriptor=requireRecord(JSON.parse(bytes.toString())); + if(descriptor.schema!=='cssearth-object@1'||descriptor.type!==(kind==='points'?'point-field':'density-volume'))throw new Error(`${kind} requires an existing physical ${kind==='points'?'point-field':'density-volume'} object.json; a spectral cube does not establish depth`); + const prepared=requireRecord(descriptor.prepared),properties=requireRecord(descriptor.properties),preparation=requireRecord(properties.preparation); + const destination=resolve(outputDirectory),staging=`${destination}.${randomUUID()}.partial`; + const checked=new Map(); + const rootReal=await realpath(root); + async function read(path:string){ + const file=resolve(root,path),lexical=relative(root,file); + if(lexical==='..'||lexical.startsWith('../'))throw new Error('Spatial resource escapes its object package'); + const rel=relative(rootReal,await realpath(file)); + if(isAbsolute(path)||rel==='..'||rel.startsWith('../')||!rel)throw new Error('Spatial resource escapes its object package'); + const bytes=await readFile(file);checked.set(path,{bytes,pin:{role:'physical object input',identity:file,sha256:sha256(bytes),bytes:bytes.length}});return bytes; + } + const recipePath=requireString(preparation.source),recipeBytes=await read(recipePath); + if(sha256(recipeBytes)!==requireString(preparation.sha256))throw new Error('Spatial preparation recipe pin mismatch'); + const recipe=requireRecord(JSON.parse(recipeBytes.toString())); + // Retain the original recipe, credits and licence. Large source datasets are not part of this render handoff. + for(const key of ['provenance','license'])if(recipe[key]){ + const pin=requireRecord(recipe[key]),path=relative(root,resolve(root,dirname(recipePath),requireString(pin.path))); + if(sha256(await read(path))!==requireString(pin.sha256))throw new Error(`Spatial ${key} pin mismatch`); + } + await mkdir(dirname(destination),{recursive:true});await mkdir(destination);await mkdir(staging); + try{ + const entry=new URL(kind==='points'?'../../../src/renderers/css/stars/loader.ts':'../../../src/renderers/css/volume/loader.ts',import.meta.url); + // Use the application's exact loader, including physical-frame and binary-bank validation. + const compiled=await build({entryPoints:[entry.pathname],bundle:true,write:false,platform:'node',format:'esm',packages:'external',metafile:true}); + const moduleFile=resolve(staging,'loader.mjs');await writeFile(moduleFile,compiled.outputFiles[0].text); + const loader: {loadPreparedCssPointField?:typeof import('../../../src/renderers/css/stars/loader.ts').loadPreparedCssPointField;loadPreparedCssVolume?:typeof import('../../../src/renderers/css/volume/loader.ts').loadPreparedCssVolume}=await import(pathToFileURL(moduleFile).href); + const transport={read:async(path:string)=>Uint8Array.from(await read(path)).buffer}; + const payload=kind==='points'?await loader.loadPreparedCssPointField!(descriptor,transport):await loader.loadPreparedCssVolume!(descriptor,transport); + const manifestPath=requireString(prepared.url); + for(const resource of payload.resources){ + const path=relative(root,resolve(root,dirname(manifestPath),resource.path)),bytes=await read(path); + if(bytes.length!==resource.bytes||sha256(bytes)!==resource.sha256)throw new Error(`Spatial resource pin mismatch: ${resource.path}`); + } + await rm(moduleFile); + const outputs=[{path:'object.json',file:resolve(staging,'object.json')}];await writeFile(outputs[0].file,bytes); + for(const [path,item] of checked){const file=resolve(staging,path);await mkdir(dirname(file),{recursive:true});await writeFile(file,item.bytes);outputs.push({path,file});} + for(const item of checked.values())if(sha256(await readFile(item.pin.identity))!==item.pin.sha256)throw new Error('Spatial source changed during export'); + if(sha256(await readFile(source))!==sha256(bytes))throw new Error('Spatial descriptor changed during export'); + const implementation=sha256(Buffer.concat([await readFile(new URL('spatial-handoff.mts',import.meta.url)),...await Promise.all(Object.keys(compiled.metafile.inputs).sort().map(file=>readFile(file)))])); + await writeProductRecord(resolve(staging,'output.product.json'),{telescope:'css.earth physical source package',stage:'telescope-spatial-handoff',inputs:[{role:'physical descriptor',identity:source,sha256:sha256(bytes),bytes:bytes.length},...Array.from(checked.values(),item=>item.pin)],parameters:{kind,target:descriptor.id,frame:payload.frame,provenance:payload.provenance,interpretation:'Existing prepared physical object; no new depth inference, reconstruction or qualification of an observation.',scope:'Portable renderer resources, source recipe and credits; raw source datasets are referenced, not bundled.'},software:[{name:'css.earth existing physical object loader',version:implementation}]},outputs); + await rmdir(destination);await rename(staging,destination); + return {directory:destination,object:resolve(destination,'object.json'),receipt:resolve(destination,'output.product.json'),kind}; + }catch(error){await rm(staging,{recursive:true,force:true});await rmdir(destination).catch(()=>{});throw error;} +} diff --git a/tools/objects/telescopes/sphere-assets.mts b/tools/objects/telescopes/sphere-assets.mts new file mode 100644 index 0000000000..64d3aaabb3 --- /dev/null +++ b/tools/objects/telescopes/sphere-assets.mts @@ -0,0 +1,10 @@ +/** Clear only inactive CSS image bindings; retain every other prepared property. */ +export function clearInactiveImageBindings(properties:readonly T[],excluded:readonly {readonly url:string}[]){ + const urls=new Set(excluded.map(entry=>entry.url)),inactiveImageProperties:string[]=[]; + const result=properties.map(property=>{ + const value=property.value.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/g,(match,_quote,url:string)=>urls.has(url.trim())?'none':match); + if(value===property.value)return property; + inactiveImageProperties.push(property.name);return {...property,value}; + }); + return {properties:result,inactiveImageProperties}; +} diff --git a/tools/objects/telescopes/sphere-lane.mts b/tools/objects/telescopes/sphere-lane.mts new file mode 100644 index 0000000000..ccf6081876 --- /dev/null +++ b/tools/objects/telescopes/sphere-lane.mts @@ -0,0 +1,132 @@ +/** A measurement lens for an existing standard sphere. No geometry or camera is authored here. */ +import { readFile, mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import sharp from 'sharp'; +import { clearInactiveImageBindings } from './sphere-assets.mts'; +import { preparedAssets } from '../../runtime-assets.mts'; +import { installRuntimeAssets } from '../../setup.mts'; +import { requireRecord } from '../../source-values.mts'; +import { sha256 } from '../../../src/platform/sha256.mts'; +import { parseRasterRecipe, prepareRasterAssets } from '../../../src/preparation/raster/index.ts'; +import { parsePreparedObjectRuntime } from '../../../src/renderers/css/validation/index.ts'; +import { parseGeometryProfile } from '../../../src/renderers/css/preparation/scene/profile.ts'; +import { prepareScientificNavigation } from '../terrestrial-layers/scientific-focus.mts'; +import { parsePreparedWorldContext } from '../../../src/renderers/css/universe/prepared-world-context.ts'; + +export async function measurementSphere(root: string, target: string, texture: string, output: string, + focus: { longitudeDegrees: number; latitudeDegrees: number; zoom: number }) { + const id = target.toLowerCase(); + if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('Sphere output needs an existing body identity'); + const object = resolve(root, 'src/objects', id); + const inputs: { role: string; identity: string; sha256: string; bytes: number }[] = []; + const pinned = async (file: string) => { + const bytes = await readFile(file); + inputs.push({ role: 'standard sphere input', identity: file, sha256: sha256(bytes), bytes: bytes.length }); + return bytes; + }; + const json = async (file: string): Promise => JSON.parse((await pinned(file)).toString()); + await pinned(resolve(root, 'pnpm-lock.yaml')); + const geometry = parseGeometryProfile(await json(resolve(object, 'source/preparation/geometry.json'))); + if (geometry.namespace !== id) throw new Error('Standard sphere geometry belongs to another body'); + const recipe = parseRasterRecipe(await json(resolve(object, 'source/preparation/raster.json'))); + await pinned(resolve(object, 'prepared-assets.json')); + const prepared = await preparedAssets(root, [id]); + await installRuntimeAssets(prepared.filter(asset => asset.filename === 'runtime.json')); + const original = parsePreparedObjectRuntime(await json(resolve(object, 'prepared/runtime.json'))); + if (original.id !== id || original.pageLayers?.length || original.destinations) + throw new Error('This sphere requires application capabilities that cannot be exported'); + const lensId = original.controls.lenses?.defaultLens; + const surface = recipe.surfaces.find(item => item.id === lensId); + if (!lensId || !surface) throw new Error('Standard sphere has no matching prepared surface lens'); + // Keep the original packing, gutters, pole atlas and density. The standard raster lane + // receives already projected colours and uses nearest/lossless handling for measurements. + const rasterDirectory = resolve(output, 'raster'); + await mkdir(rasterDirectory, { recursive: true }); + // Mercury's combined pole bank reads the unpacked source at its authored + // dimensions. Keep that source-packed route instead of inventing a pole atlas. + let rasterSource = texture; + if (recipe.resample === 'source-packed') { + if (recipe.sourceWidth !== recipe.width * 2 || recipe.sourceHeight !== recipe.height * 2 || + recipe.surfaces.some(item => (item.resolutionScale ?? 1) !== 1)) + throw new Error('Source-packed measurement requires matching canonical source dimensions'); + rasterSource = resolve(rasterDirectory, 'measurement-source.png'); + await sharp(texture).resize(recipe.sourceWidth, recipe.sourceHeight, { fit: 'fill', kernel: 'nearest' }).png().toFile(rasterSource); + } + const surfaces = (recipe.polesCombined ? recipe.surfaces : [surface]).map(item => ({ + id: item.id, source: rasterSource, falseColor: true, output: item.output.replace(/\.jpe?g$/, '.webp'), + thumbnail: item.thumbnail, ...(item.resolutionScale ? { resolutionScale: item.resolutionScale } : {}), + science: { kind: 'projected-measurement' }, + })); + const { lighting: _lighting, atmosphere: _atmosphere, interior: _interior, emission: _emission, ...layout } = recipe; + const assets = await prepareRasterAssets({ sourceDirectory: '/', publicDirectory: rasterDirectory, + outputDirectory: rasterDirectory, config: { ...layout, surfaces }, + interpret: async (_source, width, height) => ({ + data: await sharp(texture).resize(width, height, { fit: 'fill', kernel: 'nearest' }).ensureAlpha().raw().toBuffer(), + channels: 4, nearest: true, + }), + }); + const selected = assets.surfaces[lensId]; + const poles = recipe.polesOutput.replaceAll('{id}', lensId).replaceAll('{suffix}', '@2x').replaceAll('{density}', '2'); + const surfaceFile = resolve(rasterDirectory, surfaces.find(item => item.id === lensId)!.output.replaceAll('{id}', lensId).replaceAll('{suffix}', '@2x').replaceAll('{density}', '2')); + const dataUrl = async (file: string) => 'data:image/webp;base64,' + (await pinned(file)).toString('base64'); + const replacement = new Map([ + [`surface:${lensId}`, await dataUrl(surfaceFile)], + [`poles:${lensId}`, await dataUrl(resolve(rasterDirectory, poles))], + // Mercury's row-bank presentation shares a combined pole atlas across lenses. + ['poles', await dataUrl(resolve(rasterDirectory, poles))], + ]); + const variant = original.variants.find(item => item.when.lensId === lensId && item.when.shadows !== true && item.when.atmosphere !== true); + if (!variant) throw new Error('Standard sphere has no unshadowed surface variant'); + // Preserve the exact prepared tree, facing/depth bindings and camera. Quantitative colour + // must not be multiplied by the photographic lighting plane, even at full phase. + const required = variant.required.filter(key => replacement.has(key)); + if (!required.some(key => key.startsWith('surface:'))) throw new Error('Sphere surface binding is unavailable'); + // Inactive lenses retain image-valued custom properties in the shared tree. + // Clear only bindings to excluded assets; node identity and geometry stay intact. + const excluded = original.assets.entries.filter(entry => !required.includes(entry.key)); + const { properties: portableProperties, inactiveImageProperties } = clearInactiveImageBindings(original.tree.properties, excluded); + const tree = { ...original.tree, properties: portableProperties }; + const navigation = prepareScientificNavigation(id, focus, original.camera); + // Apply the measurement view immediately, keeping the viewport's fitted zoom. + const { features: _features, ...portable } = original; + const definition = parsePreparedObjectRuntime({ ...portable, tree, + controls: { lenses: { defaultLens: lensId, controls: [{ id: lensId, label: 'Measurement' }] }, settings: null }, + materials: [], motion: [], animations: [], + variants: [{ ...variant, when: { lensId }, required, materials: [], + navigation: { ...navigation, camera: { ...navigation.camera, transition: { durationMilliseconds: 0, preserveZoom: true } } }, + writes: [...variant.writes, ...original.materials.map(track => ({ kind: 'style', target: track.target, name: 'visibility', value: 'hidden' }))] }], + assets: { ...original.assets, entries: original.assets.entries.filter(entry => required.includes(entry.key)), startup: required }, + }); + // Prepared properties may address the old surface URL directly. Replace only the + // selected lens's URLs; the geometry, dimensions and texture coordinates remain exact. + const oldToNew = new Map(original.assets.entries.filter(entry => replacement.has(entry.key)).map(entry => [entry.url, replacement.get(entry.key)!])); + const styles = await Promise.all(['src/renderers/css/styles/planet-surfaces.css', 'site/planet-shell.css'].map(file => pinned(resolve(root, file)))); + // Mercury is fully styled by planet-surfaces.css. Bodies with extra scientific + // lenses may also have a body-owned binding sheet. + try { styles.push(await pinned(resolve(root, `src/renderers/css/styles/${id}-surfaces.css`))); } + catch(error) { if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') throw error; } + let css = styles.map(bytes => bytes.toString()).join('\n'); + // Shared styles also contain other bodies/lenses. None is reachable in this + // one-lens document, and none may trigger a network request from the export. + css = css.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/g, (_match, _quote, url: string) => { + const embedded = oldToNew.get(url.trim()); + return embedded ? `url("${embedded}")` : 'none'; + }); + const descriptor = requireRecord(await json(resolve(object, 'object.json'))); + const properties = requireRecord(descriptor.properties); + const worldFrame = properties.worldFrame; + if (!worldFrame) throw new Error('Standard sphere has no prepared physical frame'); + const context = parsePreparedWorldContext(await json(resolve(root, 'src/objects/sun/prepared/world-context.json'))); + // Only the selected lens is reachable. Reject an asset dependency instead of allowing + // the supposedly portable HTML to quietly fetch a different scientific image. + const serialized = JSON.stringify(definition); + for (const entry of original.assets.entries) { + if (!oldToNew.has(entry.url) && serialized.includes(entry.url)) throw new Error(`Unembedded standard sphere asset: ${entry.key}`); + } + return { definition, worldFrame, context, css, inputs, embeddedAssets: Object.fromEntries(oldToNew), owner: { + object: id, runtimeSha256: inputs.find(input => input.identity.endsWith('/prepared/runtime.json'))!.sha256, + treeSha256: sha256(JSON.stringify(original.tree)), geometry: 'existing standard sphere; unchanged nodes and geometry; inactive image bindings cleared', inactiveImageProperties, + physicalFrame: worldFrame, projectionShape: 'standard reference sphere', surfaceUrl: selected.url, + rasterToolchain: { node: process.version, sharp: sharp.versions }, + } }; +} diff --git a/tools/objects/telescopes/sphere-oracle.mts b/tools/objects/telescopes/sphere-oracle.mts new file mode 100644 index 0000000000..73040114cf --- /dev/null +++ b/tools/objects/telescopes/sphere-oracle.mts @@ -0,0 +1,113 @@ +/** Independent analytic ray/ellipsoid reference; no PolyCSS geometry is used to draw it. */ +import { execFileSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { sha256 } from '../../../src/platform/sha256.mts'; +import { requireRecord,requireArray } from '../../source-values.mts'; +import { readFile,mkdir,writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { astroqueryToolchain } from '../astronomy-packages/toolchain.mts'; +import { verifiedProduct } from './projection.mts'; +import { parseHTML } from 'linkedom'; +import { parsePreparedObjectRuntime,createWorldContextObjectRuntime } from '../../../src/renderers/css/dist/index.js'; +import { parsePreparedWorldCameraFrame } from '../../../src/renderers/css/dist/navigation.js'; +import { createObjectControlBinding,initialObjectSelection } from '../../../src/renderers/css/dist/testing.js'; +import type { ObjectAction } from '../../../src/renderers/css/runtime/object-contract.ts'; +export const ORACLE_PYTHON=String.raw` +import json,sys,re +from pathlib import Path +import numpy as np +from astropy.io import fits +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +r=json.load(sys.stdin);out=Path(r['out']);html=Path(r['html']).read_text() +prepared=json.loads(re.search(r'',html).group(1)) +product=json.loads(Path(r['map'],'map.fits.body-map.json').read_text()) +nav=json.loads(Path(r['map'],'navigation.json').read_text());a,b,c=nav['radiiKm']; radii=np.array([a,b,c]);scale=240/a +# Orthographic ray/ellipsoid intersection gives an independently drawn reference. +lon,lat=np.radians([(360-product['observations'][0]['subObserver']['westLongitudeDegrees'])%360,product['observations'][0]['subObserver']['latitudeDegrees']]);east=np.array([-np.sin(lon),np.cos(lon),0]);north=np.array([-np.sin(lat)*np.cos(lon),-np.sin(lat)*np.sin(lon),np.cos(lat)]);eye=np.cross(east,north) +n=600;lim=max(radii)*1.05;coord=(np.arange(n)+.5)/n*2*lim-lim;x,y=np.meshgrid(coord,coord[::-1]);q=x[...,None]*east+y[...,None]*north +A=np.sum((eye/radii)**2);B=2*np.sum(q*eye/radii**2,axis=-1);C=np.sum((q/radii)**2,axis=-1)-1;discriminant=B*B-4*A*C;hit=discriminant>=0 +z=(-B+np.sqrt(np.maximum(discriminant,0)))/(2*A);p=q+z[...,None]*eye +lon=np.mod(np.degrees(np.arctan2(p[:,:,1],p[:,:,0])),360);lat=np.degrees(np.arctan2(p[:,:,2],np.hypot(p[:,:,0],p[:,:,1]))) +with fits.open(Path(r['map'],'map.fits')) as f:values=np.asarray(f['VALUE'].data);errors=np.asarray(f['SIGMA'].data);emission=np.asarray(f['EMISSION'].data) +assert np.array_equal(np.isfinite(values),np.isfinite(errors));assert np.nanmax(emission[np.isfinite(values)])<=product['mask']['maximumEmissionDegrees'] +with fits.open(r['measurement']) as f: + original=f[0].data;uncertainty=f['ERR'].data + pairs=set(zip(original[np.isfinite(original)].tolist(),uncertainty[np.isfinite(original)].tolist())) + assert all(pair in pairs for pair in zip(values[np.isfinite(values)].tolist(),errors[np.isfinite(values)].tolist())) +h,w=values.shape;row=np.clip(((90-lat)*h/180).astype(int),0,h-1);col=np.mod((lon*w/360).astype(int),w) +cmap=matplotlib.colormaps['viridis'].copy();cmap.set_bad('#333941');norm=plt.Normalize(nav['normalization']['minimum'],nav['normalization']['maximum']);rgba=cmap(norm(np.ma.masked_invalid(values[row,col])));rgba[~hit]=[24/255,27/255,31/255,1] +plt.rcParams.update({'figure.facecolor':'#181b1f','axes.facecolor':'#181b1f','text.color':'#d5d7dc','axes.labelcolor':'#d5d7dc','xtick.color':'#b8bbc4','ytick.color':'#b8bbc4'}) +fig,ax=plt.subplots(figsize=(7,7),layout='constrained');ax.imshow(rgba,extent=(-lim,lim,-lim,lim));ax.set(xlabel='East (km)',ylabel='North (km)',title=product['frame']['body']+' · independent sphere reference\n'+product['definition']['quantity']+'; grey = unobserved');fig.colorbar(matplotlib.cm.ScalarMappable(norm=norm,cmap=cmap),ax=ax,shrink=.7,label=product['definition']['units']);fig.savefig(out/'sphere-reference.png',dpi=160);plt.close(fig) +json.dump({'oracle':'Analytic orthographic ray intersection with pinned triaxial ellipsoid; NumPy / Matplotlib','purpose':'independent projection reference; the HTML uses the existing standard sphere, not this ellipsoid renderer','samplePairsPreserved':True,'emissionMaskVerified':True,'pixelComparison':'not run: reference is not an HTML screenshot','versions':{'numpy':np.__version__,'matplotlib':matplotlib.__version__}},sys.stdout) +`; +export async function sphereOracle(mapRecord:string,sphereRecord:string,measurement:string,out:string){ + const map=await verifiedProduct(mapRecord),sphere=await verifiedProduct(sphereRecord),tc=await astroqueryToolchain();await mkdir(out,{recursive:true}); + const html=await readFile(resolve(sphere.root,'sphere.html'),'utf8'); + const embedded=/`; + await writeFile(resolve(staging,'sphere.html'),html); + const fresh=await verifiedProduct(source.file);if(fresh.pin.sha256!==source.pin.sha256)throw new Error('Body map changed during sphere preparation'); + const files=[...new Set([...Object.keys(compiled.metafile.inputs),...Object.keys(runtime.metafile.inputs)])].filter(p=>!p.startsWith('<')); + const implementation=sha256(Buffer.concat([await readFile(new URL('sphere.mts',import.meta.url)),...await Promise.all(files.sort().map(path=>readFile(resolve(root,path))))])); + await writeProductRecord(resolve(staging,'sphere.product.json'),{telescope:source.record.telescope,stage:'telescope-sphere',inputs:[{role:'body-map record',identity:source.file,...source.pin},...prepared.inputs.filter(input=>!input.identity.startsWith(staging)),...source.record.outputs.map(o=>({role:'body-map output',identity:localOutput(source.root,o.path),sha256:o.sha256,bytes:o.bytes}))],parameters:metadata,software:[{name:'cssEarth / PolyCSS prepared sphere',version:implementation}]},[{path:'sphere.html',file:resolve(staging,'sphere.html')}]); + await rm(moduleFile);await rm(resolve(staging,'raster'),{recursive:true}); + await rmdir(destination);await rename(staging,destination); + return {directory:destination,html:resolve(destination,'sphere.html'),receipt:resolve(destination,'sphere.product.json')}; + }catch(error){await rm(staging,{recursive:true,force:true});await rmdir(destination).catch(()=>{});throw error;} +} diff --git a/tools/objects/telescopes/survey-delivery.mts b/tools/objects/telescopes/survey-delivery.mts new file mode 100644 index 0000000000..c8b50a025d --- /dev/null +++ b/tools/objects/telescopes/survey-delivery.mts @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { createHash, randomBytes } from 'node:crypto'; +/** Repeatable local-observation survey through the public saved-query/delivery API. No claim of fresh archive discovery. */ +import { readFile, writeFile, mkdir, access, readdir, stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { requireRecord, requireString } from '../../source-values.mts'; +import { loadSourceProducts, type LoadedSourceProduct } from './source-products.mts'; +import { loadQualifiedObservations } from './qualified-observations.mts'; +import { qualifySourceProduct } from './qualify-source.mts'; +import { saveSession, getSession, type SessionServices } from './session.mts'; +import { requestFromArguments, type QueryInputs } from './query.mts'; +import { assessRequest } from './request-satisfaction.mts'; +export function shuffled(items:readonly T[],seed:string):T[]{ + let state=createHash('sha256').update(seed).digest().readUInt32LE();const result=[...items]; + for(let i=result.length-1;i>0;i--){state=(Math.imul(state,1664525)+1013904223)>>>0;const j=Math.floor(state/4294967296*(i+1));[result[i],result[j]]=[result[j]!,result[i]!];} + return result; +} +/** Uniform body sampling from the recorded, locally available numeric-product pool. No success filter. */ +export async function localTargetPool(root:string,maxBytes=256_000_000):Promise{ + const pool:string[]=[]; + for(const target of (await readdir(resolve(root,'src/objects'))).sort()){ + const dir=resolve(root,'src/objects',target,'source'); + const manifest=await readFile(resolve(dir,'manifest.json'),'utf8').then(t=>requireRecord(JSON.parse(t)),()=>undefined);if(!manifest)continue; + const entries=[...Array.isArray(manifest.inputs)?manifest.inputs:[],...Array.isArray(manifest.generatedIntermediates)?manifest.generatedIntermediates:[]]; + for(const raw of entries){const entry=requireRecord(raw),path=requireString(entry.path); + if(!/\.(fits?|img|cub|qub)$/iu.test(path)||path.split('/').includes('..'))continue; + const info=await stat(resolve(dir,path)).catch(()=>undefined); + if(info?.isFile()&&info.size>0&&info.size<=maxBytes){pool.push(target);break;} + } + } + return pool; +} +export async function surveyDeliveries(root:string,targets:readonly string[],output:string,maxBytes=256_000_000,seed=randomBytes(16).toString('hex'),targetPool:readonly string[]=targets,requestedCount=targets.length){ + await mkdir(output,{recursive:true});const results:Record[]=[]; + for(const target of targets){ + if(!/^[a-z0-9][a-z0-9-]*$/u.test(target))throw new Error('Invalid survey target'); + let selected:LoadedSourceProduct|undefined,requestArgs:string[]|undefined; + try{ + const descriptor=requireRecord(JSON.parse(await readFile(resolve(root,'src/objects',target,'object.json'),'utf8'))),catalog=requireRecord(requireRecord(descriptor.properties).catalog); + const catalogue=[{id:requireString(descriptor.id),name:requireString(catalog.name),aliases:[]}]; + const sources=await loadSourceProducts(root,target); + for(const source of shuffled(sources.filter(s=>s.kind==='cube'||s.kind==='image').sort((a,b)=>a.id.localeCompare(b.id)),`${seed}:${target}`)){ + if(source.files.reduce((s,f)=>s+f.bytes,0)>maxBytes)continue; + if((await Promise.all(source.files.map(f=>access(resolve(root,f.path)).then(()=>true,()=>false)))).every(Boolean)){selected=source;break;} + } + if(!selected){results.push({target,state:'blocked',reason:'No locally available supported source product within the explicit size budget.',discoveredSources:sources.length});continue;} + const product=selected; + const load=async():Promise=>({ledgers:[],capabilities:[],targetAssociations:[],bodyMaps:[],targetCatalogue:catalogue,sourceProducts:await loadSourceProducts(root,target),qualifiedProducts:await loadQualifiedObservations(root,target)}); + const api:SessionServices={load,qualify:async(_root,request)=>{ + if(request.configuration.kind!=='source-product'||request.configuration.id!==product.id)throw new Error('Survey selected another route'); + const {qualified,receipt,receiptProblem,facts,...source}=product;return qualifySourceProduct(root,source); + }}; + const band=product.wavelengthIntervalsMicrometres?.[0]??(product.centralWavelengthMicrometres?[product.centralWavelengthMicrometres*.99,product.centralWavelengthMicrometres*1.01]:product.kind==='cube'?[1,5]:[.4,.9]); + const dir=resolve(output,target),args=['--target',target,'--wavelength',band.join(','),'--kind',product.kind,'--any-time','--min-arcsec','1','--result','telescope-product']; + requestArgs=args; + const session=await saveSession(root,args,dir,api),choice=session.choices.find(c=>c.observation===product.id); + if(!choice)throw new Error('Indexed source has no saved-query choice'); + const result=await getSession(root,dir,choice.pick,()=>{},api); + const delivery=requireRecord(JSON.parse(await readFile(result.resultPath,'utf8'))),facts=requireRecord(delivery.facts); + results.push({target,state:'delivered',product:product.id,decoder:product.decoder,result:result.resultPath,satisfaction:result.satisfaction,metadata:facts.nativeMetadata,calibrationDependencies:facts.calibrationDependencies}); + }catch(error){ + // A newly established contradiction is a scientific refusal, not a decoder crash. + const product=selected&&requestArgs?(await loadSourceProducts(root,target).catch(()=>[])).find(p=>p.id===selected!.id):undefined; + const satisfaction=product?.facts&&requestArgs?assessRequest(requestFromArguments(requestArgs),product.facts):undefined; + results.push({target,state:satisfaction?.status==='refused'?'refused':'failed',product:selected?.id,error:String(error),...(satisfaction?{satisfaction}:{})}); + } + finally{await writeFile(resolve(output,'survey.json'),JSON.stringify({seed,targetPool,targets,requestedCount,scope:'existing local observations; public query -> qualification -> saved delivery; no new archive discovery',maxInputBytes:maxBytes,results},null,2));process.stderr.write(`${target}: ${results.at(-1)?.state}\n`);} + } + return results; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){ + const [out,...args]=process.argv.slice(2);if(!out||!args.length)throw new Error('Usage: survey-delivery.mts OUTPUT_DIRECTORY --random COUNT [SEED] | TARGET ...'); + const seed=args[0]==='--random'?(args[2]??randomBytes(16).toString('hex')):randomBytes(16).toString('hex'); + const pool=args[0]==='--random'?await localTargetPool(process.cwd()):args; + const count=args[0]==='--random'?Number(args[1]):pool.length;if(!Number.isSafeInteger(count)||count<1)throw new Error('Count must be a positive integer'); + const targets=args[0]==='--random'?shuffled(pool,seed).slice(0,count):args; + process.stderr.write(`seed=${seed}; requested=${count}; available=${pool.length}; targets=${targets.join(',')}\n`); + console.log(JSON.stringify((await surveyDeliveries(process.cwd(),targets,resolve(out),256_000_000,seed,pool,count)).map(r=>({target:r.target,state:r.state,error:r.error})),null,2)); +} diff --git a/tools/objects/telescopes/targets.mts b/tools/objects/telescopes/targets.mts index d87f5c2199..ead6bb86ca 100644 --- a/tools/objects/telescopes/targets.mts +++ b/tools/objects/telescopes/targets.mts @@ -1,4 +1,6 @@ export interface TargetCatalogueEntry { + readonly archiveClass?: string; + readonly classificationSource?: string; readonly id: string; readonly name: string; readonly aliases: readonly string[]; @@ -6,6 +8,7 @@ export interface TargetCatalogueEntry { export type TargetResolution = | { readonly status: 'resolved'; readonly requested: string; readonly canonical: { readonly id: string; readonly name: string }; readonly matchedBy: 'id' | 'name' | 'alias' } + | { readonly status: 'ambiguous'; readonly requested: string; readonly candidates: readonly { readonly id: string; readonly name: string; readonly matchedBy: 'id' | 'name' | 'alias' }[] } | { readonly status: 'unknown'; readonly requested: string; readonly suggestions: readonly { readonly id: string; readonly name: string; readonly distance: number }[] }; const normalized = (value: string): string => value.normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z0-9]+/gu, ''); @@ -26,13 +29,23 @@ function editDistance(left: string, right: string): number { * telescope query never silently changes its scientific target. */ export function resolveTarget(requested: string, catalogue: readonly TargetCatalogueEntry[]): TargetResolution { const query = normalized(requested); + const idMatches = catalogue.filter(entry => entry.id === requested); + if (idMatches.length === 1) { + const entry = idMatches[0]!; + return { status: 'resolved', requested, canonical: { id: entry.id, name: entry.name }, matchedBy: 'id' }; + } + const matches: { readonly id: string; readonly name: string; readonly matchedBy: 'id' | 'name' | 'alias' }[] = []; for (const entry of catalogue) { - if (entry.id === requested) return { status: 'resolved', requested, canonical: { id: entry.id, name: entry.name }, matchedBy: 'id' }; const values = [{ value: entry.name, matchedBy: 'name' as const }, { value: entry.id, matchedBy: 'id' as const }, ...entry.aliases.map(value => ({ value, matchedBy: 'alias' as const }))]; const exact = values.find(candidate => normalized(candidate.value) === query); - if (exact) return { status: 'resolved', requested, canonical: { id: entry.id, name: entry.name }, matchedBy: exact.matchedBy }; + if (exact && !matches.some(match => match.id === entry.id)) matches.push({ id: entry.id, name: entry.name, matchedBy: exact.matchedBy }); + } + if (matches.length === 1) { + const match = matches[0]!; + return { status: 'resolved', requested, canonical: { id: match.id, name: match.name }, matchedBy: match.matchedBy }; } + if (matches.length > 1) return { status: 'ambiguous', requested, candidates: matches.sort((a, b) => a.id.localeCompare(b.id)) }; const suggestions = catalogue.map(entry => ({ id: entry.id, name: entry.name, distance: Math.min(...[entry.id, entry.name, ...entry.aliases].map(value => editDistance(query, normalized(value)))) })) .filter(entry => entry.distance <= Math.max(2, Math.floor(query.length / 3))) diff --git a/tools/objects/telescopes/targets.test.mts b/tools/objects/telescopes/targets.test.mts new file mode 100644 index 0000000000..f7fd47ca35 --- /dev/null +++ b/tools/objects/telescopes/targets.test.mts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveTarget } from './targets.mts'; + +const catalogue = [{ id: 'emilylakdawalla', name: 'Emilylakdawalla', aliases: ['274860', '2009 RE26', '(274860) Emilylakdawalla'] }]; + +test('resolves explicit numbered and provisional designations in normalized forms', () => { + for (const requested of ['emilylakdawalla', '274860', '2009 RE26', '2009RE26', '(274860) Emilylakdawalla']) { + const resolution = resolveTarget(requested, catalogue); + assert.deepEqual(resolution.status === 'resolved' ? resolution.canonical : resolution, { id: 'emilylakdawalla', name: 'Emilylakdawalla' }); + } +}); + +test('does not choose a catalogue-order winner for colliding designations', () => { + const resolution = resolveTarget('2009 RE26', [...catalogue, { id: 'different-object', name: 'Different object', aliases: ['2009RE26'] }]); + assert.deepEqual(resolution, { status: 'ambiguous', requested: '2009 RE26', candidates: [ + { id: 'different-object', name: 'Different object', matchedBy: 'alias' }, + { id: 'emilylakdawalla', name: 'Emilylakdawalla', matchedBy: 'alias' }, + ] }); +}); diff --git a/tools/objects/telescopes/vo/access-standards.test.mts b/tools/objects/telescopes/vo/access-standards.test.mts new file mode 100644 index 0000000000..7a1af6c07f --- /dev/null +++ b/tools/objects/telescopes/vo/access-standards.test.mts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { planAccess, sodaParameters, type MetadataLoader } from './access.mts'; +import type { CapabilityRequest } from '../query.mts'; +import { jsonValue, parseLimits, type DiscoverySnapshot, type Json, type MetadataResponse, type Resource } from './contracts.mts'; +import { normalizeSnapshot, SERVICES } from './discovery.mts'; + +const fixtures = resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo'); +const target = { id: 'betelgeuse', names: ['Betelgeuse'], classification: 'star', classificationSource: 'fixture catalogue' }; +const request: CapabilityRequest = { target: target.id, wavelengthMicrometres: [0.78, 0.85], kind: 'image', result: 'telescope-product' }; +const circle = { frame: 'icrs' as const, shape: 'circle' as const, raDegrees: 88.792938, decDegrees: 7.407063, radiusDegrees: 0.01 }; + +async function source(): Promise<{ readonly snapshot: DiscoverySnapshot; readonly observation: ReturnType[number] }> { + const response = (await astroquery({ operation: 'vo-parse', file: resolve(fixtures, 'eso-obscore.xml'), url: SERVICES[0]!.service, byteLimit: 1e6, timeFormat: 'mjd', timeScale: 'utc' })).vo!; + const snapshot: DiscoverySnapshot = { schema: 'cssearth-vo-discovery@1', service: SERVICES[0]!.service, table: SERVICES[0]!.table, model: SERVICES[0]!.model, + request: jsonValue(request), query: 'fixture', scope: 'bounded fixture', sampleLimit: 1, response, completeness: 'bounded-sample' }; + const observation = normalizeSnapshot(snapshot, SERVICES[0]!, target, [target])[0]!; + return { snapshot, observation: { ...observation, kind: 'image', target: { status: 'confirmed', target: target.id, reason: 'fixture' }, + access: { url: 'https://example.org/root-links', mime: 'application/x-votable+xml; content=datalink', estimatedKilobytes: null } } }; +} + +function parameter(name: string, datatype: string, arraysize: string | null, unit: string | null, ucd: string | null, value: Json = null) { + return { name, id: null, datatype, arraysize, unit, ucd, utype: null, xtype: null, ref: null, value }; +} +function descriptor(id: string, standardID: string): Resource { + return { id, type: 'meta', utype: 'adhoc:service', parameters: [parameter('standardID', 'char', '*', null, null, standardID)], groups: [] }; +} +function response(base: MetadataResponse, name: string, rows: readonly Readonly>[], resources: readonly Resource[] = [], bindings: MetadataResponse['bindings'] = []): MetadataResponse { + return { ...base, raw: { path: `/fixture/${name}.xml`, bytes: name.length, sha256: name.padEnd(64, '0').slice(0, 64) }, effectiveUrl: `https://example.org/${name}`, + rows, resources, bindings, times: rows.map(() => ({})) }; +} + +test('SODA accepts only normative parameter triples, apart from the pinned ESO legacy ID declaration', () => { + // SODA 1.0 §3.1: https://www.ivoa.net/documents/SODA/20170517/REC-SODA-1.0.html + const base: Resource = { id: 'soda', type: 'meta', utype: 'adhoc:service', parameters: [parameter('standardID', 'char', '*', null, null, 'ivo://ivoa.net/std/SODA#sync-1.0')], groups: [{ name: 'inputParams', parameters: [ + parameter('ID', 'char', '*', null, 'meta.ref.url;meta.curation'), { ...parameter('BAND', 'double', '2', 'm', 'em.wl;stat.interval'), xtype: 'interval' }, + ] }] }; + const band = sodaParameters(base, { ...request, spectralFrame: 'barycentric' }, { ID: 'ivo://example.org/product' }).BAND; + assert.ok(Array.isArray(band)); assert.ok(Math.abs(Number(band[0]) - 7.8e-7) < 1e-20); assert.ok(Math.abs(Number(band[1]) - 8.5e-7) < 1e-20); + for (const changed of [ + { ...base, groups: [{ ...base.groups[0]!, parameters: [{ ...base.groups[0]!.parameters[0]!, ucd: 'meta.id;meta.main' }, base.groups[0]!.parameters[1]!] }] }, + { ...base, groups: [{ ...base.groups[0]!, parameters: [base.groups[0]!.parameters[0]!, { ...base.groups[0]!.parameters[1]!, datatype: 'float' }] }] }, + { ...base, groups: [{ ...base.groups[0]!, parameters: [base.groups[0]!.parameters[0]!, { ...base.groups[0]!.parameters[1]!, ucd: 'em.wl' }] }] }, + ]) assert.throws(() => sodaParameters(changed, { ...request, spectralFrame: 'barycentric' }, { ID: 'ivo://example.org/product' })); + const eso = { ...base, parameters: [...base.parameters, parameter('accessURL', 'char', '*', null, 'meta.ref.url', 'https://dataportal.eso.org/dataPortal/soda/sync')], groups: [{ ...base.groups[0]!, parameters: [ + { ...base.groups[0]!.parameters[0]!, ucd: 'meta.id;meta.dataset', value: 'ivo://eso.org/ID?ADP.fixture' }, base.groups[0]!.parameters[1]!, + ] }] }; + assert.ok(sodaParameters(eso, { ...request, spectralFrame: 'barycentric' }, { ID: 'ivo://eso.org/ID?ADP.fixture' }).BAND); +}); + +test('DataLink descriptors recurse with PyVO-bound parameters, while direct links remain supported', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-access-standards-')); + try { + const { snapshot, observation } = await source(), base = snapshot.response; + const rootLinks = response(base, 'root', [ + { semantics: '#this', service_def: 'one' }, { semantics: '#this', service_def: 'two' }, { semantics: '#this', service_def: 'malformed' }, + { semantics: '#this', access_url: '/direct-links', content_type: 'application/x-votable+xml; content=datalink' }, + ], [descriptor('one', 'ivo://ivoa.net/std/DataLink#links-1.0'), descriptor('two', 'ivo://ivoa.net/std/DataLink#links-1.0'), descriptor('malformed', 'ivo://ivoa.net/std/DataLink#links-1.0')], [ + { row: 0, serviceId: 'one', url: 'https://example.org/descriptor-links', parameters: { ID: 'one' }, error: null }, + { row: 1, serviceId: 'two', url: 'https://example.org/descriptor-links', parameters: { ID: 'two' }, error: null }, + { row: 2, serviceId: 'malformed', url: 'file:///not-a-vo-link', parameters: {}, error: null }, + ]); + const descriptorOne = response(base, 'descriptor-one', [{ semantics: '#this', access_url: 'one.fits', content_type: 'image/fits' }]); + const descriptorTwo = response(base, 'descriptor-two', [{ semantics: '#this', access_url: 'two.fits', content_type: 'image/fits' }]); + const direct = response(base, 'direct', [{ semantics: '#this', access_url: 'direct.fits', content_type: 'image/fits' }]); + const calls: { url: string; parameters: Readonly> | undefined }[] = []; + const load: MetadataLoader = async (url, parameters) => { + calls.push({ url, parameters }); + if (url === 'https://example.org/root-links') return rootLinks; + if (url === 'https://example.org/descriptor-links') return parameters?.ID === 'one' ? descriptorOne : descriptorTwo; + if (url === 'https://example.org/direct-links') return direct; + throw new Error(`Unexpected ${url}`); + }; + const plan = await planAccess(root, observation, snapshot, request, load); + assert.deepEqual(calls, [ + { url: 'https://example.org/root-links', parameters: {} }, { url: 'https://example.org/descriptor-links', parameters: { ID: 'one' } }, + { url: 'https://example.org/descriptor-links', parameters: { ID: 'two' } }, { url: 'https://example.org/direct-links', parameters: {} }, + ]); + assert.deepEqual(plan.products.map(product => product.operation.url), ['https://example.org/one.fits', 'https://example.org/two.fits', 'https://example.org/direct.fits']); + assert.match(plan.issues.join('\n'), /Unsupported VO access URL/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('DataLink depth and request caps prevent recursion, and a subset never falls back to a direct product', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-access-standards-')); + try { + const { snapshot, observation } = await source(), base = snapshot.response; + const first = response(base, 'first', [{ semantics: '#this', access_url: '/second', content_type: 'application/x-votable+xml; content=datalink' }]); + const second = response(base, 'second', [{ semantics: '#this', access_url: 'whole.fits', content_type: 'image/fits' }]); + let calls = 0; + const load: MetadataLoader = async url => { calls++; return url.endsWith('root-links') ? first : second; }; + const depth = await planAccess(root, observation, snapshot, { ...request, transferLimits: parseLimits({ nestedEdges: 0 }) }, load); + assert.equal(calls, 1); assert.equal(depth.products.length, 0); assert.match(depth.issues.join('\n'), /nesting bound/u); + calls = 0; + const budget = await planAccess(root, observation, snapshot, { ...request, transferLimits: parseLimits({ metadataRequests: 1 }) }, load); + assert.equal(calls, 1); assert.equal(budget.products.length, 0); assert.match(budget.issues.join('\n'), /request bound/u); + const subset = await planAccess(root, observation, snapshot, { ...request, region: circle }, async () => second); + assert.equal(subset.products.length, 0); assert.match(subset.issues.join('\n'), /whole-product access is not an alternative/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/tools/objects/telescopes/vo/access.mts b/tools/objects/telescopes/vo/access.mts new file mode 100644 index 0000000000..2feacfc3c6 --- /dev/null +++ b/tools/objects/telescopes/vo/access.mts @@ -0,0 +1,218 @@ +/** Select one exact product/operation. Never turn a failed cutout into a whole-product download. */ +import { dirname, resolve } from 'node:path'; +import { mkdir, readFile, writeFile, copyFile, rename, rm } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { pinFile, readProductRecord, sameRun, writeProductRecord, type ProductRun } from '../../product-record.mts'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { requireRecord } from '../../../source-values.mts'; +import type { CapabilityRequest } from '../query.mts'; +import { extractVoPackage } from './package.mts'; +import { inputWavelengths } from '../recipe-request.mts'; +import { acquisitionKey, canonical, digest, jsonValue, parseLimits, productKey, type DiscoverySnapshot, type Json, type MetadataResponse, type Pin, type Resource, type TransferLimits } from './contracts.mts'; +import type { DiscoveredObservation } from './discovery.mts'; + +export interface AcquisitionSpec { + readonly schema: 'cssearth-vo-acquisition@1'; readonly key: string; readonly productKey: string; + readonly observation: DiscoveredObservation; readonly request: CapabilityRequest; + readonly operation: { readonly kind: 'direct' | 'soda-sync'; readonly url: string; readonly parameters: Readonly> }; + readonly descriptor: Resource | null; readonly metadata: readonly Pin[]; + readonly serviceRow: number | null; readonly serviceMetadata: string | null; + readonly format: 'fits' | 'zip' | 'tar'; readonly decoder: 'fits-image'; readonly kind: 'image' | 'cube'; readonly limits: TransferLimits; + readonly implementation: string; +} +export interface AccessPlan { readonly products: readonly AcquisitionSpec[]; readonly issues: readonly string[] } +async function implementation(): Promise { + return digest(await Promise.all(['./access.mts', './package.mts', './contracts.mts', './discovery.mts', '../../astronomy-packages/client.mts', '../../astronomy-packages/requirements.lock'] + .map(path => readFile(new URL(path, import.meta.url), 'utf8')))); +} +export function mediaType(value: string | null): { type: string; parameters: Readonly> } | null { + if (value === null) return null; + const [type, ...parts] = value.split(';').map(s => s.trim()); + if (!type || !/^[\w.+-]+\/[\w.+-]+$/u.test(type)) return null; + const parameters: Record = {}; + for (const part of parts) { + const m = /^([\w-]+)=(?:"([^"]*)"|([^\s;]+))$/u.exec(part); + if (!m || m[1]!.toLowerCase() in parameters) return null; + parameters[m[1]!.toLowerCase()] = (m[2] ?? m[3])!.toLowerCase(); + } + return { type: type.toLowerCase(), parameters }; +} +const datalink = (mime: string | null) => { const m = mediaType(mime); return m?.type === 'application/x-votable+xml' && m.parameters.content === 'datalink'; }; +const fits = (mime: string | null) => ['application/fits', 'image/fits'].includes(mediaType(mime)?.type ?? ''); +const standardId = (resource: Resource) => resource.parameters.find(p => p.name === 'standardID')?.value; +const url = (value: string, base: string) => { const u = new URL(value, base); if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password) throw new TypeError('Unsupported VO access URL.'); return u.href; }; +const SODA_SYNC = 'ivo://ivoa.net/std/SODA#sync-1.0'; +const DATALINK_LINKS = 'ivo://ivoa.net/std/DataLink#links-1.0'; + +/** Validate advertised parameter meaning before giving its values to PyVO. */ +export function sodaParameters(descriptor: Resource, request: CapabilityRequest, fixed: Readonly>): Readonly> { + if (standardId(descriptor) !== SODA_SYNC) throw new TypeError('No advertised synchronous SODA operation.'); + const inputs = descriptor.groups.filter(g => g.name === 'inputParams'); + if (inputs.length !== 1) throw new TypeError('SODA input parameter declarations are missing or ambiguous.'); + const parameters = inputs[0]!.parameters; + if (new Set(parameters.map(p => p.name)).size !== parameters.length) throw new TypeError('Duplicate SODA input parameter.'); + const id = parameters.find(p => p.name === 'ID'); + if (!id || fixed.ID === undefined || fixed.ID === null || fixed.ID === '') throw new TypeError('No exact SODA dataset ID binding.'); + const normativeId = id.datatype === 'char' && id.arraysize === '*' && id.unit === null && id.ucd === 'meta.ref.url;meta.curation'; + // ESO uses the older meta.id;meta.dataset UCD. Its actual declaration is retained, never rewritten. + const esoCompatibility = id.datatype === 'char' && id.arraysize === '*' && id.unit === null && id.ucd === 'meta.id;meta.dataset' && !id.ref && typeof id.value === 'string' && id.value.startsWith('ivo://eso.org/') && + descriptor.parameters.some(p => p.name === 'accessURL' && p.value === 'https://dataportal.eso.org/dataPortal/soda/sync'); + if (!normativeId && !esoCompatibility) throw new TypeError('Unsupported SODA ID declaration.'); + const result: Record = { ...fixed }; + if (request.spectralFrame !== undefined) { + if (request.spectralFrame !== 'barycentric') throw new TypeError('SODA BAND requires a barycentric spectral frame.'); + const p = parameters.find(p => p.name === 'BAND'); + if (!p || p.datatype !== 'double' || p.arraysize !== '2' || p.unit !== 'm' || p.ucd !== 'em.wl;stat.interval' || p.xtype !== 'interval') throw new TypeError('SODA does not advertise a supported BAND interval.'); + result.BAND = inputWavelengths(request).map(n => n * 1e-6); + } + if (request.region) { + const p = parameters.find(p => p.name === 'CIRCLE'); + if (!p || p.unit !== 'deg' || p.ucd !== 'pos.outline;obs' || p.xtype !== 'circle' || p.arraysize !== '3' || !['double','float'].includes(p.datatype)) throw new TypeError('SODA does not advertise a supported ICRS CIRCLE.'); + const r = request.region; + result.CIRCLE = [r.raDegrees, r.decDegrees, r.radiusDegrees]; + } + if (!request.region && request.spectralFrame === undefined) throw new TypeError('No explicit supported SODA subset was requested.'); + for (const p of parameters) { + const changed = p.name === 'BAND' && request.spectralFrame || p.name === 'CIRCLE' && request.region; + if (!changed && p.value !== null && p.value !== '' && !Array.isArray(p.value) && result[p.name] === undefined) + throw new TypeError(`PyVO did not bind the advertised ${p.name} default.`); + if (changed && p.constraints && typeof p.constraints === 'object' && !Array.isArray(p.constraints)) { + const c = requireRecord(p.constraints), values = result[p.name]; + if (Array.isArray(values) && p.name === 'BAND') for (const value of values) { + if (typeof value !== 'number' || typeof c.minimum === 'number' && value < c.minimum || typeof c.maximum === 'number' && value > c.maximum) + throw new TypeError(`Requested ${p.name} is outside its advertised bounds.`); + } + } + } + return result; +} +/** `parameters` are PyVO's descriptor-bound DataLink request parameters, never URL text. */ +export type MetadataLoader = (url: string, parameters?: Readonly>) => Promise; +export async function planAccess(root: string, observation: DiscoveredObservation, snapshot: DiscoverySnapshot, request: CapabilityRequest, + load?: MetadataLoader): Promise { + const limits = parseLimits(request.transferLimits), products: AcquisitionSpec[] = [], issues: string[] = [], visited = new Set(); + const implementationDigest = await implementation(); + const evidenceDirectory = resolve(root, 'output/telescopes/vo/metadata'); + await mkdir(evidenceDirectory, { recursive: true }); + const snapshotFile = resolve(evidenceDirectory, `${digest(snapshot)}.snapshot.json`); + await writeFile(snapshotFile, canonical(snapshot)); + const snapshotPin = { path: snapshotFile, ...await pinFile(snapshotFile) }; + if (observation.target.status !== 'confirmed') return { products, issues: ['The archive record has no confirmed target association.'] }; + if (observation.kind !== 'image' && observation.kind !== 'cube') return { products, issues: [`No native decoder for advertised product kind ${observation.kind}.`] }; + const kind = observation.kind; + const loader = load ?? (async (address: string, parameters?: Readonly>) => (await astroquery({ operation: 'vo-links', url: address, + ...(parameters === undefined ? {} : { parameters }), directory: resolve(root, 'output/telescopes/vo/metadata'), byteLimit: limits.metadataBytes })).vo!); + let calls = 0; + function add(operation: AcquisitionSpec['operation'], binding: Json, metadata: readonly Pin[], descriptor: Resource | null = null, serviceRow: number | null = null, serviceMetadata: string | null = null, format: AcquisitionSpec['format'] = 'fits') { + const { snapshot: _snapshot, issues: _issues, ...observationFacts } = observation; + const product = productKey(observation.key, jsonValue({ binding, observation: observationFacts })); + const key = acquisitionKey(product, jsonValue({ operation, request }), jsonValue(descriptor), limits, implementationDigest); + if (!products.some(p => p.key === key)) products.push({ schema: 'cssearth-vo-acquisition@1', key, productKey: product, observation, request, operation, + descriptor, metadata, serviceRow, serviceMetadata, format, decoder: 'fits-image', kind, limits, implementation: implementationDigest }); + } + function direct(address: string, mime: string | null, size: Json | undefined, binding: Json, pins: readonly Pin[]) { + if (request.region || request.spectralFrame !== undefined) { issues.push('Subset requested; whole-product access is not an alternative.'); return; } + const type = mediaType(mime)?.type, format = fits(mime) ? 'fits' : type === 'application/zip' ? 'zip' : type === 'application/x-tar' ? 'tar' : null; + if (!format) { issues.push(`No supported decoder for ${mime ?? 'unknown MIME'}.`); return; } + if (typeof size === 'number' && size > limits.scienceBytes) { issues.push('Advertised direct product exceeds the transfer bound.'); return; } + add({ kind: 'direct', url: address, parameters: {} }, binding, pins, null, null, null, format); + } + async function links(address: string, parameters: Readonly>, depth: number, pins: readonly Pin[]): Promise { + if (depth > limits.nestedEdges) { issues.push('DataLink nesting bound reached.'); return; } + const identity = digest({ url: address, parameters }); + if (visited.has(identity)) { issues.push('Repeated DataLink operation skipped.'); return; } + if (++calls > limits.metadataRequests) { issues.push('DataLink request bound reached.'); return; } + visited.add(identity); + let response: MetadataResponse; + try { response = await loader(address, parameters); } catch (error) { issues.push(`DataLink transport or parsing failed: ${String(error)}`); return; } + const closure = [...pins, response.raw]; + if (response.queryStatus !== 'OK') { issues.push(`DataLink response is ${response.queryStatus}.`); return; } + for (let i = 0; i < response.rows.length; i++) { + const row = response.rows[i]!, semantics = typeof row.semantics === 'string' ? row.semantics : ''; + if (row.error_message) { issues.push(`DataLink error: ${String(row.error_message)}`); continue; } + if (!['#this','#proc','#cutout','http://www.ivoa.net/rdf/datalink/core#this','http://www.ivoa.net/rdf/datalink/core#proc','http://www.ivoa.net/rdf/datalink/core#cutout'].includes(semantics)) continue; + if (row.service_def) { + const binding = response.bindings.find(b => b.row === i && b.serviceId === row.service_def), descriptor = response.resources.find(r => r.id === row.service_def); + if (!binding || binding.error || !binding.url || !descriptor) { issues.push('DataLink service descriptor could not be resolved.'); continue; } + try { + const address = url(binding.url, response.effectiveUrl), standard = standardId(descriptor); + if (standard === DATALINK_LINKS) { + await links(address, binding.parameters, depth + 1, closure); + } else if (standard === SODA_SYNC) { + const parameters = sodaParameters(descriptor, request, binding.parameters); + add({ kind: 'soda-sync', url: address, parameters }, jsonValue({ row, url: address, dataset: binding.parameters }), closure, descriptor, i, response.raw.path); + } else if (typeof standard === 'string' && standard.startsWith('ivo://ivoa.net/std/SODA#')) throw new TypeError('No advertised synchronous SODA operation.'); + else throw new TypeError(`Unsupported DataLink service standard ${standard ?? 'missing'}.`); + } catch (error) { issues.push(String(error)); } + continue; + } + if (typeof row.access_url !== 'string' || !row.access_url) { issues.push('DataLink row has no access URL.'); continue; } + const next = url(row.access_url, response.effectiveUrl), mime = typeof row.content_type === 'string' ? row.content_type : null; + if (datalink(mime)) await links(next, {}, depth + 1, closure); + else direct(next, mime, row.content_length, jsonValue({ row, url: next }), closure); + } + } + if (!observation.access.url) return { products, issues: ['No archive access URL.'] }; + const address = url(observation.access.url, snapshot.response.effectiveUrl), pins = [snapshot.response.raw, snapshotPin]; + if (datalink(observation.access.mime)) await links(address, {}, 0, pins); + else direct(address, observation.access.mime, observation.access.estimatedKilobytes === null ? null : observation.access.estimatedKilobytes * 1000, + jsonValue({ url: address, identities: observation.identities }), pins); + return { products, issues }; +} + +/** Acquisition records origin/integrity only. Scientific metadata qualification is a later stage. */ +export async function acquireVoProduct(root: string, spec: AcquisitionSpec) { + if (spec.implementation !== await implementation() || spec.key !== acquisitionKey(spec.productKey, jsonValue({ operation: spec.operation, request: spec.request }), jsonValue(spec.descriptor), spec.limits, spec.implementation)) + throw new Error('VO acquisition identity or implementation changed; query again.'); + for (const pin of spec.metadata) { + const actual = await pinFile(pin.path); + if (actual.sha256 !== pin.sha256 || actual.bytes !== pin.bytes) throw new Error('VO metadata evidence changed.'); + } + if (spec.operation.kind === 'soda-sync') { + if (!spec.descriptor || spec.serviceRow === null || !spec.serviceMetadata) throw new Error('Subset has no pinned descriptor binding.'); + const parameters = sodaParameters(spec.descriptor, spec.request, spec.operation.parameters); + if (canonical(parameters) !== canonical(spec.operation.parameters)) throw new Error('Subset parameters disagree with request.'); + } else if (spec.request.region || spec.request.spectralFrame) throw new Error('Subset requests cannot acquire a direct product.'); + const destination = resolve(root, 'output/telescopes/vo/acquired', spec.key), recordPath = resolve(destination, 'acquisition.json'); + const run: ProductRun = { telescope: spec.observation.service, stage: 'archive-acquisition', + inputs: spec.metadata.map(pin => ({ identity: pin.sha256, role: 'archive metadata response', bytes: pin.bytes, sha256: pin.sha256 })), + parameters: { acquisition: spec.key, parent: spec.observation.identities, operation: spec.operation, format: spec.format, limits: spec.limits }, + software: [{ name: 'cssEarth VO acquisition', version: spec.implementation }, { name: 'PyVO', version: '1.9.1' }] }; + const previous = await readProductRecord(recordPath); + if (previous) { + if (canonical(previous.parameters) !== canonical(run.parameters) || canonical(previous.software) !== canonical(run.software) || !await sameRun(previous, previous, name => resolve(destination, name))) throw new Error('Acquired product or evidence is stale.'); + return { file: resolve(destination, 'science.fits'), record: recordPath, reused: true, replay: 'pinned-local-artifact' as const }; + } + await mkdir(dirname(destination), { recursive: true }); + const staging = `${destination}.${randomUUID()}.partial`; + await mkdir(staging); + try { + const metadata: { path: string; file: string }[] = []; + for (const pin of spec.metadata) { + const path = `${pin.sha256}${pin.path.endsWith('.json') ? '.json' : '.xml'}`; + if (metadata.some(m => m.path === path)) continue; + const file = resolve(staging, path); await copyFile(pin.path, file); + const actual = await pinFile(file); + if (actual.sha256 !== pin.sha256 || actual.bytes !== pin.bytes) throw new Error('Metadata changed while copying.'); + metadata.push({ path, file }); + } + const descriptorPin = spec.metadata.find(p => p.path === spec.serviceMetadata); + if (spec.operation.kind === 'soda-sync' && !descriptorPin) throw new Error('Subset descriptor is outside the metadata evidence closure.'); + const receivedName = spec.format === 'fits' ? 'science.fits' : `archive.${spec.format}`; + const transferred = (await astroquery({ operation: 'vo-download', url: spec.operation.url, destination: resolve(staging, receivedName), format: spec.format, + byteLimit: spec.limits.scienceBytes, parameters: spec.operation.parameters, + ...(spec.operation.kind === 'soda-sync' ? { descriptor: { file: descriptorPin!, row: spec.serviceRow!, serviceId: spec.descriptor!.id! } } : {}) })).transfer!; + const unpacked = spec.format === 'fits' ? undefined : await extractVoPackage(resolve(staging, receivedName), staging, { expandedBytes: spec.limits.expandedBytes, members: spec.limits.packageMembers }); + if (unpacked) { + if (unpacked.format !== spec.format) throw new Error('Archive format disagrees with advertised MIME.'); + await copyFile(resolve(staging, 'members', unpacked.science), resolve(staging, 'science.fits')); + metadata.push({ path: receivedName, file: resolve(staging, receivedName) }, ...unpacked.members.map(member => ({ path: `members/${member.path}`, file: resolve(staging, 'members', member.path) }))); + } + await writeFile(resolve(staging, 'origin.json'), canonical({ schema: 'cssearth-vo-origin@1', acquisition: spec.key, + parent: spec.observation.identities, operation: spec.operation, received: { ...transferred, file: { ...transferred.file, path: receivedName } }, ...(unpacked ? { unpacked } : {}) })); + await writeProductRecord(resolve(staging, 'acquisition.json'), run, [{ path: 'science.fits', file: resolve(staging, 'science.fits') }, + { path: 'origin.json', file: resolve(staging, 'origin.json') }, ...metadata], []); + await rename(staging, destination); + return { file: resolve(destination, 'science.fits'), record: recordPath, reused: false }; + } finally { await rm(staging, { recursive: true, force: true }); } +} diff --git a/tools/objects/telescopes/vo/bridge.mts b/tools/objects/telescopes/vo/bridge.mts new file mode 100644 index 0000000000..317a743be2 --- /dev/null +++ b/tools/objects/telescopes/vo/bridge.mts @@ -0,0 +1,62 @@ +/** VO observations enter the public query directly, without a synthetic instrument ledger. */ +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { assessRequest, type RequestSatisfaction, type ProductFacts } from '../request-satisfaction.mts'; +import type { CapabilityRequest } from '../query.mts'; +import type { TargetCatalogueEntry } from '../targets.mts'; +import type { QualifiedObservation } from '../qualified-observations.mts'; +import type { QualificationAction } from '../qualification-routes.mts'; +import { jsonValue, parseLimits, type DiscoverySnapshot } from './contracts.mts'; +import { discover, normalizeSnapshot, SERVICES, type DiscoveredObservation } from './discovery.mts'; +import { planAccess, type AcquisitionSpec } from './access.mts'; + +export interface VoInputs { + readonly records: readonly { readonly observation: DiscoveredObservation; readonly snapshot: DiscoverySnapshot; readonly products: readonly AcquisitionSpec[]; readonly issues: readonly string[] }[]; + readonly services: readonly { readonly service: string; readonly state: 'sampled' | 'overflow' | 'empty-in-scope' | 'unavailable'; readonly scope: string; readonly reason: string }[]; +} +export interface VoProductCandidate { + readonly acquisitionKey: string; readonly observation: DiscoveredObservation; readonly satisfaction: RequestSatisfaction; + readonly product?: QualifiedObservation; readonly action?: QualificationAction; readonly limitations: readonly string[]; +} +export async function loadVoInputs(root: string, request: CapabilityRequest, catalogue: readonly TargetCatalogueEntry[], selectedObservation?: string, discoverer: typeof discover = discover): Promise { + const identities = catalogue.map(t => ({ id: t.id, names: [t.name, ...t.aliases], classification: t.archiveClass, classificationSource: t.classificationSource })); + const target = identities.find(t => t.id === request.target); + if (!target) return { records: [], services: [] }; + const limits = parseLimits(request.transferLimits), records: VoInputs['records'][number][] = [], services: VoInputs['services'][number][] = []; + let metadataRequests = 0; + const results = await Promise.allSettled(SERVICES.map(profile => discoverer(root, profile, request, target.names, limits))); + for (let i = 0; i < results.length; i++) { + const result = results[i]!, profile = SERVICES[i]!; + if (result.status === 'rejected') { services.push({ service: profile.service, state: 'unavailable', scope: 'Target-name query', reason: String(result.reason) }); continue; } + const snapshot = result.value; + services.push({ service: profile.service, state: snapshot.completeness === 'failed' ? 'unavailable' : snapshot.completeness === 'overflow' ? 'overflow' : snapshot.response.rows.length ? 'sampled' : 'empty-in-scope', + scope: snapshot.scope, reason: snapshot.response.issues.join('; ') || `${snapshot.response.rows.length} rows; this bounded name search is not an archive inventory.` }); + if (snapshot.completeness === 'failed') continue; + for (const observation of normalizeSnapshot(snapshot, profile, target, identities)) { + if (selectedObservation !== undefined && observation.key !== selectedObservation) { records.push({ observation, snapshot, products: [], issues: ['Access descriptions were not refreshed because get selected a different observation.'] }); continue; } + const plan = await planAccess(root, observation, snapshot, request, async (url, parameters) => { + if (++metadataRequests > limits.metadataRequests) throw new Error('The query-wide access-description request limit was reached.'); + return (await astroquery({ operation: 'vo-links', url, parameters, directory: resolve(root, 'output/telescopes/vo/metadata'), byteLimit: limits.metadataBytes })).vo!; + }).catch((error: unknown) => ({ products: [], issues: [String(error)] })); + records.push({ observation, snapshot, ...plan }); + } + } + return { records, services }; +} +export function voCandidates(request: CapabilityRequest, inputs: VoInputs | undefined, qualified: readonly QualifiedObservation[]): VoProductCandidate[] { + return (inputs?.records ?? []).flatMap(entry => entry.products.map(spec => { + const observation = entry.observation, product = qualified.find(p => p.program === spec.key && p.target === request.target && p.observation === observation.key); + const [lo, hi] = observation.wavelengthsMicrometres; + const advertised: ProductFacts = { target: observation.target.target, verified: false, kind: spec.kind, result: 'telescope-product', + ...(lo !== null && hi !== null ? { wavelengthIntervalsMicrometres: [[lo, hi]] } : {}), + ...(observation.startIso ? { startIso: observation.startIso } : {}), ...(observation.endIso ? { endIso: observation.endIso } : {}) }; + const satisfaction = assessRequest(request, product?.facts ?? advertised); + const complete = request.time && request.kind && (request.angularResolutionArcsec !== undefined || request.surfaceResolutionKm !== undefined || request.resolutionElements !== undefined); + const refused = !complete || satisfaction.status === 'refused' || observation.target.status !== 'confirmed' || request.result !== 'telescope-product'; + const action: QualificationAction = { kind: 'qualify-observation', observation: observation.key, program: spec.key, + configuration: { kind: 'archive-acquisition', key: spec.key, request }, command: 'pnpm', arguments: ['--silent','telescope:qualify','--target',request.target, + '--telescope',observation.service,'--mode',`native-${spec.kind}`,'--observation',observation.key,'--acquisition',spec.key,'--request',JSON.stringify(request)] }; + return { acquisitionKey: spec.key, observation, satisfaction, ...(product ? { product } : {}), ...(!product && !refused ? { action } : {}), + limitations: ['Archive coverage is advertised metadata; acquisition does not establish local recalibration or a body map.', ...entry.issues] }; + })); +} diff --git a/tools/objects/telescopes/vo/contracts.mts b/tools/objects/telescopes/vo/contracts.mts new file mode 100644 index 0000000000..e1fca03df1 --- /dev/null +++ b/tools/objects/telescopes/vo/contracts.mts @@ -0,0 +1,117 @@ +/** Archive metadata is evidence, not a qualified scientific product. */ +import { createHash } from 'node:crypto'; +import { requireArray, requireFiniteNumber, requireRecord, requireString } from '../../../source-values.mts'; + +export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }; +export function jsonValue(value: unknown): Json { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value) || Number.isInteger(value) && !Number.isSafeInteger(value)) throw new TypeError('VO numbers must be finite and lossless.'); + return Object.is(value, -0) ? 0 : value; + } + if (Array.isArray(value)) return value.map(jsonValue); + const record = requireRecord(value, 'VO JSON'); + if (![null, Object.prototype].includes(Object.getPrototypeOf(record))) throw new TypeError('VO JSON requires plain records.'); + return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => [key, jsonValue(entry)])); +} +/** Opaque strings are never folded or normalized. Null differs from an omitted key. */ +export const canonical = (value: unknown): string => JSON.stringify(jsonValue(value)); +export const digest = (value: unknown): string => createHash('sha256').update(canonical(value)).digest('hex'); +export interface Pin { readonly path: string; readonly bytes: number; readonly sha256: string } +export function parsePin(value: unknown): Pin { + const p = requireRecord(value), bytes = requireFiniteNumber(p.bytes), sha256 = requireString(p.sha256), path = requireString(p.path); + if (!path || !Number.isSafeInteger(bytes) || bytes < 0 || !/^[a-f0-9]{64}$/u.test(sha256)) throw new TypeError('Invalid VO file pin.'); + return { path, bytes, sha256 }; +} +export const DEFAULT_LIMITS = { scienceBytes: 1_073_741_824, metadataBytes: 33_554_432, nestedEdges: 3, metadataRequests: 32, expandedBytes: 1_073_741_824, packageMembers: 1024 } as const; +export interface TransferLimits { readonly scienceBytes: number; readonly metadataBytes: number; readonly nestedEdges: number; readonly metadataRequests: number; readonly expandedBytes: number; readonly packageMembers: number } +export function parseLimits(value: unknown = {}): TransferLimits { + const raw = requireRecord(value); + const read = (key: keyof TransferLimits): number => { + const n = requireFiniteNumber(raw[key] === undefined ? DEFAULT_LIMITS[key] : raw[key], key); + if (!Number.isSafeInteger(n) || n < (key === 'nestedEdges' ? 0 : 1)) throw new TypeError(`Invalid transfer limit ${key}.`); + return n; + }; + for (const key of Object.keys(raw)) if (!Object.hasOwn(DEFAULT_LIMITS, key)) throw new TypeError(`Unknown transfer limit ${key}.`); + return { scienceBytes: read('scienceBytes'), metadataBytes: read('metadataBytes'), nestedEdges: read('nestedEdges'), metadataRequests: read('metadataRequests'), expandedBytes: read('expandedBytes'), packageMembers: read('packageMembers') }; +} +export interface IcrsCircle { readonly frame: 'icrs'; readonly shape: 'circle'; readonly raDegrees: number; readonly decDegrees: number; readonly radiusDegrees: number } +export function parseRegion(value: unknown): IcrsCircle { + const r = requireRecord(value), raDegrees = requireFiniteNumber(r.raDegrees), decDegrees = requireFiniteNumber(r.decDegrees), radiusDegrees = requireFiniteNumber(r.radiusDegrees); + for (const key of Object.keys(r)) if (!['frame','shape','raDegrees','decDegrees','radiusDegrees'].includes(key)) throw new TypeError(`Unsupported region field ${key}.`); + if (r.frame !== 'icrs' || r.shape !== 'circle' || raDegrees < 0 || raDegrees >= 360 || decDegrees < -90 || decDegrees > 90 || radiusDegrees <= 0 || radiusDegrees > 180) + throw new TypeError('VO region requires an explicit ICRS circle in degrees.'); + return { frame: 'icrs', shape: 'circle', raDegrees, decDegrees, radiusDegrees }; +} +export interface Field { + readonly name: string; readonly id: string | null; readonly datatype: string; readonly arraysize: string | null; + readonly unit: string | null; readonly ucd: string | null; readonly utype: string | null; readonly xtype: string | null; readonly ref: string | null; +} +export interface Parameter extends Field { readonly value: Json; readonly constraints?: Json } +export interface Resource { + readonly id: string | null; readonly type: string | null; readonly utype: string | null; + readonly parameters: readonly Parameter[]; readonly groups: readonly { readonly name: string | null; readonly parameters: readonly Parameter[] }[]; +} +export const nullableString = (value: unknown): string | null => value === null ? null : requireString(value); +function parseField(value: unknown): Field { + const f = requireRecord(value); + return { name: requireString(f.name), id: nullableString(f.id), datatype: requireString(f.datatype), arraysize: nullableString(f.arraysize), + unit: nullableString(f.unit), ucd: nullableString(f.ucd), utype: nullableString(f.utype), xtype: nullableString(f.xtype), ref: nullableString(f.ref) }; +} +const parseParameter = (value: unknown): Parameter => ({ ...parseField(value), value: jsonValue(requireRecord(value).value), + ...(requireRecord(value).constraints === undefined ? {} : { constraints: jsonValue(requireRecord(value).constraints) }) }); +export interface MetadataResponse { + readonly schema: 'cssearth-vo-metadata@1'; readonly pyvo: '1.9.1'; readonly raw: Pin; + readonly effectiveUrl: string; readonly fetchedAt: string; readonly httpStatus: number; + readonly queryStatus: 'OK' | 'OVERFLOW' | 'ERROR'; readonly fields: readonly Field[]; + readonly rows: readonly Readonly>[]; readonly resources: readonly Resource[]; + readonly coordinateSystems: readonly Json[]; readonly timeSystems: readonly Json[]; readonly issues: readonly string[]; + readonly times: readonly Readonly>[]; + readonly bindings: readonly { readonly row: number; readonly serviceId: string; readonly url: string | null; readonly parameters: Readonly>; readonly error: string | null }[]; +} +export function parseMetadata(value: unknown): MetadataResponse { + const r = requireRecord(value, 'VO metadata'); + if (r.schema !== 'cssearth-vo-metadata@1' || r.pyvo !== '1.9.1' || !['OK', 'OVERFLOW', 'ERROR'].includes(requireString(r.queryStatus))) throw new TypeError('Invalid VO metadata contract, package version or query status.'); + const fields = requireArray(r.fields).map(parseField), names = fields.map(f => f.name); + if (new Set(names).size !== names.length) throw new TypeError('Duplicate VO field names.'); + const ids = fields.flatMap(f => f.id === null ? [] : [f.id]); + if (new Set(ids).size !== ids.length) throw new TypeError('Duplicate VO field IDs.'); + const rows = requireArray(r.rows).map(row => { + const record = requireRecord(row); + if (Object.keys(record).length !== names.length || names.some(name => !(name in record))) throw new TypeError('VO row does not match its fields.'); + return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, jsonValue(entry)])); + }); + const times = requireArray(r.times).map(row => Object.fromEntries(Object.entries(requireRecord(row)).map(([k, v]) => [k, nullableString(v)]))); + if (times.length !== rows.length) throw new TypeError('VO time coordinates do not match rows.'); + const resources = requireArray(r.resources).map(value => { const v = requireRecord(value); return { + id: nullableString(v.id), type: nullableString(v.type), utype: nullableString(v.utype), parameters: requireArray(v.parameters).map(parseParameter), + groups: requireArray(v.groups).map(value => { const g = requireRecord(value); return { name: nullableString(g.name), parameters: requireArray(g.parameters).map(parseParameter) }; }) }; }); + const bindings = requireArray(r.bindings).map(value => { const b = requireRecord(value), row = requireFiniteNumber(b.row); + if (!Number.isSafeInteger(row) || row < 0 || row >= rows.length) throw new TypeError('Service binding has no matching row.'); + return { row, serviceId: requireString(b.serviceId), url: nullableString(b.url), parameters: Object.fromEntries(Object.entries(requireRecord(b.parameters)).map(([k, v]) => [k, jsonValue(v)])), error: nullableString(b.error) }; }); + const httpStatus = requireFiniteNumber(r.httpStatus), fetchedAt = requireString(r.fetchedAt), effectiveUrl = requireString(r.effectiveUrl); + if (!Number.isInteger(httpStatus) || httpStatus < 100 || httpStatus > 599 || !Number.isFinite(Date.parse(fetchedAt))) throw new TypeError('Invalid VO response provenance.'); + if ((httpStatus < 200 || httpStatus >= 300) && r.queryStatus !== 'ERROR') throw new TypeError('VO query status contradicts HTTP failure.'); + new URL(effectiveUrl); + return { schema: 'cssearth-vo-metadata@1', pyvo: '1.9.1', raw: parsePin(r.raw), effectiveUrl, fetchedAt, httpStatus, + queryStatus: r.queryStatus as MetadataResponse['queryStatus'], fields, rows, resources, times, bindings, + coordinateSystems: requireArray(r.coordinateSystems).map(jsonValue), timeSystems: requireArray(r.timeSystems).map(jsonValue), issues: requireArray(r.issues).map(v => requireString(v)) }; +} +export interface DiscoverySnapshot { + readonly schema: 'cssearth-vo-discovery@1'; readonly service: string; readonly table: string; readonly model: 'obscore-1.1' | 'epn-tap-2.0'; + readonly request: Json; readonly query: string; readonly scope: string; readonly sampleLimit: number; + readonly response: MetadataResponse; + /** Query completion is distinct from a complete archive inventory. */ + readonly completeness: 'bounded-sample' | 'overflow' | 'failed'; +} +export function recordKey(snapshot: DiscoverySnapshot, row: Readonly>, identityColumns: readonly string[], rowIndex = snapshot.response.rows.indexOf(row)): string { + const identity = identityColumns.map(column => row[column]); + const unique = identity.length > 0 && identity.every(v => v !== undefined && v !== null && v !== '') && + snapshot.response.rows.filter(r => identityColumns.every(column => canonical(r[column]) === canonical(row[column]))).length === 1; + return digest(unique ? { service: snapshot.service, table: snapshot.table, identityColumns, identity } + : { service: snapshot.service, table: snapshot.table, snapshot: snapshot.response.raw.sha256, rowIndex, row }); +} +export const productKey = (record: string, binding: Json): string => digest({ record, binding }); +/** Fetch times are deliberately outside this identity. Descriptor content and limits are inside it. */ +export const acquisitionKey = (product: string, operation: Json, descriptor: Json, limits: TransferLimits, implementation: string): string => + digest({ product, operation, descriptor, limits, implementation }); diff --git a/tools/objects/telescopes/vo/coverage.test.mts b/tools/objects/telescopes/vo/coverage.test.mts new file mode 100644 index 0000000000..ce82335a2a --- /dev/null +++ b/tools/objects/telescopes/vo/coverage.test.mts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { jsonValue, type DiscoverySnapshot, type MetadataResponse } from './contracts.mts'; +import { loadVoInputs } from './bridge.mts'; +import { SERVICES } from './discovery.mts'; +import { sodaParameters } from './access.mts'; + +const fixtures = resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo'); +const request = { target: 'betelgeuse', wavelengthMicrometres: [0.78, 0.85] as const, kind: 'image' as const, result: 'telescope-product' as const, + time: { any: true } as const, angularResolutionArcsec: 1 }; +const catalogue = [{ id: request.target, name: 'Betelgeuse', aliases: [], archiveClass: 'star', classificationSource: 'fixture' }]; + +async function metadata(file: string, url: string): Promise { + return (await astroquery({ operation: 'vo-parse', file: resolve(fixtures, file), url, byteLimit: 1e6, timeFormat: 'mjd', timeScale: 'utc' })).vo!; +} +function snapshot(profile: typeof SERVICES[number], response: MetadataResponse, completeness: DiscoverySnapshot['completeness']): DiscoverySnapshot { + return { schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, request: jsonValue(request), + query: 'fixture', scope: 'exact bounded fixture scope', sampleLimit: 1, response, completeness }; +} + +test('independent discovery outcomes retain empty, overflow-unsupported, and failed service states', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-coverage-')); + try { + const eso = await metadata('eso-obscore.xml', SERVICES[0]!.service), alma = await metadata('alma-obscore.xml', SERVICES[1]!.service); + const empty = snapshot(SERVICES[0]!, { ...eso, rows: [], times: [] }, 'bounded-sample'); + const unsupported = snapshot(SERVICES[1]!, { ...alma, queryStatus: 'OVERFLOW', rows: [{ ...alma.rows[0]!, dataproduct_type: 'spectrum' }], times: [alma.times[0]!] }, 'overflow'); + const inputs = await loadVoInputs(root, request, catalogue, undefined, async (_root, profile) => { + if (profile === SERVICES[0]) return empty; + if (profile === SERVICES[1]) return unsupported; + throw new Error('fixture service unavailable'); + }); + assert.deepEqual(inputs.services.map(service => service.state), ['empty-in-scope', 'overflow', 'unavailable']); + assert.equal(inputs.records.length, 1); assert.equal(inputs.records[0]!.products.length, 0); + assert.match(inputs.records[0]!.issues.join('\n'), /No native decoder/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('PyVO binds a ref-derived SODA ID from the selected DataLink row', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-ref-id-')), file = resolve(root, 'links.xml'); + const xml = ` + +
ivo://fixture/parent-1fixture-soda#cutout
+
`; + try { + await writeFile(file, xml); + const response = (await astroquery({ operation: 'vo-parse', file, url: 'https://example.org/links', byteLimit: 100_000 })).vo!; + const descriptor = response.resources.find(resource => resource.id === 'fixture-soda')!, binding = response.bindings[0]!; + assert.equal(binding.error, null); assert.equal(binding.parameters.ID, 'ivo://fixture/parent-1'); + assert.equal(sodaParameters(descriptor, { ...request, region: { frame: 'icrs', shape: 'circle', raDegrees: 1, decDegrees: 2, radiusDegrees: 0.1 } }, binding.parameters).ID, 'ivo://fixture/parent-1'); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/tools/objects/telescopes/vo/discovery.mts b/tools/objects/telescopes/vo/discovery.mts new file mode 100644 index 0000000000..ae13cce623 --- /dev/null +++ b/tools/objects/telescopes/vo/discovery.mts @@ -0,0 +1,123 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import type { CapabilityRequest } from '../query.mts'; +import { inputWavelengths } from '../recipe-request.mts'; +import { requireFiniteNumber, requireRecord, requireString } from '../../../source-values.mts'; +import { canonical, digest, jsonValue, parseMetadata, recordKey, type DiscoverySnapshot, type Json, type TransferLimits } from './contracts.mts'; + +export interface ServiceProfile { + readonly authority: string; readonly service: string; readonly table: string; readonly model: DiscoverySnapshot['model']; + readonly identityColumns: readonly string[]; readonly timeScale?: 'utc' | 'tai' | 'tt' | 'tdb'; readonly documentation: string; +} +/** A bounded service list, with no target-specific selection rules. */ +export const SERVICES: readonly ServiceProfile[] = [ + { authority: 'ivo://eso.org', service: 'https://archive.eso.org/tap_obs', table: 'ivoa.ObsCore', model: 'obscore-1.1', identityColumns: ['obs_publisher_did', 'obs_id'], timeScale: 'utc', documentation: 'https://archive.eso.org/tap_obs' }, + { authority: 'ivo://alma', service: 'https://almascience.eso.org/tap', table: 'ivoa.obscore', model: 'obscore-1.1', identityColumns: ['obs_publisher_did', 'obs_id'], timeScale: 'utc', documentation: 'https://almascience.eso.org/alma-data/archive/archive-notebooks/nb9_ALMA_Download_data.html' }, + { authority: 'ivo://esa/psa', service: 'https://psa.esa.int/psa-tap/tap', table: 'psa.epn_core', model: 'epn-tap-2.0', identityColumns: ['granule_uid'], documentation: 'https://archives.esac.esa.int/psa/' }, +]; +export interface ArchiveTarget { readonly id: string; readonly names: readonly string[]; readonly classification?: string; readonly classificationSource?: string } +export interface TargetAssociation { readonly status: 'confirmed' | 'ambiguous' | 'unmatched'; readonly target: string; readonly reason: string } +const nameKey = (s: string) => s.trim().toLocaleLowerCase('en-US').replace(/\s+/gu, ' '); +export function associateTarget(rawName: Json | undefined, rawClass: Json | undefined, target: ArchiveTarget, catalogue: readonly ArchiveTarget[]): TargetAssociation { + if (typeof rawName !== 'string' || !rawName.trim()) return { status: 'unmatched', target: target.id, reason: 'No archive target name.' }; + // EPN multi-target lists cannot be treated as a measurement of one named body. + if (/[#;,]/u.test(rawName)) return { status: 'ambiguous', target: target.id, reason: 'Multiple archive target names; no per-target measurement association.' }; + // A numbered minor-planet name can collide with a satellite name. Its class is required to disambiguate that shortened spelling. + const matches = catalogue.filter(t => t.names.some(n => nameKey(n) === nameKey(rawName) || t.classification === 'asteroid' && nameKey(n.replace(/^\(?\d+\)?\s+/u, '')) === nameKey(rawName))); + const classified = typeof rawClass === 'string' && rawClass.trim() ? matches.filter(t => t.classificationSource && nameKey(t.classification ?? '') === nameKey(rawClass)) : matches; + if (classified.length === 1 && classified[0]!.id === target.id) return { status: 'confirmed', target: target.id, reason: 'Exact catalogue name/alias and any supplied archive classification agree.' }; + return { status: matches.some(t => t.id === target.id) ? 'ambiguous' : 'unmatched', target: target.id, + reason: 'Archive target name/class does not establish a unique catalogue identity; sky overlap is not target association.' }; +} +export interface DiscoveredObservation { + readonly key: string; readonly snapshot: string; readonly service: string; readonly table: string; + readonly identities: Readonly>; readonly target: TargetAssociation; readonly rawTarget: Json; + readonly kind: string | null; readonly calibration: { readonly scheme: string; readonly token: Json }; + readonly wavelengthsMicrometres: readonly [number | null, number | null]; readonly startIso: string | null; readonly endIso: string | null; + readonly spatial: { readonly frame: string | null; readonly description: Json; readonly coordinates: Readonly> }; + readonly access: { readonly url: string | null; readonly mime: string | null; readonly estimatedKilobytes: number | null }; + readonly issues: readonly string[]; +} +export function normalizeSnapshot(snapshot: DiscoverySnapshot, profile: ServiceProfile, target: ArchiveTarget, catalogue: readonly ArchiveTarget[]): DiscoveredObservation[] { + if (snapshot.service !== profile.service || snapshot.table !== profile.table || snapshot.model !== profile.model) throw new TypeError('VO profile does not match snapshot.'); + const epn = profile.model === 'epn-tap-2.0'; + return snapshot.response.rows.map((row, index) => { + const issues: string[] = [...snapshot.response.issues]; + const number = (key: string, unit?: string): number | null => { + const raw = row[key]; + if (raw === null || raw === undefined) return null; + const field = snapshot.response.fields.find(f => f.name === key); + if (unit && field?.unit !== unit) { issues.push(`${key}: expected unit ${unit}, found ${field?.unit ?? 'undeclared'}.`); return null; } + if (typeof raw !== 'number' || !Number.isFinite(raw)) { issues.push(`${key}: not a finite numeric coordinate.`); return null; } + return raw; + }; + const positive = (key: string, unit: string) => { const n = number(key, unit); if (n !== null && n <= 0) { issues.push(`${key}: non-positive spectral coordinate.`); return null; } return n; }; + const lower = positive(epn ? 'spectral_range_min' : 'em_min', epn ? 'Hz' : 'm'), upper = positive(epn ? 'spectral_range_max' : 'em_max', epn ? 'Hz' : 'm'); + let wavelengths: [number | null, number | null] = epn ? [upper === null ? null : 299792458e6 / upper, lower === null ? null : 299792458e6 / lower] : [lower === null ? null : lower * 1e6, upper === null ? null : upper * 1e6]; + if (wavelengths[0] !== null && wavelengths[1] !== null && wavelengths[0] > wavelengths[1]) { issues.push('Inverted spectral interval.'); wavelengths = [null, null]; } + const string = (key: string): string | null => typeof row[key] === 'string' ? row[key] : null; + const time = (key: string): string | null => { + const value = snapshot.response.times[index]?.[key]; + if (value && Number.isFinite(Date.parse(value)) && !/:60(?:\.|Z)/u.test(value)) return value; + if (row[key] !== undefined && row[key] !== null) issues.push(`${key}: no representable UTC instant established.`); + return null; + }; + const mime = string('access_format'); + if (mime && !/^[\w.+-]+\/[\w.+-]+(?:\s*;.*)?$/u.test(mime)) issues.push(`Malformed access_format ${JSON.stringify(mime)}; access protocol is unresolved.`); + const keys = epn ? ['granule_uid', 'granule_gid', 'obs_id'] : ['obs_publisher_did', 'obs_id']; + const uniqueIdentity = profile.identityColumns.every(k => row[k] !== undefined && row[k] !== null && row[k] !== '') && snapshot.response.rows.filter(r => profile.identityColumns.every(k => canonical(r[k] ?? null) === canonical(row[k] ?? null))).length === 1; + if (!uniqueIdentity) issues.push('Declared row identity is absent or repeated; this record key is bound to its snapshot and row position.'); + return { key: recordKey(snapshot, row, profile.identityColumns, index), snapshot: snapshot.response.raw.sha256, service: snapshot.service, table: snapshot.table, + identities: Object.fromEntries(keys.map(k => [k, row[k] ?? null])), rawTarget: row.target_name ?? null, + target: associateTarget(row.target_name, row.target_class, target, catalogue), kind: epn && row.dataproduct_type === 'im' ? 'image' : epn && row.dataproduct_type === 'sc' ? 'cube' : string('dataproduct_type'), + calibration: { scheme: epn ? 'epn-tap:processing_level' : 'obscore:calib_level', token: row[epn ? 'processing_level' : 'calib_level'] ?? null }, + wavelengthsMicrometres: wavelengths, startIso: time(epn ? 'time_min' : 't_min'), endIso: time(epn ? 'time_max' : 't_max'), + spatial: { frame: epn ? string('spatial_frame_type') : 'icrs', description: row[epn ? 'spatial_coordinate_description' : 's_region'] ?? null, + coordinates: Object.fromEntries((epn ? ['c1min','c1max','c2min','c2max','c3min','c3max'] : ['s_ra','s_dec','s_fov','s_region']).map(k => [k, row[k] ?? null])) }, + access: { url: string('access_url'), mime, estimatedKilobytes: number('access_estsize') }, issues }; + }); +} +export function targetQuery(profile: ServiceProfile, names: readonly string[], sampleLimit = 50, request?: CapabilityRequest): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/u.test(profile.table)) throw new TypeError('Unvalidated TAP table identifier.'); + if (!Number.isSafeInteger(sampleLimit) || sampleLimit < 1 || sampleLimit > 1000 || !names.length) throw new TypeError('A bounded TAP target-name query is required.'); + const literals = [...new Set(names)].map(n => { if (!n.trim() || /[\u0000-\u001f]/u.test(n)) throw new TypeError('Invalid target name.'); return `'${n.replaceAll("'", "''")}'`; }); + const filters = [`target_name IN (${literals.join(',')})`]; + if (request?.kind) { + const token = profile.model === 'epn-tap-2.0' ? ({ image: 'im', cube: 'sc', spectrum: 'sp', table: 'ca', photometry: 'ts', events: 'ev', strips: 'im' } as const)[request.kind] : request.kind; + filters.push(`(dataproduct_type IS NULL OR dataproduct_type='${token}')`); + } + if (request && profile.model === 'obscore-1.1') { + const [lo, hi] = inputWavelengths(request); + filters.push(`(em_min IS NULL OR em_min<=${hi * 1e-6})`, `(em_max IS NULL OR em_max>=${lo * 1e-6})`); + } + return `SELECT TOP ${sampleLimit} * FROM ${profile.table} WHERE ${filters.join(' AND ')}`; +} +export function parseSnapshot(value: unknown): DiscoverySnapshot { + const r = requireRecord(value); + if (r.schema !== 'cssearth-vo-discovery@1' || !['obscore-1.1','epn-tap-2.0'].includes(requireString(r.model))) throw new TypeError('Unsupported VO snapshot.'); + const response = parseMetadata(r.response), sampleLimit = requireFiniteNumber(r.sampleLimit); + if (!Number.isSafeInteger(sampleLimit) || sampleLimit < 1) throw new TypeError('Invalid VO sample limit.'); + const completeness = response.queryStatus === 'ERROR' ? 'failed' : response.queryStatus === 'OVERFLOW' ? 'overflow' : 'bounded-sample'; + if (r.completeness !== completeness) throw new TypeError('VO completeness contradicts response status.'); + return { schema: 'cssearth-vo-discovery@1', service: requireString(r.service), table: requireString(r.table), model: r.model as DiscoverySnapshot['model'], + request: jsonValue(r.request), query: requireString(r.query), scope: requireString(r.scope), sampleLimit, response, completeness }; +} +/** A failed refresh never replaces a successful immutable snapshot. */ +export async function discover(root: string, profile: ServiceProfile, request: CapabilityRequest, names: readonly string[], limits: TransferLimits): Promise { + const directory = resolve(root, 'output/telescopes/vo/metadata'), query = targetQuery(profile, names, 50, request), sampleLimit = 50; + await mkdir(directory, { recursive: true }); + const response = (await astroquery({ operation: 'vo-tap', service: profile.service, query, maxrec: sampleLimit, directory, byteLimit: limits.metadataBytes, + timeFormat: profile.model === 'obscore-1.1' ? 'mjd' : 'jd', ...(profile.timeScale ? { timeScale: profile.timeScale } : {}), + ...(profile.model === 'epn-tap-2.0' ? { timeModel: profile.model } : {}) })).vo!; + const snapshot = parseSnapshot({ schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, + request, query, sampleLimit, scope: 'Exact target-name/alias search; bounded sample; incidental targets are not covered.', response, + completeness: response.queryStatus === 'ERROR' ? 'failed' : response.queryStatus === 'OVERFLOW' ? 'overflow' : 'bounded-sample' }); + await writeFile(resolve(directory, `${digest(snapshot)}.json`), canonical(snapshot)); + return snapshot; +} +export async function verifySnapshot(snapshot: DiscoverySnapshot): Promise { + const bytes = await readFile(snapshot.response.raw.path); + const { createHash } = await import('node:crypto'); + return bytes.length === snapshot.response.raw.bytes && createHash('sha256').update(bytes).digest('hex') === snapshot.response.raw.sha256; +} diff --git a/tools/objects/telescopes/vo/epn-time.test.mts b/tools/objects/telescopes/vo/epn-time.test.mts new file mode 100644 index 0000000000..2dbd1b2b38 --- /dev/null +++ b/tools/objects/telescopes/vo/epn-time.test.mts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { normalizeSnapshot, SERVICES } from './discovery.mts'; +import type { DiscoverySnapshot } from './contracts.mts'; + +const field = (name: string, attributes = '') => ``; +const xml = (fields: string, values: string, systems = '') => `${systems}${fields}${values}
`; +async function parse(document: string) { + const directory = await mkdtemp(resolve(tmpdir(), 'epn-time-')), file = resolve(directory, 'epn.xml'); + try { + await writeFile(file, document); + return (await astroquery({ operation: 'vo-parse', file, url: 'https://example.org/epn', byteLimit: 10000, timeFormat: 'jd', timeModel: 'epn-tap-2.0' })).vo!; + } finally { await rm(directory, { recursive: true, force: true }); } +} + +test('EPN time defaults to UTC, preserves raw values, and rejects a missing unit', async () => { + const declared = await parse(xml(field('time_min'), '2451545')); + assert.equal(declared.rows[0]!.time_min, 2451545); + assert.equal(declared.times[0]!.time_min, '2000-01-01T12:00:00.000Z'); + const missingUnit = await parse(xml('', '2451545')); + assert.equal(missingUnit.times[0]!.time_min, null); + assert.ok(missingUnit.issues.some(issue => issue.includes('Time unit is not days'))); +}); + +test('UTC fallback is EPN-model scoped and blank row metadata remains absent', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'epn-time-generic-')), file = resolve(directory, 'generic.xml'); + try { + await writeFile(file, xml(field('time_min'), '2451545')); + const generic = (await astroquery({ operation: 'vo-parse', file, url: 'https://example.org/generic', byteLimit: 10000, timeFormat: 'jd' })).vo!; + assert.equal(generic.times[0]!.time_min, null); + } finally { await rm(directory, { recursive: true, force: true }); } + const blank = await parse(xml(field('time_min') + field('time_scale') + field('time_refposition'), '2451545 ')); + assert.equal(blank.times[0]!.time_min, '2000-01-01T12:00:00.000Z'); +}); + +test('EPN row scales override the UTC fallback and explicit TIMESYS converts with Astropy', async () => { + const rowScale = await parse(xml(field('time_min') + field('time_scale'), '2451545TT')); + assert.equal(rowScale.times[0]!.time_min, '2000-01-01T11:58:55.816Z'); + const system = ''; + const referenced = await parse(xml(field('time_min', ' ref="clock"'), '2451545', system)); + assert.equal(referenced.times[0]!.time_min, '2000-01-01T11:58:55.816Z'); +}); + +test('EPN contradictory or unresolved TIMESYS metadata refuses normalization', async () => { + const system = ''; + const scaleConflict = await parse(xml(field('time_min', ' ref="clock"') + field('time_scale'), '2451545TDB', system)); + assert.equal(scaleConflict.times[0]!.time_min, null); + assert.ok(scaleConflict.issues.some(issue => issue.includes('conflicts with EPN time_scale'))); + const referenceConflict = await parse(xml(field('time_min', ' ref="clock"') + field('time_refposition'), '2451545BARYCENTER', system)); + assert.equal(referenceConflict.times[0]!.time_min, null); + assert.ok(referenceConflict.issues.some(issue => issue.includes('reference position conflicts'))); + const unresolved = await parse(xml(field('time_min', ' ref="missing"'), '2451545', system)); + assert.equal(unresolved.times[0]!.time_min, null); + assert.ok(unresolved.issues.some(issue => issue.includes('unresolved or ambiguous TIMESYS'))); +}); + +test('EPN rejects malformed scales and incompatible time origins', async () => { + const malformed = await parse(xml(field('time_min') + field('time_scale'), '2451545not a time scale')); + assert.equal(malformed.times[0]!.time_min, null); + assert.ok(malformed.issues.some(issue => issue.includes('not a time scale'))); + const origin = ''; + const incompatibleOrigin = await parse(xml(field('time_min', ' ref="clock"'), '2451545', origin)); + assert.equal(incompatibleOrigin.times[0]!.time_min, null); + assert.ok(incompatibleOrigin.issues.some(issue => issue.includes('origin disagrees'))); +}); + +test('EPN normalization reads the standard spatial coordinate description', async () => { + const spatialFields = ''; + const response = await parse(xml(spatialFields, 'row-1MarsbodyIAU2020:49900')); + const profile = SERVICES[2]!, snapshot: DiscoverySnapshot = { schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: 'epn-tap-2.0', + request: {}, query: 'SELECT', scope: 'fixture', sampleLimit: 1, response, completeness: 'bounded-sample' }; + const target = { id: 'mars', names: ['Mars'] }; + assert.equal(normalizeSnapshot(snapshot, profile, target, [target])[0]!.spatial.description, 'IAU2020:49900'); +}); diff --git a/tools/objects/telescopes/vo/links-boundary.test.mts b/tools/objects/telescopes/vo/links-boundary.test.mts new file mode 100644 index 0000000000..206de4534a --- /dev/null +++ b/tools/objects/telescopes/vo/links-boundary.test.mts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'node:http'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { pinFile } from '../../product-record.mts'; +import { loadVoInputs } from './bridge.mts'; +import { SERVICES } from './discovery.mts'; +import { jsonValue, type DiscoverySnapshot } from './contracts.mts'; + +test('PyVO sends descriptor-bound DataLink parameters and retains the exact response', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-links-boundary-')); + const xml = await readFile(resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo/eso-links.xml')); + const requests: URL[] = []; + const server = createServer((request, response) => { + requests.push(new URL(request.url!, 'http://localhost')); + response.writeHead(200, { 'Content-Type': 'application/x-votable+xml;content=datalink' }); + response.end(xml); + }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No HTTP port.'); + try { + const url = `http://127.0.0.1:${address.port}/links`; + const parameters = { ID: 'ivo://fixture/a+b&member=1', RESPONSEFORMAT: 'application/x-votable+xml;content=datalink' }; + const answer = (await astroquery({ operation: 'vo-links', url, parameters, directory, byteLimit: 1_000_000 })).vo!; + assert.equal(requests.length, 1); + assert.equal(requests[0]!.searchParams.get('ID'), parameters.ID); + assert.equal(requests[0]!.searchParams.get('RESPONSEFORMAT'), parameters.RESPONSEFORMAT); + assert.equal(new URL(answer.effectiveUrl).searchParams.get('ID'), parameters.ID); + assert.equal(answer.queryStatus, 'OK'); + assert.ok(answer.bindings.some(binding => binding.url?.includes('/soda/sync'))); + assert.deepEqual(await readFile(answer.raw.path), xml); + assert.deepEqual(await pinFile(answer.raw.path), { bytes: answer.raw.bytes, sha256: answer.raw.sha256 }); + } finally { + server.closeAllConnections(); + await new Promise(done => server.close(() => done())); + await rm(directory, { recursive: true, force: true }); + } +}); + +test('public VO query follows a descriptor-bound nested DataLink service', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-public-links-')); + const seen: URL[] = []; + // DataLink 1.0 service descriptor: input ID references the selected table row. + const document = (nested: boolean) => ` + +
ivo://fixture/member+1${nested ? '' : '/science.fits'}${nested ? 'nested' : ''}#this${nested ? '' : 'image/fits'}
+ ${nested ? '' : ''}
`; + const server = createServer((request, response) => { + const url = new URL(request.url!, 'http://localhost'); seen.push(url); + response.writeHead(200, { 'Content-Type': 'application/x-votable+xml;content=datalink' }); + response.end(document(url.pathname === '/root')); + }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No HTTP port.'); + try { + const service = `http://127.0.0.1:${address.port}`; + const response = (await astroquery({ operation: 'vo-parse', file: resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo/eso-obscore.xml'), + url: service, byteLimit: 1_000_000, timeFormat: 'mjd', timeScale: 'utc' })).vo!; + const request = { target: 'betelgeuse', wavelengthMicrometres: [0.78, 0.85] as const, kind: 'image' as const, + result: 'telescope-product' as const, time: { any: true } as const, angularResolutionArcsec: 1 }; + const inputs = await loadVoInputs(directory, request, + [{ id: 'betelgeuse', name: 'Betelgeuse', aliases: [], archiveClass: 'star', classificationSource: 'fixture' }], undefined, + async (_root, profile): Promise => ({ schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, + request: jsonValue(request), query: 'fixture', scope: 'synthetic descriptor traversal', sampleLimit: 1, completeness: 'bounded-sample', + response: { ...response, rows: profile === SERVICES[0] ? [{ ...response.rows[0]!, target_name: 'Betelgeuse', dataproduct_type: 'image', + access_url: `${service}/root`, access_format: 'application/x-votable+xml;content=datalink' }] : [], times: profile === SERVICES[0] ? response.times : [] } })); + assert.equal(seen.length, 2); + assert.equal(seen[1]!.pathname, '/nested'); + assert.equal(seen[1]!.searchParams.get('ID'), 'ivo://fixture/member+1'); + assert.equal(inputs.records.length, 1); + assert.deepEqual(inputs.records[0]!.issues, []); + const products = inputs.records[0]!.products; + assert.equal(products.length, 1); + assert.equal(products[0]!.operation.url, `${service}/science.fits`); + assert.equal(products[0]!.metadata.length, 4); // Discovery bytes, snapshot, and both DataLink responses. + for (const pin of products[0]!.metadata) assert.deepEqual(await pinFile(pin.path), { bytes: pin.bytes, sha256: pin.sha256 }); + } finally { + server.closeAllConnections(); + await new Promise(done => server.close(() => done())); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tools/objects/telescopes/vo/matrix.test.mts b/tools/objects/telescopes/vo/matrix.test.mts new file mode 100644 index 0000000000..2be53996ba --- /dev/null +++ b/tools/objects/telescopes/vo/matrix.test.mts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { choiceKey } from '../session.mts'; +import { planAccess } from './access.mts'; +import { recordKey, type DiscoverySnapshot, type MetadataResponse } from './contracts.mts'; +import { normalizeSnapshot, SERVICES } from './discovery.mts'; + +const fixtures = resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo'); +const target = { id: 'betelgeuse', names: ['Betelgeuse'], classification: 'star', classificationSource: 'fixture catalogue' }; +const baseRequest = { target: target.id, wavelengthMicrometres: [0.78, 0.85] as const, kind: 'image' as const, result: 'telescope-product' as const }; +const circle = (radiusDegrees: number) => ({ frame: 'icrs' as const, shape: 'circle' as const, raDegrees: 88.792938, decDegrees: 7.407063, radiusDegrees }); + +async function metadata(file: string, url: string): Promise { + return (await astroquery({ operation: 'vo-parse', file: resolve(fixtures, file), url, byteLimit: 1e6, timeFormat: 'mjd', timeScale: 'utc' })).vo!; +} +function snapshot(response: MetadataResponse): DiscoverySnapshot { + const profile = SERVICES[0]!; + return { schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, request: baseRequest, + query: 'fixture', scope: 'bounded fixture', sampleLimit: 1, response, completeness: 'bounded-sample' }; +} +async function datalinkObservation() { + const discovered = snapshot(await metadata('eso-obscore.xml', SERVICES[0]!.service)); + const observation = normalizeSnapshot(discovered, SERVICES[0]!, target, [target])[0]!; + return { snapshot: discovered, observation: { ...observation, kind: 'image' as const, + access: { url: 'https://example.org/links', mime: 'application/x-votable+xml; content=datalink', estimatedKilobytes: null } } }; +} + +test('different subsets and descriptors have separate saved-choice and acquisition identities', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-matrix-')); + try { + const { snapshot: discovered, observation } = await datalinkObservation(); + const links = await metadata('eso-links.xml', observation.access.url!); + const firstRequest = { ...baseRequest, region: circle(0.3 / 3600) }; + const secondRequest = { ...baseRequest, region: circle(0.6 / 3600) }; + const first = await planAccess(root, observation, discovered, firstRequest, async () => links); + const second = await planAccess(root, observation, discovered, secondRequest, async () => links); + const descriptor = links.resources.find(resource => resource.parameters.some(parameter => parameter.value === 'ivo://ivoa.net/std/SODA#sync-1.0'))!; + const changedDescriptor = { ...links, resources: links.resources.map(resource => resource === descriptor ? { ...resource, + parameters: [...resource.parameters, { name: 'fixtureDescriptorRevision', id: null, datatype: 'char', arraysize: null, unit: null, ucd: null, utype: null, xtype: null, ref: null, value: null }] } : resource) }; + const changed = await planAccess(root, observation, discovered, firstRequest, async () => changedDescriptor); + assert.equal(first.products.length, 1); assert.equal(second.products.length, 1); assert.equal(changed.products.length, 1); + assert.notEqual(first.products[0]!.key, second.products[0]!.key); + assert.notEqual(first.products[0]!.key, changed.products[0]!.key); + const saved = (key: string) => choiceKey({ acquisitionKey: key, telescope: observation.service, mode: 'native-image', observation: observation.key, program: key }); + assert.notEqual(saved(first.products[0]!.key), saved(second.products[0]!.key)); + assert.notEqual(resolve(root, 'output/telescopes/vo/acquired', first.products[0]!.key), resolve(root, 'output/telescopes/vo/acquired', second.products[0]!.key)); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('a granule identifier is scoped to its discovery service', async () => { + const response = await metadata('eso-obscore.xml', SERVICES[0]!.service), row = response.rows[0]!; + const sameGranuleElsewhere = { ...snapshot(response), service: SERVICES[1]!.service, table: SERVICES[1]!.table, model: SERVICES[1]!.model }; + assert.notEqual(recordKey(snapshot(response), row, ['obs_publisher_did', 'obs_id']), recordKey(sameGranuleElsewhere, row, ['obs_publisher_did', 'obs_id'])); +}); + +test('missing or async-only descriptors, link errors, and nested request caps stay unselectable', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-matrix-')); + try { + const { snapshot: discovered, observation } = await datalinkObservation(); + const links = await metadata('eso-links.xml', observation.access.url!); + const descriptor = links.resources.find(resource => resource.parameters.some(parameter => parameter.value === 'ivo://ivoa.net/std/SODA#sync-1.0'))!; + const missing = { ...links, resources: [], bindings: [] }; + const asyncOnly = { ...links, resources: links.resources.map(resource => resource === descriptor ? { ...resource, + parameters: resource.parameters.map(parameter => parameter.name === 'standardID' ? { ...parameter, value: 'ivo://ivoa.net/std/SODA#async-1.0' } : parameter) } : resource) }; + const request = { ...baseRequest, region: circle(0.3 / 3600) }; + const missingPlan = await planAccess(root, observation, discovered, request, async () => missing); + const asyncPlan = await planAccess(root, observation, discovered, request, async () => asyncOnly); + assert.equal(missingPlan.products.length, 0); assert.match(missingPlan.issues.join('\n'), /descriptor could not be resolved/u); + assert.equal(asyncPlan.products.length, 0); assert.match(asyncPlan.issues.join('\n'), /synchronous SODA/u); + + const linkError = { ...links, rows: [{ semantics: '#this', error_message: 'fixture link failure' }] }; + const failed = await planAccess(root, observation, discovered, request, async () => linkError); + assert.equal(failed.products.length, 0); assert.match(failed.issues.join('\n'), /fixture link failure/u); + + const capped = await planAccess(root, observation, discovered, { ...request, transferLimits: { scienceBytes: 1_073_741_824, metadataBytes: 33_554_432, nestedEdges: 3, metadataRequests: 1, expandedBytes: 1_073_741_824, packageMembers: 1024 } }, async () => + ({ ...links, effectiveUrl: 'https://example.org/links', rows: [{ semantics: '#this', access_url: '/nested', content_type: 'application/x-votable+xml; content=datalink' }] })); + assert.equal(capped.products.length, 0); assert.match(capped.issues.join('\n'), /request bound reached/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/tools/objects/telescopes/vo/package.mts b/tools/objects/telescopes/vo/package.mts new file mode 100644 index 0000000000..35554f33e6 --- /dev/null +++ b/tools/objects/telescopes/vo/package.mts @@ -0,0 +1,212 @@ +import { spawn } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { astroqueryToolchain } from '../../astronomy-packages/toolchain.mts'; + +export type VoPackageMember = { path: string; bytes: number; sha256: string }; +export type VoPackage = { science: string; members: VoPackageMember[]; format: 'zip' | 'tar' }; +export type VoPackageLimits = { expandedBytes: number; members: number }; + +const PYTHON = String.raw` +import hashlib, json, os, posixpath, re, shutil, stat, sys, tarfile, tempfile, zipfile +import xml.etree.ElementTree as ET +from astropy.io import fits +from pathlib import Path, PureWindowsPath + +def fail(message): + raise ValueError(message) + +def normalized(name, label_reference=False): + if not isinstance(name, str) or not name or '\x00' in name or '\\' in name: + fail('unsafe archive member path') + if name.startswith('/') or PureWindowsPath(name).is_absolute() or re.match(r'^[A-Za-z]:', name): + fail('absolute archive member path') + if not label_reference and '..' in name.split('/'): + fail('archive member path contains traversal') + result = posixpath.normpath(name) + if result in ('', '.', '..') or result.startswith('../') or result.startswith('/'): + fail('archive member path escapes its package') + return result + +def fits_image(path): + try: + with fits.open(path, mode='readonly', memmap=False, lazy_load_hdus=False, ignore_missing_end=False) as product: + product.verify('exception') + return any(isinstance(hdu, (fits.PrimaryHDU, fits.ImageHDU, fits.CompImageHDU)) and hdu.data is not None and hdu.data.ndim >= 2 for hdu in product) + except (OSError, ValueError, KeyError, fits.VerifyError): + return False + +def pds3_dependency(value, logical): + value = value.strip() + if re.fullmatch(r'\d+(?:\s*)?', value, re.IGNORECASE): + return None + if value.startswith('(') and value.endswith(')'): + value = value[1:-1].strip() + if ',' in value: + value = value.split(',', 1)[0].strip() + if value.startswith(('"', "'")): + quote = value[0] + end = value.find(quote, 1) + if end < 0 or value[end + 1:].strip() not in ('',) and not value[end + 1:].lstrip().startswith(','): + fail('unsupported PDS3 pointer syntax: ' + logical) + return value[1:end] + if re.fullmatch(r'[^\s,(){}<>"\']+', value): + return value + fail('unsupported PDS3 pointer syntax: ' + logical) + +def declared_dependencies(logical, path): + lower = logical.lower() + if not lower.endswith(('.lbl', '.lab', '.fmt', '.xml')): + return [] + try: + if lower.endswith('.xml'): + root = ET.parse(path).getroot() + names = [(element.text or '').strip() for element in root.iter() if element.tag.rsplit('}', 1)[-1] == 'file_name'] + if any(not name for name in names): fail('empty PDS4 file_name: ' + logical) + else: + text = Path(path).read_text(encoding='latin1') + names = [] + for line in text.splitlines(): + statement = line.strip() + if statement.upper() == 'END': break + if not statement.startswith('^'): continue + key, separator, value = statement.partition('=') + if not separator or not re.fullmatch(r'\^[A-Za-z][A-Za-z0-9_:-]*', key.strip()): fail('unsupported PDS3 pointer syntax: ' + logical) + name = pds3_dependency(value, logical) + if name is not None: names.append(name) + except (OSError, UnicodeDecodeError, ET.ParseError): + fail('unreadable PDS label: ' + logical) + base = posixpath.dirname(logical) + return [normalized(posixpath.join(base, name), True) for name in names] + +def entries(archive, kind, member_limit, expanded_limit): + if kind == 'zip': + with zipfile.ZipFile(archive) as source: + result = [] + if len(source.infolist()) > member_limit: fail('archive member limit exceeded') + for item in source.infolist(): + mode = item.external_attr >> 16 + directory = item.is_dir() + if stat.S_IFMT(mode) == stat.S_IFLNK: + fail('symbolic link in ZIP archive') + if not directory and stat.S_IFMT(mode) not in (0, stat.S_IFREG): + fail('non-regular member in ZIP archive') + result.append((normalized(item.filename), directory, item)) + return result + with tarfile.open(archive, 'r:*') as source: + result, declared_bytes = [], 0 + for item in source: + if len(result) >= member_limit: fail('archive member limit exceeded') + declared_bytes += item.size + if declared_bytes > expanded_limit: fail('expanded archive byte limit exceeded') + if not (item.isdir() or item.isfile()): + fail('link or device in TAR archive') + result.append((normalized(item.name), item.isdir(), item)) + return result + +def extract(archive, destination, expanded_limit, member_limit): + kind = 'zip' if zipfile.is_zipfile(archive) else 'tar' if tarfile.is_tarfile(archive) else None + if kind is None: + fail('archive is neither ZIP nor TAR') + items = entries(archive, kind, member_limit, expanded_limit) + if len(items) > member_limit: + fail('archive member limit exceeded') + names = [item[0] for item in items] + if len(set(names)) != len(names): + fail('duplicate normalized archive member path') + if (destination / 'members').exists(): + fail('destination already contains members') + stage = Path(tempfile.mkdtemp(prefix='.vo-package-', dir=destination)) + output = stage / 'members' + output.mkdir() + pins, total = [], 0 + try: + def copy_member(logical, source): + nonlocal total + target = output / logical + target.parent.mkdir(parents=True, exist_ok=True) + digest, count = hashlib.sha256(), 0 + with source, open(target, 'xb') as sink: + while True: + chunk = source.read(1024 * 1024) + if not chunk: + break + if total + len(chunk) > expanded_limit: + fail('expanded archive byte limit exceeded') + sink.write(chunk); digest.update(chunk); count += len(chunk); total += len(chunk) + pins.append({'path': logical, 'bytes': count, 'sha256': digest.hexdigest()}) + if kind == 'zip': + with zipfile.ZipFile(archive) as source: + for logical, directory, item in items: + if not directory: + copy_member(logical, source.open(item, 'r')) + else: + with tarfile.open(archive, 'r:*') as source: + for logical, directory, item in items: + if not directory: + stream = source.extractfile(item) + if stream is None: + fail('TAR member cannot be read') + copy_member(logical, stream) + logical_files = {pin['path'] for pin in pins} + for logical in logical_files: + for dependency in declared_dependencies(logical, output / logical): + if dependency not in logical_files: + fail('declared label dependency is missing: ' + dependency) + science = [pin['path'] for pin in pins if pin['path'].lower().endswith(('.fits', '.fit', '.fts')) and fits_image(output / pin['path'])] + if len(science) != 1: + fail('package must contain exactly one FITS science image') + os.rename(output, destination / 'members') + shutil.rmtree(stage) + return {'science': science[0], 'members': sorted(pins, key=lambda pin: pin['path']), 'format': kind} + except BaseException: + shutil.rmtree(stage, ignore_errors=True) + raise + +request = json.load(sys.stdin) +archive = Path(request['archive']).resolve() +destination = Path(request['destination']).resolve() +if not archive.is_file() or not destination.is_dir(): + fail('archive file and caller-created destination directory are required') +print(json.dumps(extract(archive, destination, request['expandedBytes'], request['members']), separators=(',', ':'))) +`; + +function positiveLimit(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer.`); + return value; +} + +function parsePackage(value: unknown): VoPackage { + if (!value || typeof value !== 'object') throw new Error('Package extractor returned no result.'); + const result = value as Record; + if (typeof result.science !== 'string' || (result.format !== 'zip' && result.format !== 'tar') || !Array.isArray(result.members)) + throw new Error('Package extractor returned an invalid result.'); + const members = result.members.map((member): VoPackageMember => { + if (!member || typeof member !== 'object') throw new Error('Package extractor returned an invalid member.'); + const pin = member as Record; + if (typeof pin.path !== 'string' || typeof pin.bytes !== 'number' || !Number.isSafeInteger(pin.bytes) || pin.bytes < 0 || typeof pin.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(pin.sha256)) + throw new Error('Package extractor returned an invalid member pin.'); + return { path: pin.path, bytes: pin.bytes, sha256: pin.sha256 }; + }); + if (!members.some(member => member.path === result.science)) throw new Error('Package science member is not pinned.'); + return { science: result.science, members, format: result.format }; +} + +/** Safely stage one archive's complete pinned member closure into `destination/members`. */ +export async function extractVoPackage(archivePath: string, destination: string, limits: VoPackageLimits): Promise { + positiveLimit(limits.expandedBytes, 'expandedBytes'); + positiveLimit(limits.members, 'members'); + await access(destination); + const toolchain = await astroqueryToolchain(); + return await new Promise((resolve, reject) => { + const child = spawn(toolchain.python, ['-c', PYTHON], { env: { ...process.env, ...toolchain.env }, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; }); + child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; }); + child.on('error', error => reject(new Error(`VO package extractor could not start: ${error.message}`))); + child.on('close', code => { + if (code !== 0) return reject(new Error(`VO package extraction failed: ${stderr.trim() || stdout.trim() || `Python exited ${code}`}`)); + try { resolve(parsePackage(JSON.parse(stdout))); } catch (error) { reject(error); } + }); + child.stdin.end(JSON.stringify({ archive: archivePath, destination, expandedBytes: limits.expandedBytes, members: limits.members })); + }); +} diff --git a/tools/objects/telescopes/vo/package.test.mts b/tools/objects/telescopes/vo/package.test.mts new file mode 100644 index 0000000000..cb009af38c --- /dev/null +++ b/tools/objects/telescopes/vo/package.test.mts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { extractVoPackage } from './package.mts'; + +const PYTHON = String.raw` +import io, sys, tarfile, zipfile +kind, target, mode = sys.argv[1:] +def card(key, value): return f'{key:<8}= {value:>20}'.encode() + b' ' * 50 +header = card('SIMPLE', 'T') + card('BITPIX', '8') + card('NAXIS', '2') + card('NAXIS1', '1') + card('NAXIS2', '1') + b'END' + b' ' * 77 +fits = header + b' ' * (2880 - len(header)) + b'\x00' + b' ' * 2879 +entries = [('science.fits', fits), ('labels/product.lbl', b'^IMAGE = (../science.fits, 1)\n'), ('labels/product.xml', b'../science.fits'), ('labels/format.fmt', b'OBJECT = COLUMN\n')] +if mode == 'two': entries.append(('other.fit', fits)) +if mode == 'missing-label': entries[1] = ('labels/product.lbl', b'^IMAGE = "missing.fits"\n') +if mode == 'missing-fmt': entries[-1] = ('labels/format.fmt', b'^STRUCTURE = missing.fmt\n') +if mode == 'large': entries = [('science.fits', fits + b'x' * 4096)] +if mode == 'traversal': entries = [('../escape.fits', fits)] +if mode == 'duplicate': entries = [('science.fits', fits), ('./science.fits', fits)] +if kind == 'zip': + with zipfile.ZipFile(target, 'w') as archive: + for name, value in entries: + item = zipfile.ZipInfo(name) + if mode == 'link': item.external_attr = 0o120777 << 16 + archive.writestr(item, value) +else: + with tarfile.open(target, 'w') as archive: + for name, value in entries: + item = tarfile.TarInfo(name) + if mode == 'link': item.type = tarfile.SYMTYPE; item.linkname = 'science.fits'; archive.addfile(item) + else: item.size = len(value); archive.addfile(item, io.BytesIO(value)) +`; + +async function workspace(): Promise<{ root: string; staging: string }> { + const root = await mkdtemp(resolve(tmpdir(), 'vo-package-')), staging = resolve(root, 'staging'); + await (await import('node:fs/promises')).mkdir(staging); + return { root, staging }; +} +function archive(root: string, kind: 'zip' | 'tar', mode = 'good'): string { + const path = resolve(root, `package.${kind === 'zip' ? 'zip' : 'tar'}`); + execFileSync('python3', ['-c', PYTHON, kind, path, mode]); + return path; +} + +for (const kind of ['zip', 'tar'] as const) test(`${kind} package stages its FITS image and companions with pins`, async () => { + const { root, staging } = await workspace(); + try { + const result = await extractVoPackage(archive(root, kind), staging, { expandedBytes: 100_000, members: 10 }); + assert.equal(result.format, kind); assert.equal(result.science, 'science.fits'); assert.equal(result.members.length, 4); + assert.equal((await readFile(resolve(staging, 'members', result.science))).subarray(0, 6).toString(), 'SIMPLE'); + assert.ok(result.members.every(member => /^[a-f0-9]{64}$/u.test(member.sha256))); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +for (const mode of ['two', 'missing-label', 'missing-fmt', 'large', 'traversal', 'duplicate'] as const) test(`unsafe or ambiguous ${mode} package leaves staging empty`, async () => { + const { root, staging } = await workspace(); + try { + await assert.rejects(extractVoPackage(archive(root, 'zip', mode), staging, { expandedBytes: mode === 'large' ? 4000 : 100_000, members: 10 })); + assert.deepEqual(await readdir(staging), []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +for (const kind of ['zip', 'tar'] as const) test(`${kind} links and truncated archives never publish`, async () => { + const { root, staging } = await workspace(); + try { + await assert.rejects(extractVoPackage(archive(root, kind, 'link'), staging, { expandedBytes: 100_000, members: 10 }), /link|regular/u); + assert.deepEqual(await readdir(staging), []); + const corrupt = archive(root, kind); + const bytes = await readFile(corrupt); + await (await import('node:fs/promises')).writeFile(corrupt, bytes.subarray(0, Math.min(bytes.length - 1, 612))); + await assert.rejects(extractVoPackage(corrupt, staging, { expandedBytes: 100_000, members: 10 })); + assert.deepEqual(await readdir(staging), []); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('member count and caller-provided destination policy are enforced', async () => { + const { root, staging } = await workspace(); + try { + const input = archive(root, 'tar'); + await assert.rejects(extractVoPackage(input, staging, { expandedBytes: 100_000, members: 2 }), /member limit/u); + await assert.deepEqual(await readdir(staging), []); + await extractVoPackage(input, staging, { expandedBytes: 100_000, members: 10 }); + await assert.rejects(extractVoPackage(input, staging, { expandedBytes: 100_000, members: 10 }), /already contains/u); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/tools/objects/telescopes/vo/qualify.mts b/tools/objects/telescopes/vo/qualify.mts new file mode 100644 index 0000000000..64c27c1a67 --- /dev/null +++ b/tools/objects/telescopes/vo/qualify.mts @@ -0,0 +1,42 @@ +/** Native archive qualification reuses the same FITS and product-science owners as source products. */ +import { dirname, resolve } from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { inspectFits } from '../qualify-source.mts'; +import { readProductScience } from '../product-science.mts'; +import { rememberQualification, type QualifiedObservation } from '../qualified-observations.mts'; +import { readProductRecord, pinFile, writeProductRecord, type ProductRun } from '../../product-record.mts'; +import { digest } from './contracts.mts'; +import { acquireVoProduct, type AcquisitionSpec } from './access.mts'; + +export async function qualifyVoProduct(root: string, spec: AcquisitionSpec): Promise { + if (spec.observation.target.status !== 'confirmed' || spec.observation.target.target !== spec.request.target || typeof spec.observation.rawTarget !== 'string') + throw new Error('The selected archive record does not establish the requested target.'); + const acquired = await acquireVoProduct(root, spec), outputRoot = dirname(acquired.file), acquisition = await readProductRecord(acquired.record); + if (!acquisition) throw new Error('Acquisition record is missing.'); + const pin = await pinFile(acquired.file); + if (!acquisition.outputs.some(p => p.path === 'science.fits' && p.bytes === pin.bytes && p.sha256 === pin.sha256)) throw new Error('Acquired bytes changed.'); + const decoded = inspectFits(await readFile(acquired.file), { OBJECT: spec.observation.rawTarget }, spec.kind); + const archiveId = spec.operation.parameters.ID ?? spec.observation.identities.obs_publisher_did; + if (typeof archiveId === 'string' && archiveId.startsWith('ivo://eso.org/ID?') && decoded.header.ARCFILE !== `${archiveId.slice('ivo://eso.org/ID?'.length)}.fits`) + throw new Error('ESO archive product identity disagrees with the FITS ARCFILE header.'); + const facts = { target: spec.request.target, verified: true, kind: spec.kind, result: 'telescope-product' as const, + ...await readProductScience(root, { file: acquired.file, format: 'fits', target: spec.request.target, decoded, ...(spec.request.region ? { region: spec.request.region } : {}) }) }; + const receipt = resolve(outputRoot, 'qualification.json'), productRecord = resolve(outputRoot, 'qualified.product.json'); + await writeFile(receipt, `${JSON.stringify({ schema: 'cssearth-vo-qualification@1', acquisition: spec.key, artifact: pin, facts, + identity: { target: spec.observation.target, headerObject: decoded.header.OBJECT, parent: spec.observation.identities }, + acceptance: 'Pinned archive response, matching FITS OBJECT, supported native arrays and qualified product metadata. No local recalibration, full-parent equivalence or request fulfillment is implied.' }, null, 2)}\n`); + const run: ProductRun = { telescope: spec.observation.service, stage: 'native-product-qualification', + inputs: [...acquisition.outputs.map(p => ({ role: 'acquired product and metadata', identity: p.path, bytes: p.bytes, sha256: p.sha256 })), + { role: 'acquisition record', identity: 'acquisition.json', ...await pinFile(acquired.record) }], + parameters: { acquisition: spec.key, observation: { decoder: spec.decoder, kind: spec.kind, target: spec.request.target }, operation: spec.operation }, + software: [...acquisition.software, { name: 'cssEarth native product qualification', version: digest(await Promise.all(['./qualify.mts', '../product-science.mts', '../native-metadata.mts', '../calibration-dependencies.mts', '../../astronomy-packages/science.mts', '../../astronomy-packages/requirements.lock'].map(path => readFile(new URL(path, import.meta.url), 'utf8')))) }] }; + await writeProductRecord(productRecord, run, [...acquisition.outputs.map(p => ({ path: p.path, file: resolve(outputRoot, p.path) })), + { path: 'acquisition.json', file: acquired.record }, { path: 'qualification.json', file: receipt }, + ...(facts.calibrationDependencies ?? []).flatMap(d => d.file ? [{ path: d.file, file: resolve(root, d.file) }] : [])], + [{ kind: spec.operation.kind === 'soda-sync' ? 'archive-subset-origin' : 'archive-retrieval-origin', product: 'science.fits', receipt: 'origin.json', + establishes: 'The selected archive service returned these bytes for this exact parent and operation. No recalibration, sample-equivalence or final calibration level is claimed.' }]); + const result: QualifiedObservation = { target: spec.request.target, telescope: spec.observation.service, mode: `native-${spec.kind}`, observation: spec.observation.key, + program: spec.key, product: acquired.file, receipt, productRecord, outputRoot, facts }; + await rememberQualification(root, result); + return result; +} diff --git a/tools/objects/telescopes/vo/session.test.mts b/tools/objects/telescopes/vo/session.test.mts new file mode 100644 index 0000000000..d02765f613 --- /dev/null +++ b/tools/objects/telescopes/vo/session.test.mts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, appendFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { astroquery } from '../../astronomy-packages/client.mts'; +import { saveSession, getSession, type SessionServices } from '../session.mts'; +import { loadQualifiedObservations } from '../qualified-observations.mts'; +import { delivery, listOutputs, exportOutput } from '../outputs.mts'; +import { sessionRequest } from '../session.mts'; +import { parseSnapshot, normalizeSnapshot, SERVICES } from './discovery.mts'; +import { planAccess } from './access.mts'; +import { jsonValue } from './contracts.mts'; +import { qualifyVoProduct } from './qualify.mts'; +import type { QueryInputs } from '../query.mts'; + +test('saved VO choice acquires, qualifies, exports through existing owners, and refuses tampered evidence', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-session-')); + const fixtures = resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo'), fits = await readFile(resolve(fixtures, 'eso-circle.fits')); + const archive = resolve(root, 'science.zip'); + execFileSync('python3', ['-c', "import sys,zipfile\nwith zipfile.ZipFile(sys.argv[2], 'w') as z:\n z.write(sys.argv[1], 'science.fits')\n z.writestr('labels/product.lbl', '^IMAGE = \\\"../science.fits\\\"\\n')", resolve(fixtures, 'eso-circle.fits'), archive]); + const packageBytes = await readFile(archive); + const server = createServer((_request, response) => response.end(packageBytes)); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('No port.'); + try { + const args = ['--target','betelgeuse','--wavelength','0.78,0.85','--kind','image','--any-time','--min-arcsec','1','--result','telescope-product']; + const request = sessionRequest(args), target = { id: request.target, names: ['Betelgeuse'] }, profile = SERVICES[0]!; + const response = (await astroquery({ operation: 'vo-parse', file: resolve(fixtures,'eso-obscore.xml'), url: profile.service, byteLimit: 1e6 })).vo!; + const snapshot = parseSnapshot({ schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, request, query: 'captured fixture', scope: 'fixture replay', sampleLimit: 1, response, completeness: 'bounded-sample' }); + const normalized = normalizeSnapshot(snapshot, profile, target, [target])[0]!; + const observation = { ...normalized, access: { url: `http://127.0.0.1:${address.port}/science.zip`, mime: 'application/zip', estimatedKilobytes: 291 } }; + const plan = await planAccess(root, observation, snapshot, request); assert.equal(plan.products.length, 1); + let available = true; + const load = async (): Promise => ({ ledgers: [], capabilities: [], targetCatalogue: [{ id: target.id, name: 'Betelgeuse', aliases: [] }], targetAssociations: [], bodyMaps: [], + qualifiedProducts: await loadQualifiedObservations(root, target.id), vo: { services: [], records: available ? [{ observation, snapshot, ...plan }] : [] } }); + const api: SessionServices = { load, loadRequest: load, qualify: async (_root, q) => { + assert.equal(q.configuration.kind, 'archive-acquisition'); + return qualifyVoProduct(root, plan.products[0]!); + } }; + const directory = resolve(root, 'delivery'), saved = await saveSession(root, args, directory, api); + assert.equal(saved.choices.length, 1); assert.equal(saved.choices[0]!.state, 'qualify'); + const result = await getSession(root, directory, 1, () => {}, api), data = await delivery(result.resultPath); + assert.equal(data.record.schema, 'cssearth-telescope-delivery@1'); assert.equal(data.record.observation, observation.key); + assert.equal(data.producing.evidence[0]!.kind, 'archive-retrieval-origin'); + assert.ok(data.files.some(f => f.path.endsWith('/acquisition.json'))); assert.ok(data.files.some(f => f.path.endsWith('.xml'))); + assert.ok(data.files.some(f => f.path.endsWith('/archive.zip'))); assert.ok(data.files.some(f => f.path.endsWith('/members/labels/product.lbl'))); + assert.equal(typeof data.record.outputRoot, 'string'); + const originalDelivery = await readFile(result.resultPath, 'utf8'); + await writeFile(result.resultPath, JSON.stringify({ ...data.record, outputRoot: `${String(data.record.outputRoot)}/members` })); + await assert.rejects(delivery(result.resultPath), /absent or ambiguous/u); + await writeFile(result.resultPath, originalDelivery); + const outputs = await listOutputs(result.resultPath); assert.ok(outputs.outputs.some(o => o.kind === 'image' && o.available)); + await exportOutput(result.resultPath, { kind: 'image', hdu: 0 }, resolve(root, 'figure')); + available = false; + const replay = await getSession(root, directory, 1, () => {}, api, { offline: true }); + assert.equal(replay.replay, 'pinned-local-artifact'); + await assert.rejects(getSession(root, directory, 1, () => {}, api), /no longer available/u); + const metadata = data.files.find(f => f.path.endsWith('.xml'))!; + await appendFile(resolve(data.directory, metadata.path), '\nchanged'); + await assert.rejects(delivery(result.resultPath), /pin mismatch/u); + } finally { server.closeAllConnections(); await new Promise(done => server.close(() => done())); await rm(root, { recursive: true, force: true }); } +}); + +test('two saved SODA subsets of one parent keep separate acquisition, qualification, and delivery records', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'vo-two-subsets-')); + const fixtures = resolve(import.meta.dirname, '../../../../tests/fixtures/telescope-vo'), fits = await readFile(resolve(fixtures, 'eso-circle.fits')); + const requests: string[] = []; + const server = createServer((request, response) => { requests.push(request.url!); response.end(fits); }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('No port.'); + try { + const endpoint = `http://127.0.0.1:${address.port}/soda`, profile = SERVICES[0]!; + const base = ['--target','betelgeuse','--wavelength','0.78,0.85','--kind','image','--any-time','--min-arcsec','1','--result','telescope-product']; + const firstArgs = [...base, '--icrs-circle', '88.792938,7.407063,0.00008333333333333333']; + const secondArgs = [...base, '--icrs-circle', '88.792938,7.407063,0.00016666666666666666']; + const discoveredRequest = sessionRequest(firstArgs), target = { id: discoveredRequest.target, names: ['Betelgeuse'] }; + const response = (await astroquery({ operation: 'vo-parse', file: resolve(fixtures,'eso-obscore.xml'), url: profile.service, byteLimit: 1e6 })).vo!; + const snapshot = parseSnapshot({ schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, request: discoveredRequest, + query: 'captured fixture', scope: 'fixture replay', sampleLimit: 1, response, completeness: 'bounded-sample' }); + const normalized = normalizeSnapshot(snapshot, profile, target, [target])[0]!; + const observation = { ...normalized, access: { url: 'https://example.org/links', mime: 'application/x-votable+xml; content=datalink', estimatedKilobytes: null } }; + const localLinksFile = resolve(root, 'links.xml'), originalLinks = await readFile(resolve(fixtures, 'eso-links.xml'), 'utf8'); + await writeFile(localLinksFile, originalLinks.replaceAll('https://dataportal.eso.org/dataPortal/soda/sync', endpoint).replace('ucd="meta.id;meta.dataset"', 'ucd="meta.ref.url;meta.curation"')); + const localLinks = (await astroquery({ operation: 'vo-parse', file: localLinksFile, url: endpoint, byteLimit: 1e6 })).vo!; + const plans = new Map>>(); + const planFor = async (request: ReturnType) => { + const key = JSON.stringify(request), existing = plans.get(key); + if (existing) return existing; + const plan = await planAccess(root, observation, { ...snapshot, request: jsonValue(request) }, request, async () => localLinks); + assert.equal(plan.products.length, 1, plan.issues.join('\n')); plans.set(key, plan); return plan; + }; + const load = async (_root: string, request: ReturnType): Promise => { + const plan = await planFor(request); + return { ledgers: [], capabilities: [], targetCatalogue: [{ id: target.id, name: 'Betelgeuse', aliases: [] }], targetAssociations: [], bodyMaps: [], qualifiedProducts: await loadQualifiedObservations(root, target.id), + vo: { services: [], records: [{ observation, snapshot: { ...snapshot, request: jsonValue(request) }, ...plan }] } }; + }; + const api: SessionServices = { load: async () => load(root, discoveredRequest), loadRequest: load, qualify: async (_root, qualification) => { + const configuration = qualification.configuration; + if (configuration.kind !== 'archive-acquisition') throw new Error('Expected an archive subset qualification.'); + const spec = (await planFor(configuration.request)).products.find(product => product.key === configuration.key); + if (!spec) throw new Error('Saved subset acquisition was not rediscovered.'); + return qualifyVoProduct(root, spec); + } }; + const firstDirectory = resolve(root, 'first'), secondDirectory = resolve(root, 'second'); + const firstSaved = await saveSession(root, firstArgs, firstDirectory, api), secondSaved = await saveSession(root, secondArgs, secondDirectory, api); + assert.equal(firstSaved.choices.length, 1); assert.equal(secondSaved.choices.length, 1); + assert.notEqual(firstSaved.choices[0]!.acquisitionKey, secondSaved.choices[0]!.acquisitionKey); + const first = await getSession(root, firstDirectory, 1, () => {}, api); + const second = await getSession(root, secondDirectory, 1, () => {}, api); + const firstDelivery = await delivery(first.resultPath), secondDelivery = await delivery(second.resultPath); + assert.notEqual(firstDelivery.record.choice, secondDelivery.record.choice); + const firstScience = firstDelivery.files.find(file => file.path.endsWith('science.fits'))!, secondScience = secondDelivery.files.find(file => file.path.endsWith('science.fits'))!; + assert.notEqual(firstScience.path, secondScience.path); + assert.equal(firstScience.sha256, secondScience.sha256); + const qualifications = await readdir(resolve(root, 'output/telescopes', target.id, 'qualifications')); + assert.equal(qualifications.filter(name => name.endsWith('.json')).length, 2); + assert.ok(requests.some(url => url.includes('CIRCLE=88.792938'))); assert.equal(requests.length, 2); + const replay = await getSession(root, firstDirectory, 1, () => {}, api); + assert.equal(replay.reused, true); assert.equal((await delivery(replay.resultPath)).record.choice, firstDelivery.record.choice); + } finally { server.closeAllConnections(); await new Promise(done => server.close(() => done())); await rm(root, { recursive: true, force: true }); } +}); diff --git a/tools/objects/telescopes/vo/vo.test.mts b/tools/objects/telescopes/vo/vo.test.mts new file mode 100644 index 0000000000..278c55eb68 --- /dev/null +++ b/tools/objects/telescopes/vo/vo.test.mts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, readFile, writeFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { createServer } from 'node:http'; +import { astroquery, VoAccessError } from '../../astronomy-packages/client.mts'; +import { canonical, digest, parseMetadata, parseLimits, parseRegion, recordKey, acquisitionKey, type DiscoverySnapshot, type MetadataResponse, type Resource } from './contracts.mts'; +import { associateTarget, normalizeSnapshot, SERVICES, targetQuery } from './discovery.mts'; +import { mediaType, planAccess, sodaParameters } from './access.mts'; + +const root = resolve(import.meta.dirname, '../../../..'), fixtures = resolve(root, 'tests/fixtures/telescope-vo'); +const profile = SERVICES[1]!; +const target = { id: 'betelgeuse', names: ['Betelgeuse'], classification: 'star', classificationSource: 'fixture catalogue' }; +const request = { target: target.id, wavelengthMicrometres: [0.78, 0.85] as const, kind: 'image' as const, result: 'telescope-product' as const }; +const circle = { frame: 'icrs' as const, shape: 'circle' as const, raDegrees: 88.792938, decDegrees: 7.407063, radiusDegrees: 0.3 / 3600 }; +const parse = async (file: string) => (await astroquery({ operation: 'vo-parse', file: resolve(fixtures, file), url: 'https://example.org/links', byteLimit: 1e6, timeFormat: 'mjd', timeScale: 'utc' })).vo!; +const almaPromise = parse('alma-obscore.xml'), esoPromise = parse('eso-links.xml'); +function snapshot(response: MetadataResponse): DiscoverySnapshot { return { schema: 'cssearth-vo-discovery@1', service: profile.service, table: profile.table, model: profile.model, + request, query: 'SELECT TOP 2 * FROM ivoa.obscore', scope: 'two-row sample', sampleLimit: 2, response, completeness: 'bounded-sample' }; } +function esoDescriptor(response: MetadataResponse) { return response.resources.find(r => r.parameters.some(p => p.value === 'ivo://ivoa.net/std/SODA#sync-1.0'))!; } + +test('metadata preserves raw evidence, nulls, field widths and distinct observations sharing one dataset', async () => { + const response = await almaPromise, saved = snapshot(response); + assert.equal(response.queryStatus, 'OK'); assert.equal(response.rows.length, 2); + assert.equal(response.raw.sha256, 'ea9d3eda733a4299d5b98fb2110b2f0cf26e410fafc0fcbbeb5b84621f49226f'); + assert.equal(response.fields.find(f => f.name === 'access_format')!.arraysize, '9'); + assert.equal(response.rows[0]!.access_format, 'applicati'); + assert.equal(response.rows[0]!.obs_publisher_did, response.rows[1]!.obs_publisher_did); + assert.notEqual(recordKey(saved, response.rows[0]!, profile.identityColumns), recordKey(saved, response.rows[1]!, profile.identityColumns)); + assert.ok(response.issues.some(i => i.includes('too long'))); + assert.equal(response.times[0]!.t_min, '2023-08-03T11:12:29.578Z'); + assert.throws(() => parseMetadata({ ...response, rows: undefined }), /array/u); + assert.throws(() => parseMetadata({ ...response, rows: [{}] }), /fields/u); +}); +test('canonical identities preserve opaque strings and reject lossy or undefined values', () => { + assert.equal(canonical({ b: 2, a: null }), canonical({ a: null, b: 2 })); + assert.notEqual(digest({ id: 'ABC/1' }), digest({ id: 'abc/1' })); + assert.notEqual(digest({ a: null }), digest({})); + assert.throws(() => canonical({ a: undefined })); assert.throws(() => canonical(2 ** 54)); assert.throws(() => canonical(NaN)); + const limits = parseLimits(); + assert.notEqual(acquisitionKey('p', { BAND: [1, 2] }, {}, limits, 'v1'), acquisitionKey('p', { BAND: [1, 3] }, {}, limits, 'v1')); + assert.notEqual(acquisitionKey('p', {}, {}, limits, 'v1'), acquisitionKey('p', {}, {}, { ...limits, scienceBytes: 123 }, 'v1')); +}); +test('Astropy boundary distinguishes JD and MJD, converts declared time scales and preserves unsafe integers', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-time-')); + const file = resolve(directory, 'time.xml'); + await writeFile(file, `
24515459007199254740993
`); + try { + const result = (await astroquery({ operation: 'vo-parse', file, url: 'https://example.org/tap', byteLimit: 10000, timeFormat: 'jd', timeScale: 'tt' })).vo!; + assert.equal(result.times[0]!.time_min, '2000-01-01T11:58:55.816Z'); + assert.deepEqual(result.rows[0]!.id, { integer: '9007199254740993' }); + const unscaled = (await astroquery({ operation: 'vo-parse', file, url: 'https://example.org/tap', byteLimit: 10000, timeFormat: 'jd' })).vo!; + assert.equal(unscaled.times[0]!.time_min, null); + } finally { await rm(directory, { recursive: true, force: true }); } +}); +test('target association respects catalogue class collisions and does not equate sky position with identity', () => { + const moon = { id: 'io', names: ['Io'], classification: 'satellite', classificationSource: 'package' }; + const asteroid = { id: 'asteroid-85-io', names: ['85 Io','Io'], classification: 'asteroid', classificationSource: 'package' }; + assert.equal(associateTarget('Io', null, moon, [moon, asteroid]).status, 'ambiguous'); + assert.equal(associateTarget('Io', 'satellite', moon, [moon, asteroid]).status, 'confirmed'); + assert.notEqual(associateTarget('Io', 'asteroid', moon, [moon, asteroid]).status, 'confirmed'); + assert.equal(associateTarget('Io#Europa', 'satellite', moon, [moon]).status, 'ambiguous'); + assert.equal(associateTarget('Jupiter', null, moon, [moon]).status, 'unmatched'); +}); +test('normalization retains unknown time and malformed MIME without promoting archive flags', async () => { + const response = await almaPromise, saved = snapshot(response), row = response.rows[0]!; + const t = { id: 'test', names: [String(row.target_name)] }; + const result = normalizeSnapshot(saved, profile, t, [t])[0]!; + assert.ok(result.issues.some(i => i.includes('Malformed'))); assert.equal(result.access.mime, 'applicati'); + assert.equal('verified' in result, false); + const noTimes = { ...response, times: response.rows.map(() => ({})) }; + assert.equal(normalizeSnapshot(snapshot(noTimes), profile, t, [t])[0]!.startIso, null); +}); +test('EPN frequency bounds reverse, body coordinates remain body coordinates, and missing bounds remain null', async () => { + const base = await almaPromise; + const values = { spectral_range_min: 1e14, spectral_range_max: 2e14, target_name: 'Betelgeuse', target_class: 'star', spatial_frame_type: 'body', c1min: 350, c1max: 10, granule_uid: 'Case:1', dataproduct_type: 'im' }; + const fields = Object.keys(values).map(name => ({ name, id: name, datatype: name.includes('spectral') ? 'double' : 'char', arraysize: null, unit: name.includes('spectral') ? 'Hz' : null, ucd: null, utype: null, xtype: null, ref: null })); + const response = { ...base, fields, rows: [values], times: [{}], bindings: [] }; + const p = SERVICES[2]!, s = { ...snapshot(response), service: p.service, table: p.table, model: p.model }; + const result = normalizeSnapshot(s, p, target, [target])[0]!; + assert.deepEqual(result.wavelengthsMicrometres, [1.49896229, 2.99792458]); assert.equal(result.spatial.frame, 'body'); + assert.equal(result.spatial.coordinates.c1min, 350); assert.equal(result.spatial.coordinates.c1max, 10); + assert.equal(result.startIso, null); assert.equal(result.kind, 'image'); + assert.equal(normalizeSnapshot({ ...s, response: { ...response, rows: [{ ...values, spectral_range_max: 0 }] } }, p, target, [target])[0]!.wavelengthsMicrometres[0], null); +}); +test('bounded queries escape literals and invalid requests cannot manufacture archive-wide completeness', () => { + assert.match(targetQuery(profile, ["Barnard's star"]), /Barnard''s star/u); + assert.throws(() => targetQuery({ ...profile, table: 'x;DROP TABLE x' }, ['a'])); + assert.throws(() => parseLimits({ scienceBytes: 0 })); assert.throws(() => parseRegion({ ...circle, frame: 'body' })); + assert.equal(mediaType('applicati'), null); assert.equal(mediaType('application/x-votable+xml; content=datalink')!.parameters.content, 'datalink'); +}); +test('live-captured ESO and PSA scalar masks decode without losing the response', async () => { + for (const file of ['eso-obscore.xml', 'psa-epn.xml']) { + const response = await parse(file); + assert.equal(response.queryStatus, file === 'psa-epn.xml' ? 'OVERFLOW' : 'OK', response.issues.join('\n')); + assert.equal(response.rows.length, 1); assert.ok(Object.values(response.rows[0]!).some(v => v === null)); + } +}); +test('ESO fixed-ID and CIRCLE resolve via PyVO; a nonexistent BAND capability is refused', async () => { + const eso = await esoPromise, descriptor = esoDescriptor(eso), binding = eso.bindings.find(b => b.serviceId === descriptor.id)!; + assert.equal(binding.error, null); assert.equal(binding.parameters.ID, 'ivo://eso.org/ID?ADP.2026-08-19T13:19:07.647'); + assert.deepEqual(sodaParameters(descriptor, { ...request, region: circle }, binding.parameters).CIRCLE, [circle.raDegrees, circle.decDegrees, circle.radiusDegrees]); + assert.throws(() => sodaParameters(descriptor, { ...request, spectralFrame: 'barycentric' }, binding.parameters), /BAND/u); + assert.throws(() => sodaParameters({ ...descriptor, groups: [] }, { ...request, region: circle }, binding.parameters), /missing/u); +}); +test('BAND includes continuum support and requires the explicit spectral frame', async () => { + const descriptor = esoDescriptor(await esoPromise); + const band: Resource = { ...descriptor, groups: descriptor.groups.map(g => ({ ...g, parameters: [...g.parameters, { name: 'BAND', id: null, datatype: 'double', arraysize: '2', unit: 'm', ucd: 'em.wl;stat.interval', utype: null, xtype: 'interval', ref: null, value: null }] })) }; + const r = { ...request, kind: 'cube' as const, wavelengthMicrometres: [2.30, 2.34] as const, continuumMicrometres: [[2.2, 2.25], [2.4, 2.45]] as const }; + assert.throws(() => sodaParameters(band, r, { ID: 'opaque' }), /explicit/u); + const values = sodaParameters(band, { ...r, spectralFrame: 'barycentric' }, { ID: 'opaque' }).BAND; + assert.ok(Array.isArray(values)); + assert.ok(Math.abs(Number(values[0]) - 2.2e-6) < 1e-20); assert.ok(Math.abs(Number(values[1]) - 2.45e-6) < 1e-20); +}); +test('multiple science links stay distinct, previews are excluded, cycles are bounded, and subsets never become direct access', async () => { + const base = await almaPromise, saved = snapshot(base), obs = { ...normalizeSnapshot(saved, profile, target, [target])[0]!, kind: 'image', target: { status: 'confirmed' as const, target: target.id, reason: 'fixture' }, access: { url: 'https://example.org/links', mime: 'application/x-votable+xml;content=datalink', estimatedKilobytes: null } }; + const response = { ...base, bindings: [], rows: [ + { semantics: '#this', access_url: 'one.fits', content_type: 'image/fits' }, + { semantics: '#this', access_url: 'two.fits', content_type: 'image/fits' }, + { semantics: '#preview', access_url: 'preview.fits', content_type: 'image/fits' }, + { semantics: '#this', access_url: '/links', content_type: 'application/x-votable+xml;content=datalink' }, + ] }; + let calls = 0; + const plan = await planAccess(root, obs, saved, request, async () => { calls++; return response; }); + assert.equal(plan.products.length, 2); assert.equal(calls, 1); assert.notEqual(plan.products[0]!.key, plan.products[1]!.key); + assert.equal(plan.products[0]!.operation.url, 'https://example.org/one.fits'); + assert.equal((await planAccess(root, obs, saved, { ...request, region: circle }, async () => response)).products.length, 0); +}); +test('bounded transfer rejects chunked oversized and error bodies without publishing partial files', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-transfer-')), fixture = await readFile(resolve(fixtures, 'eso-circle.fits')); + const requests: string[] = []; + const server = createServer((req, res) => { requests.push(req.url!); if (req.url === '/error') { res.end('error'); return; } res.write(fixture.subarray(0,100)); res.end(fixture.subarray(100)); }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('No test server port.'); + const url = `http://127.0.0.1:${address.port}`; + try { + await assert.rejects(astroquery({ operation: 'vo-download', url: `${url}/large`, destination: resolve(directory,'large.fits'), byteLimit: 1024, parameters: {} }), /byte limit/u); + await assert.rejects(astroquery({ operation: 'vo-download', url: `${url}/error`, destination: resolve(directory,'error.fits'), byteLimit: 1e6, parameters: {} })); + const result = await astroquery({ operation: 'vo-download', url: `${url}/small`, destination: resolve(directory,'small.fits'), byteLimit: 1e6, parameters: {} }); + assert.equal(result.transfer!.file.bytes, 290880); assert.equal(result.transfer!.file.sha256, 'fd2a2d371e121bb50f64d781ac57b60f2f76d2c25d71f1c6a5ab9b5e262def60'); + assert.deepEqual(await readdir(directory), ['small.fits']); assert.deepEqual(requests, ['/large','/error','/small']); + } finally { server.closeAllConnections(); await new Promise(done => server.close(() => done())); await rm(directory, { recursive: true, force: true }); } +}); +test('failed SODA requests never ask for the whole product and never publish a partial file', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-soda-failure-')), requests: string[] = []; + const server = createServer((req, res) => { requests.push(req.url!); res.writeHead(503); res.end('deliberate subset failure'); }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('No test server port.'); + const url = `http://127.0.0.1:${address.port}/soda`; + try { + const original = await readFile(resolve(fixtures, 'eso-links.xml'), 'utf8'), file = resolve(directory, 'links.xml'); + await writeFile(file, original.replaceAll('https://dataportal.eso.org/dataPortal/soda/sync', url)); + const metadata = (await astroquery({ operation: 'vo-parse', file, url, byteLimit: 100000 })).vo!; + const binding = metadata.bindings.find(b => b.url === url)!; + await assert.rejects(astroquery({ operation: 'vo-download', url, destination: resolve(directory, 'science.fits'), byteLimit: 1e6, + descriptor: { file: metadata.raw, row: binding.row, serviceId: binding.serviceId }, parameters: { ...binding.parameters, CIRCLE: [circle.raDegrees, circle.decDegrees, circle.radiusDegrees] } }), /503/u); + assert.equal(requests.length, 1); assert.match(requests[0]!, /^\/soda\?/u); assert.ok(!requests.some(r => r.includes('/file') || r.includes('/parent'))); + assert.deepEqual(await readdir(directory), ['links.xml']); + } finally { server.closeAllConnections(); await new Promise(done => server.close(() => done())); await rm(directory, { recursive: true, force: true }); } +}); + + +test('top-level TIMESYS/COOSYS survive and unresolved explicit references never inherit a profile time scale', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-timesys-')), file = resolve(directory, 'systems.xml'); + const xml = (ref: string) => `
2451545
`; + try { + await writeFile(file, xml('clock')); + const request = { operation: 'vo-parse' as const, file, url: 'https://example.org/tap', byteLimit: 10000, timeFormat: 'jd' as const, timeScale: 'utc' as const }; + const declared = (await astroquery(request)).vo!; + assert.equal(declared.times[0]!.time_min, '2000-01-01T11:58:55.816Z'); + assert.equal(declared.timeSystems.length, 1); assert.equal(declared.coordinateSystems.length, 1); + await writeFile(file, xml('missing')); + const unresolved = (await astroquery(request)).vo!; + assert.equal(unresolved.times[0]!.time_min, null); + assert.ok(unresolved.issues.some(i => i.includes('unresolved or ambiguous TIMESYS'))); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + +test('transfer failures distinguish authentication, no content, error payloads and interrupted streams', async () => { + const directory = await mkdtemp(resolve(tmpdir(), 'vo-failure-states-')); + const server = createServer((req, res) => { + if (req.url === '/auth') { res.writeHead(403); res.end('private'); } + else if (req.url === '/empty') { res.writeHead(204); res.end(); } + else if (req.url === '/broken') { res.writeHead(200, { 'Content-Length': '10000' }); res.write('SIMPLE ='); setTimeout(() => res.destroy(), 10); } + else res.end('not science'); + }); + await new Promise(done => server.listen(0, '127.0.0.1', done)); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('No port.'); + try { + for (const [path, code] of [['auth','authentication'], ['empty','no-content'], ['html','protocol'], ['broken','interrupted']]) { + await assert.rejects(astroquery({ operation: 'vo-download', url: `http://127.0.0.1:${address.port}/${path}`, destination: resolve(directory, `${path}.fits`), byteLimit: 100000, parameters: {} }), + error => error instanceof VoAccessError && error.code === code); + } + assert.deepEqual(await readdir(directory), []); + } finally { server.closeAllConnections(); await new Promise(done => server.close(() => done())); await rm(directory, { recursive: true, force: true }); } +}); diff --git a/tools/objects/terrestrial-layers/isis3-raster.mts b/tools/objects/terrestrial-layers/isis3-raster.mts index 5b19898c9e..a60f075ed7 100644 --- a/tools/objects/terrestrial-layers/isis3-raster.mts +++ b/tools/objects/terrestrial-layers/isis3-raster.mts @@ -64,7 +64,7 @@ export async function loadIsis3Raster(path: string, grid: unknown) { } /** Native numeric core shared by mapped rasters and telescope qualification. No projection is inferred. */ -export function isis3CoreHeader(bytes: Buffer) { +export function isis3CoreHeader(bytes: Buffer, requireTarget = true) { const label=bytes.subarray(0,128*1024).toString('latin1'); if(!/^Object\s*=\s*IsisCube/mu.test(label)) throw new Error('Not an ISIS3 cube.'); const core=/Object\s*=\s*Core\s+([\s\S]*?)End_Object/u.exec(label)?.[1]; @@ -78,11 +78,11 @@ export function isis3CoreHeader(bytes: Buffer) { const order=field(core,'ByteOrder'); if(!['Tile','BandSequential'].includes(format??'')||field(core,'Type')!=='Real'||!['Lsb','Msb'].includes(order??''))throw new Error('Unsupported ISIS3 core encoding.'); const identity:Record=Object.fromEntries(['TargetName','SpacecraftName','InstrumentId','ProductId'].flatMap(key=>{const v=field(label,key);return v===undefined?[]:[[key,v]];})); - if(!identity.TargetName)throw new Error('ISIS3 target identity is absent.'); + if(requireTarget&&!identity.TargetName)throw new Error('ISIS3 target identity is absent.'); return {coreFile:field(core,'\\^Core'),width,height,bands,start,tileWidth,tileHeight,base,multiplier,littleEndian:order==='Lsb',identity}; } -export function decodeIsis3Core(bytes: Buffer, label: Buffer = bytes) { - const h=isis3CoreHeader(label),columns=Math.ceil(h.width/h.tileWidth),rows=Math.ceil(h.height/h.tileHeight),plane=columns*rows*h.tileWidth*h.tileHeight; +export function decodeIsis3Core(bytes: Buffer, label: Buffer = bytes, requireTarget = true) { + const h=isis3CoreHeader(label,requireTarget),columns=Math.ceil(h.width/h.tileWidth),rows=Math.ceil(h.height/h.tileHeight),plane=columns*rows*h.tileWidth*h.tileHeight; if(!Number.isSafeInteger(plane*h.bands*4)||h.start+plane*h.bands*4>bytes.length)throw new Error('Truncated ISIS3 raster.'); const data=new Float32Array(h.width*h.height*h.bands),threshold=Buffer.from('faff7fff','hex').readFloatLE(); for(let b=0;b